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 यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला 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();

})();