YouTube Auto-Resume

Seamlessly continue any YouTube video where you left off. This script automatically saves your playback position and features intelligent playlist handling: your progress within a playlist is saved separately, keeping it distinct from your progress on the same video watched elsewhere. Old data is cleaned up automatically.

// ==UserScript==
// @name                    YouTube Auto-Resume
// @name:zh-TW              YouTube 自動續播
// @name:zh-CN              YouTube 自动续播
// @name:ja                 YouTube 自動レジューム
// @icon                    https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @author                  ElectroKnight22
// @namespace               electroknight22_youtube_auto_resume_namespace
// @version                 2.2.6
// @match                   *://www.youtube.com/*
// @match                   *://m.youtube.com/*
// @match                   *://www.youtube-nocookie.com/*
// @exclude                 *://music.youtube.com/*
// @exclude                 *://studio.youtube.com/*
// @exclude                 *://*.youtube.com/embed/*
// @exclude                 *://www.youtube.com/live_chat*
// @require                 https://update.greasyfork.org/scripts/549881/1669057/YouTube%20Helper%20API.js
// @grant                   GM.getValue
// @grant                   GM.setValue
// @grant                   GM.deleteValue
// @grant                   GM.listValues
// @grant                   GM_getValue
// @grant                   GM_setValue
// @grant                   GM_deleteValue
// @grant                   GM_listValues
// @run-at                  document-idle
// @inject-into             page
// @license                 MIT
// @description             Seamlessly continue any YouTube video where you left off. This script automatically saves your playback position and features intelligent playlist handling: your progress within a playlist is saved separately, keeping it distinct from your progress on the same video watched elsewhere. Old data is cleaned up automatically.
// @description:zh-TW       無縫接續播放任何 YouTube 影片,從您上次離開的地方繼續觀看。此腳本會自動儲存您的播放進度,並擁有智慧型播放清單處理功能:您在播放清單中的進度會被獨立儲存,不會影響您在其他地方觀看同部影片的紀錄。此外,它還能以獨特規則處理 Shorts 和影片預覽,並會自動清理過期資料。
// @description:zh-CN       无缝接续播放任何 YouTube 视频,从您上次离开的地方继续观看。此脚本会自动保存您的播放进度,并拥有智能播放列表处理功能:您在播放列表中的进度会被独立保存,不会影响您在其他地方观看同一视频的记录。此外,它还能以独特规则处理 Shorts 和视频预览,并会自动清理过期数据。
// @description:ja          あらゆるYouTube動画を、中断したその場所からシームレスに再生を再開します。このスクリプトは再生位置を自動的に保存し、スマートなプレイリスト処理機能を搭載。プレイリスト内での視聴進捗はそのプレイリスト専用に別途保存され、他の場所で同じ動画を視聴した際の進捗に影響を与えません。また、ショート動画やプレビューも独自のルールで処理し、古いデータは自動でクリーンアップします。
// @homepage                https://greasyfork.org/scripts/526798-youtube-auto-resume
// ==/UserScript==

/*jshint esversion: 11 */

(function () {
    'use strict';

    const DAYS_TO_REMEMBER = 30;
    const DAYS_TO_REMEMBER_SHORTS = 1;
    const DAYS_TO_REMEMBER_PREVIEWS = 10 / (24 * 60); // 10 minutes
    const MIN_REMEMBER_THRESHOLD = 1.5; // 1.5 seconds. Help prevent unwanted entries.
    const STATIC_FINISH_SECONDS = 15;
    const CLEANUP_INTERVAL_MS = 300000; // 5 minutes

    const STORAGE_PREFIX = 'YT_AUTO_RESUME_';
    const FOCUS_LOCK_KEY = `focusLock`;
    const LAST_CLEANUP_KEY = 'lastCleanupTimestamp';
    const TAB_ID = crypto.randomUUID();

    let activeCleanup = null;
    let lastPlaylistId = null;
    let currentVideoContext = { storageKey: null, timeupdateHandler: null };

    const StorageManager = {
        getValue: async (key) => {
            try {
                return await window.youtubeHelperApi.loadFromStorage(STORAGE_PREFIX + key);
            } catch (error) {
                console.error(`Failed to parse storage key "${key}"`, error);
                return null;
            }
        },
        setValue: async (key, value) => {
            try {
                await window.youtubeHelperApi.saveToStorage(STORAGE_PREFIX + key, value);
            } catch (error) {
                console.error(`Failed to set storage key "${key}"`, error);
            }
        },
        deleteValue: async (key) => {
            await window.youtubeHelperApi.deleteFromStorage(STORAGE_PREFIX + key);
        },
        listValues: async () => {
            const fullList = await window.youtubeHelperApi.listFromStorage();
            const filteredList = fullList
                .filter((key) => key.startsWith(STORAGE_PREFIX))
                .map((key) => key.substring(STORAGE_PREFIX.length));
            return filteredList;
        },
    };

    async function claimFocus() {
        if (currentVideoContext.storageKey) {
            await StorageManager.setValue(FOCUS_LOCK_KEY, {
                tabId: TAB_ID,
                key: currentVideoContext.storageKey,
                lastFocused: Date.now(),
            });
        }
    }

    async function hasWritePermission() {
        if (!currentVideoContext.storageKey) return false;
        const focusLock = await StorageManager.getValue(FOCUS_LOCK_KEY);
        if (!focusLock) return true; // If no lock exists, any tab can claim it.
        return focusLock.key === currentVideoContext.storageKey && focusLock.tabId === TAB_ID;
    }

    async function applySeek(playerApi, timeToSeek) {
        if (!playerApi || isNaN(timeToSeek) || timeToSeek < MIN_REMEMBER_THRESHOLD) return;
        playerApi.seekTo(timeToSeek, true);
        console.log(`%cSeeking video to ${timeToSeek.toFixed(2)}s`, 'font-weight: bold;');
    }

    async function resumePlayback(navigatedFromPlaylistId = null) {
        try {
            const playerApi = window.youtubeHelperApi.apiProxy;
            const inPlaylist = !!window.youtubeHelperApi.video.playlistId;
            const playlistId = window.youtubeHelperApi.video.playlistId;
            const videoId = window.youtubeHelperApi.video.id;

            const playerSize = playerApi.getPlayerSize();
            if (playerSize.width === 0 || playerSize.height === 0) return;
            const keyToFetch = inPlaylist ? playlistId : videoId;
            const playbackStatus = await StorageManager.getValue(keyToFetch);
            if (!playbackStatus) return;
            let lastPlaybackTime;
            let videoToResumeId = videoId;
            if (inPlaylist) {
                if (!playbackStatus.videos) return;
                const lastWatchedFromStorage = playbackStatus.lastWatchedVideoId;
                if (playlistId !== navigatedFromPlaylistId && lastWatchedFromStorage && videoId !== lastWatchedFromStorage) {
                    videoToResumeId = lastWatchedFromStorage;
                }
                lastPlaybackTime = playbackStatus.videos?.[videoToResumeId]?.timestamp;
            } else {
                lastPlaybackTime = playbackStatus.timestamp;
            }
            if (lastPlaybackTime) {
                if (inPlaylist && videoId !== videoToResumeId) {
                    const playlist = await getPlaylistWhenReady(playerApi);
                    const index = playlist.indexOf(videoToResumeId);
                    if (index !== -1) playerApi.playVideoAt(index);
                } else {
                    await applySeek(playerApi, lastPlaybackTime);
                }
            }
        } catch (error) {
            console.error(`Failed to resume playback: ${error}`);
        }
    }

    async function updatePlaybackStatus(videoType, playlistId = '') {
        try {
            if (!(await hasWritePermission())) return;
            const liveVideoId = window.youtubeHelperApi.video.id;
            if (!liveVideoId) return;
            const videoDuration = window.youtubeHelperApi.video.lengthSeconds;
            const currentPlaybackTime = window.youtubeHelperApi.video.realCurrentProgress;
            if (isNaN(videoDuration) || isNaN(currentPlaybackTime) || currentPlaybackTime < MIN_REMEMBER_THRESHOLD) return;
            const finishThreshold = Math.min(1 + videoDuration * 0.01, STATIC_FINISH_SECONDS);
            const isFinished = videoDuration - currentPlaybackTime < finishThreshold;
            if (playlistId) {
                const playlistData = (await StorageManager.getValue(playlistId)) || { lastWatchedVideoId: '', videos: {} };
                if (isFinished) {
                    if (playlistData.videos?.[liveVideoId]) {
                        delete playlistData.videos[liveVideoId];
                        await StorageManager.setValue(playlistId, playlistData);
                    }
                } else {
                    playlistData.videos = playlistData.videos || {};
                    playlistData.videos[liveVideoId] = {
                        timestamp: currentPlaybackTime,
                        lastUpdated: Date.now(),
                        videoType: 'playlist',
                    };
                    playlistData.lastWatchedVideoId = liveVideoId;
                    await StorageManager.setValue(playlistId, playlistData);
                }
            } else {
                if (isFinished) {
                    await StorageManager.deleteValue(liveVideoId);
                } else {
                    await StorageManager.setValue(liveVideoId, {
                        timestamp: currentPlaybackTime,
                        lastUpdated: Date.now(),
                        videoType: videoType,
                    });
                }
            }
        } catch (error) {
            console.error(`Failed to update playback status: ${error}`);
        }
    }

    async function processVideo() {
        if (activeCleanup) activeCleanup();
        const videoElement = window.youtubeHelperApi.player.videoElement;
        const videoId = window.youtubeHelperApi.video.id;
        if (!videoId) return;
        // Exclude the watch later playlist.
        const playlistId = window.youtubeHelperApi.video.playlistId === 'WL' ? null : window.youtubeHelperApi.video.playlistId;
        currentVideoContext = { storageKey: playlistId || videoId };

        await claimFocus();
        const isLive = window.youtubeHelperApi.video.isLive;
        const timeSpecified = window.youtubeHelperApi.video.isTimeSpecified;
        if (isLive || timeSpecified) {
            lastPlaylistId = window.youtubeHelperApi.video.playlistId;
            return;
        }

        const videoType = ((pageType) => {
            switch (pageType) {
                case 'shorts':
                    return 'short';
                case 'watch':
                    return 'regular';
                default:
                    return 'preview';
            }
        })(window.youtubeHelperApi.page.type);

        let hasAttemptedResume = false;
        currentVideoContext.timeupdateHandler = () => {
            if (!hasAttemptedResume) {
                hasAttemptedResume = true;
                if (videoType === 'preview') {
                    videoElement.addEventListener(
                        'timeupdate',
                        () => {
                            resumePlayback(lastPlaylistId);
                        },
                        { once: true },
                    );
                } else {
                    resumePlayback(lastPlaylistId);
                }
            } else {
                updatePlaybackStatus(videoType, playlistId);
            }
        };

        videoElement.removeEventListener('timeupdate', currentVideoContext.timeupdateHandler);
        videoElement.addEventListener('timeupdate', currentVideoContext.timeupdateHandler);

        activeCleanup = () => {
            currentVideoContext = { storageKey: null, timeupdateHandler: null };
        };
        lastPlaylistId = playlistId;
    }

    async function handleCleanupCycle() {
        const lastCleanupTime = (await StorageManager.getValue(LAST_CLEANUP_KEY)) || 0;
        const now = Date.now();
        if (now - lastCleanupTime < CLEANUP_INTERVAL_MS) return;
        await StorageManager.setValue(LAST_CLEANUP_KEY, now);
        console.log('%cThis tab is handling the scheduled cleanup.', 'font-weight: bold;');
        await cleanUpExpiredStatuses();
    }

    async function cleanUpExpiredStatuses() {
        try {
            const keys = await StorageManager.listValues();
            for (const key of keys) {
                if (key === LAST_CLEANUP_KEY || key === FOCUS_LOCK_KEY) continue;
                const storedData = await StorageManager.getValue(key);
                if (!storedData) continue;
                if (storedData.videos) {
                    let hasChanged = false;
                    for (const videoId in storedData.videos) {
                        if (isExpired(storedData.videos[videoId])) {
                            delete storedData.videos[videoId];
                            hasChanged = true;
                        }
                    }
                    if (Object.keys(storedData.videos).length === 0) {
                        await StorageManager.deleteValue(key);
                    } else if (hasChanged) {
                        await StorageManager.setValue(key, storedData);
                    }
                } else {
                    if (isExpired(storedData)) {
                        await StorageManager.deleteValue(key);
                    }
                }
            }
        } catch (error) {
            console.error(`Failed to clean up stored playback statuses: ${error}`);
        }
    }

    function getPlaylistWhenReady(playerApi) {
        return new Promise((resolve, reject) => {
            const initialPlaylist = playerApi.getPlaylist();
            if (initialPlaylist?.length > 0) return resolve(initialPlaylist);
            let hasResolved = false,
                pollerInterval = null;
            const cleanup = () => {
                window.removeEventListener('yt-playlist-data-updated', startPolling);
                if (pollerInterval) clearInterval(pollerInterval);
            };
            const startPolling = () => {
                if (hasResolved) return;
                let attempts = 0;
                pollerInterval = setInterval(() => {
                    const playlist = playerApi.getPlaylist();
                    if (playlist?.length > 0) {
                        hasResolved = true;
                        cleanup();
                        resolve(playlist);
                    } else if (++attempts >= 50) {
                        hasResolved = true;
                        cleanup();
                        reject(new Error('Playlist not found after 5s.'));
                    }
                }, 100);
            };
            document.addEventListener('yt-playlist-data-updated', startPolling, { once: true });
            setTimeout(() => {
                if (!hasResolved) startPolling();
            }, 1000);
        });
    }

    function isExpired(statusObject) {
        if (!statusObject?.lastUpdated || isNaN(statusObject.lastUpdated)) return true;
        let daysToExpire;
        switch (statusObject.videoType || 'regular') {
            case 'short':
                daysToExpire = DAYS_TO_REMEMBER_SHORTS;
                break;
            case 'preview':
                daysToExpire = DAYS_TO_REMEMBER_PREVIEWS;
                break;
            default:
                daysToExpire = DAYS_TO_REMEMBER;
                break;
        }
        return Date.now() - statusObject.lastUpdated > daysToExpire * 86400 * 1000;
    }

    async function setupCleanup() {
        window.addEventListener('pagehide', () => {
            if (activeCleanup) activeCleanup();
        });
        document.addEventListener('yt-autonav-pause-player-ended', async () => {
            if (activeCleanup) activeCleanup();
            await StorageManager.deleteValue(window.youtubeHelperApi.video.id);
        });
        // Run cleanup cycle logic independently
        await handleCleanupCycle();
        setInterval(handleCleanupCycle, CLEANUP_INTERVAL_MS);
    }

    function initialize() {
        try {
            setupCleanup();
            window.addEventListener('focus', claimFocus);
            window.youtubeHelperApi.eventTarget.addEventListener('yt-helper-api-ready', processVideo);
        } catch (error) {
            console.error(`Initialization failed: ${error}`);
        }
    }

    initialize();
})();