YouTube - Resumer

Automatically saves and resumes YouTube videos from where you left off, with playlist, Shorts, and preview handling, plus automatic cleanup.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name          YouTube - Resumer
// @version       2.2.3
// @description   Automatically saves and resumes YouTube videos from where you left off, with playlist, Shorts, and preview handling, plus automatic cleanup.
// @author        Journey Over
// @license       MIT
// @match         *://*.youtube.com/*
// @match         *://*.youtube-nocookie.com/*
// @require       https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@0171b6b6f24caea737beafbc2a8dacd220b729d8/libs/utils/utils.min.js
// @grant         GM_setValue
// @grant         GM_getValue
// @grant         GM_deleteValue
// @grant         GM_listValues
// @grant         GM_addValueChangeListener
// @icon          https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @homepageURL   https://github.com/StylusThemes/Userscripts
// @namespace https://greasyfork.org/users/32214
// ==/UserScript==

(function() {
  'use strict';

  const logger = Logger('YT - Resumer', { debug: false });

  const MIN_SEEK_DIFFERENCE = 1.5;
  const DAYS_TO_KEEP_REGULAR = 90;
  const DAYS_TO_KEEP_SHORTS = 1;
  const DAYS_TO_KEEP_PREVIEWS = 10 / (24 * 60);
  const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;

  let currentAbortController = null;
  let currentVideoContext = { videoId: null, playlistId: null };
  let lastPlaylistId = null;

  const isExpired = status => {
    if (!status?.lastUpdated) return true;
    let daysToKeep;
    switch (status.videoType) {
      case 'short': {
        daysToKeep = DAYS_TO_KEEP_SHORTS;
        break;
      }
      case 'preview': {
        daysToKeep = DAYS_TO_KEEP_PREVIEWS;
        break;
      }
      default: {
        daysToKeep = DAYS_TO_KEEP_REGULAR;
      }
    }
    return Date.now() - status.lastUpdated > daysToKeep * 86400 * 1000;
  };

  async function getStorage() {
    const storedData = GM_getValue('yt_resumer_storage');
    return storedData || { videos: {}, playlists: {}, meta: {} };
  }

  async function setStorage(storage) {
    GM_setValue('yt_resumer_storage', storage);
  }

  async function seekVideo(player, videoElement, time) {
    if (!player || !videoElement || isNaN(time)) return;
    if (Math.abs(player.getCurrentTime() - time) < MIN_SEEK_DIFFERENCE) return;

    logger.debug('Seeking video', { currentTime: player.getCurrentTime(), targetTime: time });

    const releaseLock = () => {
      if (videoElement._ytAutoResumeSeekPending) videoElement._ytAutoResumeSeekPending = false;
      clearTimeout(seekTimeout);
      for (const event of ['seeked', 'abort', 'emptied', 'error']) {
        videoElement.removeEventListener(event, releaseLock);
      }
    };

    // If the browser is busy seeking, wait for it to finish then try again
    if (videoElement.seeking && !videoElement._ytAutoResumeSeekPending) {
      const retrySeek = () => {
        setTimeout(() => seekVideo(player, videoElement, time), 0);
      };
      videoElement.addEventListener('seeked', retrySeek, { once: true });
      return;
    }

    for (const event of ['seeked', 'abort', 'emptied', 'error']) {
      videoElement.addEventListener(event, releaseLock, { once: true });
    }
    const seekTimeout = setTimeout(releaseLock, 2000);
    videoElement._ytAutoResumeSeekPending = true;

    player.seekTo(time, true, { skipBufferingCheck: window.location.pathname === '/' });
  }

  async function resumePlayback(player, videoId, videoElement, inPlaylist = false, playlistId = '', previousPlaylistId = null) {
    try {
      logger.debug('Attempting to resume playback', { videoId, inPlaylist, playlistId, previousPlaylistId });

      const storage = await getStorage();
      const storedData = inPlaylist ? storage.playlists[playlistId] : storage.videos[videoId];
      if (!storedData) return;

      let targetVideoId = videoId;
      let resumeTime = storedData.timestamp;

      // Handle playlist navigation - resume last watched video if switching playlists
      if (inPlaylist && storedData.videos) {
        const lastWatchedVideoId = storedData.lastWatchedVideoId;
        if (playlistId !== previousPlaylistId && lastWatchedVideoId && videoId !== lastWatchedVideoId) {
          targetVideoId = lastWatchedVideoId;
        }
        resumeTime = storedData.videos?.[targetVideoId]?.timestamp;
      }

      if (resumeTime) {
        logger('Resuming playback', { videoId: targetVideoId, resumeTime, inPlaylist });

        if (inPlaylist && videoId !== targetVideoId) {
          const playlistVideos = await waitForPlaylist(player);
          const videoIndex = playlistVideos.indexOf(targetVideoId);
          if (videoIndex !== -1) player.playVideoAt(videoIndex);
        } else {
          await seekVideo(player, videoElement, resumeTime);
        }
      }
    } catch (error) {
      logger.error('Failed to resume playback', error);
    }
  }

  async function updateStatus(player, videoElement, type, playlistId = '') {
    try {
      const videoId = player.getVideoData()?.video_id;
      if (!videoId) return;

      const currentTime = videoElement.currentTime;
      if (isNaN(currentTime) || currentTime === 0) return;

      logger.debug('Updating status', { videoId, currentTime, type, playlistId });

      const storage = await getStorage();
      if (playlistId) {
        storage.playlists[playlistId] = storage.playlists[playlistId] || { lastWatchedVideoId: '', videos: {} };
        storage.playlists[playlistId].videos[videoId] = {
          timestamp: currentTime,
          lastUpdated: Date.now(),
          videoType: type
        };
        storage.playlists[playlistId].lastWatchedVideoId = videoId;
      } else {
        storage.videos[videoId] = {
          timestamp: currentTime,
          lastUpdated: Date.now(),
          videoType: type
        };
      }

      await setStorage(storage);
    } catch (error) {
      logger.error('Failed to update playback status', error);
    }
  }

  async function handleVideo(playerContainer, player, videoElement, skipResume = false) {
    logger.debug('Handling video load', { videoId: player.getVideoData()?.video_id, skipResume });

    // Cancel any existing listeners from the previous video
    if (currentAbortController) currentAbortController.abort();
    currentVideoContext = { videoId: null, playlistId: null };
    currentAbortController = new AbortController();
    const signal = currentAbortController.signal;

    const urlSearchParameters = new URLSearchParams(window.location.search);
    const videoId = urlSearchParameters.get('v') || player.getVideoData()?.video_id;
    if (!videoId) return;

    // Exclude "Watch Later" playlist (WL) from playlist tracking
    const playlistId = ((rawPlaylistId) => (rawPlaylistId !== 'WL' ? rawPlaylistId : null))(urlSearchParameters.get('list'));
    currentVideoContext = { videoId, playlistId };

    const isLiveStream = player.getVideoData()?.isLive;
    const isPreviewVideo = playerContainer.id === 'inline-player';
    const hasTimeParameter = urlSearchParameters.has('t');

    // Don't resume live streams or videos with explicit timestamps
    if (isLiveStream || hasTimeParameter) {
      lastPlaylistId = playlistId;
      return;
    }

    const videoType = window.location.pathname.startsWith('/shorts/') ? 'short' : isPreviewVideo ? 'preview' : 'regular';
    let hasResumed = false;
    let isResuming = false;
    let lastSaveTime = Date.now();

    const onTimeUpdate = () => {
      const isAdShowing = playerContainer.classList.contains('ad-showing') || playerContainer.classList.contains('ad-interrupting');

      // Do not save progress while an ad is playing, while waiting for the resume jump, or while seeking natively!
      if (isAdShowing || isResuming || videoElement._ytAutoResumeSeekPending) return;

      if (!hasResumed && skipResume) {
        hasResumed = true;
      } else if (!hasResumed) {
        isResuming = true;

        // Wait for the async resume process to completely finish before unlocking
        resumePlayback(player, videoId, videoElement, !!playlistId, playlistId, lastPlaylistId).then(() => {
          hasResumed = true;
          isResuming = false;
          lastSaveTime = Date.now();
        });
      } else if (hasResumed) {
        const now = Date.now();
        if (now - lastSaveTime > 1000) {
          updateStatus(player, videoElement, videoType, playlistId);
          lastSaveTime = now;
        }
      }
    };

    const onRemoteUpdate = async (event_) => {
      logger.debug('Remote update received', { time: event_.detail.time });
      await seekVideo(player, videoElement, event_.detail.time);
    };

    videoElement.addEventListener('timeupdate', onTimeUpdate, { signal });
    window.addEventListener('yt-resumer-remote-update', onRemoteUpdate, { signal });

    lastPlaylistId = playlistId;
  }

  function waitForPlaylist(player) {
    logger.debug('Waiting for playlist data');

    return new Promise((resolve, reject) => {
      const existingPlaylist = player.getPlaylist();
      if (existingPlaylist?.length) {
        logger.debug('Playlist already available', { length: existingPlaylist.length });
        return resolve(existingPlaylist);
      }

      let hasResolved = false;
      let checkInterval = null;

      const cleanup = () => {
        document.removeEventListener('yt-playlist-data-updated', checkPlaylist);
        if (checkInterval) clearInterval(checkInterval);
      };

      const checkPlaylist = () => {
        if (hasResolved) return;
        const playlist = player.getPlaylist();
        if (playlist?.length) {
          logger.debug('Playlist data received', { length: playlist.length });
          hasResolved = true;
          cleanup();
          resolve(playlist);
        }
      };

      // Listen for YouTube's native event
      document.addEventListener('yt-playlist-data-updated', checkPlaylist, { once: true });

      // Fallback polling just in case the event fired before we started listening
      let attempts = 0;
      checkInterval = setInterval(() => {
        checkPlaylist();
        if (!hasResolved && ++attempts > 50) {
          hasResolved = true;
          cleanup();
          reject(new Error('Playlist not found'));
        }
      }, 100);
    });
  }

  function onStorageChange(storageKey, oldStorageValue, newStorageValue, isRemoteChange) {
    if (!isRemoteChange || !newStorageValue) return;

    logger.debug('Storage change detected', { storageKey, isRemoteChange });
    // Sync playback position across tabs for current video
    let resumeTime;
    if (currentVideoContext.playlistId && newStorageValue.playlists?.[currentVideoContext.playlistId]?.videos) {
      resumeTime = newStorageValue.playlists[currentVideoContext.playlistId].videos[currentVideoContext.videoId]?.timestamp;
    } else if (currentVideoContext.videoId && newStorageValue.videos?.[currentVideoContext.videoId]) {
      resumeTime = newStorageValue.videos[currentVideoContext.videoId].timestamp;
    }
    if (resumeTime) {
      window.dispatchEvent(new CustomEvent('yt-resumer-remote-update', { detail: { time: resumeTime } }));
    }
  }

  async function cleanupOldData() {
    try {
      logger.debug('Starting cleanup of old data');

      const storage = await getStorage();
      const videoCleanup = async () => {
        for (const videoId in storage.videos) {
          if (isExpired(storage.videos[videoId])) delete storage.videos[videoId];
        }
      };
      const playlistCleanup = async () => {
        for (const playlistId in storage.playlists) {
          let hasChanged = false;
          const playlist = storage.playlists[playlistId];
          for (const videoId in playlist.videos) {
            if (isExpired(playlist.videos[videoId])) {
              delete playlist.videos[videoId];
              hasChanged = true;
            }
          }
          if (Object.keys(playlist.videos).length === 0) delete storage.playlists[playlistId];
          else if (hasChanged) storage.playlists[playlistId] = playlist;
        }
      };
      await Promise.all([videoCleanup(), playlistCleanup()]);
      await setStorage(storage);
    } catch (error) {
      logger.error(`Failed to clean up stored playback statuses: ${error}`);
    }
  }

  async function periodicCleanup() {
    logger.debug('Checking if periodic cleanup is needed');

    const storage = await getStorage();
    const lastCleanupTime = storage.meta.lastCleanup || 0;
    if (Date.now() - lastCleanupTime < CLEANUP_INTERVAL_MS) return;
    storage.meta.lastCleanup = Date.now();
    await setStorage(storage);
    logger('This tab is handling the scheduled cleanup');
    await cleanupOldData();
  }

  function interceptTimestampLinks() {
    logger.debug('Setting up timestamp link interception');

    document.documentElement.addEventListener('click', (event) => {
      if (!(event.target instanceof Element)) return;
      const anchor = event.target.closest('a');
      if (!anchor || !anchor.href || !/[?&]t=/.test(anchor.href)) return;

      // Allow native timestamp clicks inside comments and descriptions
      if (anchor.closest('ytd-comments, ytd-text-inline-expander, #description, #content-text')) return;

      const isNewTabClick = event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey;
      if (isNewTabClick) return;

      try {
        const url = new URL(anchor.href);
        if (url.searchParams.has('t')) {
          logger.debug('Intercepting timestamp link', { originalUrl: anchor.href });
          url.searchParams.delete('t');
          const newUrl = url.toString();
          anchor.href = newUrl;

          event.preventDefault();
          event.stopImmediatePropagation();
          history.pushState(null, '', newUrl);
          window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
        }
      } catch (error) {
        logger('Could not modify link href:', error);
      }
    }, true);
  }

  async function init() {
    try {
      logger('Initializing YouTube Resumer');

      window.addEventListener('pagehide', () => {
        currentAbortController?.abort();
        currentVideoContext = { videoId: null, playlistId: null };
      }, true);

      await periodicCleanup();
      setInterval(periodicCleanup, CLEANUP_INTERVAL_MS);

      GM_addValueChangeListener('yt_resumer_storage', onStorageChange);

      interceptTimestampLinks();

      logger('This tab is handling the initial load');
      window.addEventListener('pageshow', () => {
        logger('This tab is handling the video load');
        initVideoLoad();
        window.addEventListener('yt-player-updated', onVideoContainerLoad, true);
        // window.addEventListener('yt-autonav-pause-player-ended', () => currentAbortController?.abort(), true);
      }, { once: true });

    } catch (error) { logger.error('Initialization failed', error); }
  }

  function initVideoLoad() {
    logger.debug('Initializing video load');

    const player = document.querySelector('#movie_player');
    if (!player) return;
    const videoElement = player.querySelector('video');
    if (videoElement) handleVideo(player, player.player_ || player, videoElement);
  }

  function onVideoContainerLoad(event_) {
    logger.debug('Video container updated');

    const videoContainer = event_.target;
    const playerInstance = videoContainer?.player_;
    const videoElement = videoContainer?.querySelector('video');
    if (playerInstance && videoElement) handleVideo(videoContainer, playerInstance, videoElement);
  }

  init();

})();