YouTube - SmartSponsorBlock

Automatically skip sponsor segments in YouTube videos using SponsorBlock API

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         YouTube - SmartSponsorBlock
// @description  Automatically skip sponsor segments in YouTube videos using SponsorBlock API
// @namespace    http://tampermonkey.net/
// @icon         https://cdn-icons-png.flaticon.com/64/2504/2504965.png
// @supportURL   https://github.com/5tratz/Tampermonkey-Scripts/issues
// @version      0.0.9
// @author       5tratz
// @match        https://www.youtube.com/*
// @match        https://m.youtube.com/*
// @grant        GM_xmlhttpRequest
// @connect      api.sponsor.ajay.app
// @license      MIT
// ==/UserScript==

(() => {
    'use strict';

    /* ================= CONFIG ================= */

    const SB_API = 'https://api.sponsor.ajay.app/api/skipSegments';
    const SKIP_CATEGORIES = ['sponsor', 'selfpromo', 'exclusive_access', 'interaction'];
    const SKIP_PADDING = 0.35;
    const CHECK_INTERVAL = 100; // Check more frequently for better accuracy
    const MAX_RETRIES = 3; // Retry failed API calls

    /* ================= STATE ================= */

    const segmentCache = new Map();
    let currentVideoId = null;
    let currentVideoEl = null;
    let skipInProgress = false; // Prevent multiple simultaneous skips
    let retryCount = 0;

    /* ================= UTILS ================= */

    const log = (...args) => console.debug('[SmartSponsorBlock]', ...args);
    const warn = (...args) => console.warn('[SmartSponsorBlock]', ...args);

    function getVideoId() {
        try {
            const url = new URL(location.href);

            // Normal videos
            if (url.searchParams.get('v')) {
                return url.searchParams.get('v');
            }

            // Shorts
            if (location.pathname.includes('/shorts/')) {
                const shorts = location.pathname.split('/shorts/')[1]?.split(/[?#]/)[0];
                if (shorts) return shorts;
            }

            // Embedded videos
            if (location.pathname.includes('/embed/')) {
                const embed = location.pathname.split('/embed/')[1]?.split(/[?#]/)[0];
                if (embed) return embed;
            }

            // Fallback to meta tag
            const meta = document.querySelector('meta[itemprop="videoId"]');
            if (meta) {
                return meta.getAttribute('content');
            }

            // Try to find video ID in page data
            const ytInitialData = document.getElementById('ytd-player') ||
                                 document.querySelector('script[nonce]');
            if (ytInitialData) {
                const dataStr = ytInitialData.textContent || '';
                const match = dataStr.match(/"videoId":"([^"]+)"/);
                if (match) return match[1];
            }
        } catch (e) {
            warn('Error getting video ID:', e);
        }
        return null;
    }

    function getVideoElement() {
        return document.querySelector('video');
    }

    function waitForElement(selector, timeout = 5000) {
        return new Promise((resolve) => {
            const element = document.querySelector(selector);
            if (element) {
                resolve(element);
                return;
            }

            const observer = new MutationObserver((mutations, obs) => {
                const element = document.querySelector(selector);
                if (element) {
                    obs.disconnect();
                    resolve(element);
                }
            });

            observer.observe(document.body, {
                childList: true,
                subtree: true
            });

            setTimeout(() => {
                observer.disconnect();
                resolve(null);
            }, timeout);
        });
    }

    /* ================= SPONSORBLOCK ================= */

    function fetchSegments(videoId, callback, retry = 0) {
        if (segmentCache.has(videoId)) {
            callback(segmentCache.get(videoId));
            return;
        }

        const url = new URL(SB_API);
        url.searchParams.append('videoID', videoId);
        url.searchParams.append('categories', JSON.stringify(SKIP_CATEGORIES));

        // Add minimal fields parameter to reduce response size
        url.searchParams.append('action', 'skipSegments');

        log(`Fetching segments for video ${videoId}`);

        GM_xmlhttpRequest({
            method: 'GET',
            url: url.toString(),
            headers: {
                'Accept': 'application/json',
                'Content-Type': 'application/json'
            },
            timeout: 10000, // 10 second timeout
            onload: res => {
                try {
                    if (res.status === 404) {
                        // No segments found
                        segmentCache.set(videoId, []);
                        callback([]);
                        return;
                    }

                    if (res.status !== 200) {
                        throw new Error(`HTTP ${res.status}`);
                    }

                    const data = JSON.parse(res.responseText);
                    const segments = Array.isArray(data)
                        ? data
                              .filter(item => item.segment && item.segment.length === 2)
                              .map(item => ({
                                  start: Math.max(0, item.segment[0]),
                                  end: item.segment[1],
                                  category: item.category,
                                  UUID: item.UUID,
                                  videoDuration: item.videoDuration
                              }))
                              .filter(s => s.end > s.start && s.end - s.start > 0.5) // Ignore very short segments
                              .sort((a, b) => a.start - b.start)
                        : [];

                    // Merge overlapping or adjacent segments
                    const merged = [];
                    for (const s of segments) {
                        const last = merged[merged.length - 1];
                        if (!last || s.start > last.end + 0.1) { // Add small gap tolerance
                            merged.push({ ...s });
                        } else {
                            last.end = Math.max(last.end, s.end);
                        }
                    }

                    log(`Found ${merged.length} sponsor segments for video ${videoId}`);
                    segmentCache.set(videoId, merged);
                    retryCount = 0; // Reset retry count on success
                    callback(merged);
                } catch (e) {
                    warn('Failed to parse SponsorBlock response:', e);

                    if (retry < MAX_RETRIES) {
                        setTimeout(() => {
                            fetchSegments(videoId, callback, retry + 1);
                        }, 1000 * (retry + 1)); // Exponential backoff
                    } else {
                        callback([]);
                    }
                }
            },
            onerror: (err) => {
                warn('Network error fetching segments:', err);

                if (retry < MAX_RETRIES) {
                    setTimeout(() => {
                        fetchSegments(videoId, callback, retry + 1);
                    }, 1000 * (retry + 1));
                } else {
                    callback([]);
                }
            },
            ontimeout: () => {
                warn('Request timeout');

                if (retry < MAX_RETRIES) {
                    setTimeout(() => {
                        fetchSegments(videoId, callback, retry + 1);
                    }, 1000 * (retry + 1));
                } else {
                    callback([]);
                }
            }
        });
    }

    /* ================= SKIP LOGIC ================= */

    function attachSkipper(videoId) {
        const video = getVideoElement();
        if (!video) {
            // Try to wait for video element
            waitForElement('video').then(videoEl => {
                if (videoEl && videoId) {
                    initializeSkipper(videoId, videoEl);
                }
            });
            return;
        }

        initializeSkipper(videoId, video);
    }

    function initializeSkipper(videoId, video) {
        // Skip if same video and element
        if (video === currentVideoEl && videoId === currentVideoId) {
            return;
        }

        // Clean up old listeners
        if (currentVideoEl) {
            currentVideoEl._skipCleanup?.();
        }

        currentVideoEl = video;
        currentVideoId = videoId;

        log(`Initializing skipper for video ${videoId}`);

        fetchSegments(videoId, segments => {
            if (!segments.length) {
                log('No sponsor segments found for this video');
                return;
            }

            let nextIndex = 0;
            let checkTimeout = null;
            let isActive = true;

            const findCurrentSegment = (time) => {
                return segments.find(s => time >= s.start && time < s.end);
            };

            const findNextSegmentIndex = (time) => {
                return segments.findIndex(s => time < s.end);
            };

            const performSkip = (segment) => {
                if (skipInProgress) return;

                skipInProgress = true;

                try {
                    const targetTime = segment.end + SKIP_PADDING;
                    // Ensure we don't skip beyond video duration
                    if (targetTime < video.duration) {
                        video.currentTime = targetTime;
                        log(`✅ Skipped sponsor: ${segment.start.toFixed(2)} → ${segment.end.toFixed(2)}`);

                        // Update nextIndex after skip
                        nextIndex = findNextSegmentIndex(targetTime);
                        if (nextIndex === -1) nextIndex = segments.length;
                    }
                } catch (e) {
                    warn('Error during skip:', e);
                } finally {
                    skipInProgress = false;
                }
            };

            const checkAndSkip = () => {
                if (!isActive || !video || video.paused) return;

                const currentTime = video.currentTime;

                // Find if we're in a segment
                const currentSegment = findCurrentSegment(currentTime);

                if (currentSegment) {
                    performSkip(currentSegment);
                } else {
                    // Update nextIndex for future checks
                    nextIndex = findNextSegmentIndex(currentTime);
                    if (nextIndex === -1) nextIndex = segments.length;
                }
            };

            const handleSeeking = () => {
                if (!isActive) return;

                const currentTime = video.currentTime;
                const currentSegment = findCurrentSegment(currentTime);

                if (currentSegment) {
                    log('User seeked into sponsor, skipping');
                    performSkip(currentSegment);
                } else {
                    nextIndex = findNextSegmentIndex(currentTime);
                    if (nextIndex === -1) nextIndex = segments.length;
                }
            };

            const handlePlay = () => {
                // Check immediately when video starts playing
                checkAndSkip();
            };

            const handleLoadedMetadata = () => {
                // Reset state when video metadata loads
                nextIndex = findNextSegmentIndex(video.currentTime);
                if (nextIndex === -1) nextIndex = segments.length;
            };

            // Add event listeners
            video.addEventListener('timeupdate', checkAndSkip);
            video.addEventListener('seeking', handleSeeking);
            video.addEventListener('play', handlePlay);
            video.addEventListener('loadedmetadata', handleLoadedMetadata);

            // Also check periodically for better reliability
            const intervalId = setInterval(checkAndSkip, CHECK_INTERVAL);

            // Store cleanup function
            video._skipCleanup = () => {
                isActive = false;
                video.removeEventListener('timeupdate', checkAndSkip);
                video.removeEventListener('seeking', handleSeeking);
                video.removeEventListener('play', handlePlay);
                video.removeEventListener('loadedmetadata', handleLoadedMetadata);
                clearInterval(intervalId);
                if (checkTimeout) clearTimeout(checkTimeout);
            };

            // Check immediately in case we're already in a segment
            setTimeout(checkAndSkip, 100);
        });
    }

    /* ================= PAGE OBSERVER ================= */

    let navigationTimeout = null;

    function handleNavigation() {
        if (navigationTimeout) {
            clearTimeout(navigationTimeout);
        }

        navigationTimeout = setTimeout(() => {
            const videoId = getVideoId();
            if (videoId) {
                log(`Navigation detected, video ID: ${videoId}`);
                attachSkipper(videoId);
            }
        }, 800); // Slightly longer delay for YouTube to fully initialize
    }

    function observePage() {
        // YouTube navigation events
        document.addEventListener('yt-navigate-finish', handleNavigation);
        document.addEventListener('yt-page-data-updated', handleNavigation);

        // Also listen for video element changes
        const observer = new MutationObserver((mutations) => {
            for (const mutation of mutations) {
                if (mutation.type === 'childList') {
                    const addedNodes = Array.from(mutation.addedNodes);
                    const hasVideo = addedNodes.some(node =>
                        node.nodeName === 'VIDEO' ||
                        (node.querySelector && node.querySelector('video'))
                    );

                    if (hasVideo) {
                        const videoId = getVideoId();
                        if (videoId && videoId !== currentVideoId) {
                            handleNavigation();
                        }
                    }
                }
            }
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });

        // Initial check
        handleNavigation();

        // Fallback periodic check
        setInterval(() => {
            const videoId = getVideoId();
            if (videoId && videoId !== currentVideoId) {
                handleNavigation();
            }
        }, 2000);
    }

    // Start the script
    log('Starting SmartSponsorBlock');
    observePage();
})();