YouTube - Resumer

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

// ==UserScript==
// @name          YouTube - Resumer
// @version       2.0.0
// @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@c185c2777d00a6826a8bf3c43bbcdcfeba5a9566/libs/gm/gmcompat.min.js
// @require       https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@c185c2777d00a6826a8bf3c43bbcdcfeba5a9566/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 });

  /** CONFIG **/
  const MIN_SEEK_DIFF = 1.5;
  const DAYS_REGULAR = 90; // normal videos expire after 90 days
  const DAYS_SHORTS = 1; // Shorts expire after 1 day
  const DAYS_PREVIEWS = 10 / (24 * 60); // previews expire after 10 minutes
  const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes

  /** STATE **/
  let activeCleanup = null;
  let currentContext = { videoId: null, playlistId: null };
  let lastPlaylistId = null;

  /** UTILS **/
  const isExpired = status => {
    if (!status?.lastUpdated) return true;
    let days;
    switch (status.videoType) {
      case 'short':
        days = DAYS_SHORTS;
        break;
      case 'preview':
        days = DAYS_PREVIEWS;
        break;
      default:
        days = DAYS_REGULAR;
    }
    return Date.now() - status.lastUpdated > days * 86400 * 1000;
  };

  /** STORAGE HELPERS **/
  async function getStorage() {
    const stored = await GMC.getValue('yt_resumer_storage');
    return stored || { videos: {}, playlists: {}, meta: {} };
  }

  async function setStorage(storage) {
    await GMC.setValue('yt_resumer_storage', storage);
  }

  /** VIDEO CONTROL **/
  async function seekVideo(player, videoEl, time) {
    if (!player || !videoEl || isNaN(time)) return;
    if (Math.abs(player.getCurrentTime() - time) > MIN_SEEK_DIFF) {
      await new Promise(resolve => {
        const onSeeked = () => {
          clearTimeout(timeout);
          videoEl.removeEventListener('seeked', onSeeked);
          resolve();
        };
        const timeout = setTimeout(onSeeked, 1500);
        videoEl.addEventListener('seeked', onSeeked, { once: true });
        player.seekTo(time, true, { skipBufferingCheck: window.location.pathname === '/' });
        logger(`Seeking to ${Math.round(time)}s`);
      });
    }
  }

  /** PLAYBACK MANAGEMENT **/
  async function resumePlayback(player, videoId, videoEl, inPlaylist = false, playlistId = '', prevPlaylistId = null) {
    try {
      const playerSize = player.getPlayerSize();
      if (playerSize.width === 0 || playerSize.height === 0) return;

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

      let targetVideoId = videoId;
      let timeToResume = stored.timestamp;

      if (inPlaylist && stored.videos) {
        const lastVideo = stored.lastWatchedVideoId;
        if (playlistId !== prevPlaylistId && lastVideo && videoId !== lastVideo) {
          targetVideoId = lastVideo;
        }
        timeToResume = stored.videos?.[targetVideoId]?.timestamp;
      }

      if (timeToResume) {
        if (inPlaylist && videoId !== targetVideoId) {
          const playlist = await waitForPlaylist(player);
          const idx = playlist.indexOf(targetVideoId);
          if (idx !== -1) player.playVideoAt(idx);
        } else {
          await seekVideo(player, videoEl, timeToResume);
        }
      }
    } catch (err) {
      logger.error('Failed to resume playback', err);
    }
  }

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

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

      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: 'playlist'
        };
        storage.playlists[playlistId].lastWatchedVideoId = videoId;
      } else {
        storage.videos[videoId] = {
          timestamp: currentTime,
          lastUpdated: Date.now(),
          videoType: type
        };
      }

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

  /** VIDEO PROCESSING **/
  async function handleVideo(playerContainer, player, videoEl, skipResume = false) {
    if (activeCleanup) activeCleanup();

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

    const playlistId = urlParams.get('list');
    currentContext = { videoId, playlistId };

    const isLive = player.getVideoData()?.isLive;
    const isPreview = playerContainer.id === 'inline-player';
    const timeSpecified = urlParams.has('t');

    if (isLive || timeSpecified) {
      lastPlaylistId = playlistId;
      return;
    }

    const videoType = window.location.pathname.startsWith('/shorts/') ? 'short' : isPreview ? 'preview' : 'regular';
    let resumed = false;

    const onTimeUpdate = () => {
      if (!resumed && !skipResume) {
        resumed = true;
        resumePlayback(player, videoId, videoEl, !!playlistId, playlistId, lastPlaylistId);
      } else {
        updateStatus(player, videoEl, videoType, playlistId);
      }
    };

    const onRemoteUpdate = async (event) => {
      logger(`Remote update received`);
      await seekVideo(player, videoEl, event.detail.time);
    };

    videoEl.addEventListener('timeupdate', onTimeUpdate, true);
    window.addEventListener('yt-resumer-remote-update', onRemoteUpdate, true);

    activeCleanup = () => {
      videoEl.removeEventListener('timeupdate', onTimeUpdate, true);
      window.removeEventListener('yt-resumer-remote-update', onRemoteUpdate, true);
      currentContext = { videoId: null, playlistId: null };
    };

    lastPlaylistId = playlistId;
  }

  /** PLAYLIST HANDLER **/
  function waitForPlaylist(player) {
    return new Promise((resolve, reject) => {
      const existing = player.getPlaylist();
      if (existing?.length) return resolve(existing);

      let attempts = 0;
      const interval = setInterval(() => {
        const list = player.getPlaylist();
        if (list?.length) {
          clearInterval(interval);
          resolve(list);
        } else if (++attempts > 50) {
          clearInterval(interval);
          reject('Playlist not found');
        }
      }, 100);
    });
  }

  /** STORAGE EVENTS **/
  function onStorageChange(key, newValue, remote) {
    if (!remote || !newValue) return;
    // Broadcast update to video if it's current
    let time;
    if (key === currentContext.playlistId && newValue.videos) {
      time = newValue.videos[currentContext.videoId]?.timestamp;
    } else if (key === currentContext.videoId) {
      time = newValue.timestamp;
    }
    if (time) {
      window.dispatchEvent(new CustomEvent('yt-resumer-remote-update', { detail: { time } }));
    }
  }

  /** CLEANUP **/
  async function cleanupOldData() {
    try {
      const storage = await getStorage();
      for (const vid in storage.videos) {
        if (isExpired(storage.videos[vid])) delete storage.videos[vid];
      }
      for (const pl in storage.playlists) {
        let changed = false;
        const playlist = storage.playlists[pl];
        for (const vid in playlist.videos) {
          if (isExpired(playlist.videos[vid])) {
            delete playlist.videos[vid];
            changed = true;
          }
        }
        if (Object.keys(playlist.videos).length === 0) delete storage.playlists[pl];
        else if (changed) storage.playlists[pl] = playlist;
      }
      await setStorage(storage);
    } catch (err) {
      logger.error(`Failed to clean up stored playback statuses: ${err}`);
    }
  }

  async function periodicCleanup() {
    const storage = await getStorage();
    const last = storage.meta.lastCleanup || 0;
    if (Date.now() - last < CLEANUP_INTERVAL) return;
    storage.meta.lastCleanup = Date.now();
    await setStorage(storage);
    logger('This tab is handling the scheduled cleanup');
    await cleanupOldData();
  }

  /** INITIALIZATION **/
  async function init() {
    try {
      window.addEventListener('pagehide', () => activeCleanup?.(), true);

      await periodicCleanup();
      setInterval(periodicCleanup, CLEANUP_INTERVAL);

      GMC.addValueChangeListener(onStorageChange);

      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', () => activeCleanup?.(), true);
      }, { once: true });

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

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

  function onVideoContainerLoad(event) {
    const container = event.target;
    const player = container?.player_;
    const videoEl = container?.querySelector('video');
    if (player && videoEl) handleVideo(container, player, videoEl);
  }

  /** START **/
  init();

})();