YouTube Playback Saver

Periodically save and restore playback time of YouTube videos, including seek events.

// ==UserScript==
// @name        YouTube Playback Saver
// @namespace   http://tampermonkey.net/
// @version     1.6
// @description Periodically save and restore playback time of YouTube videos, including seek events.
// @license     Unlicense
// @match       *://*.youtube.com/*
// @grant       none
// ==/UserScript==

const SAVE_INTERVAL = 2; // Interval for saving playback data in seconds
const STORAGE_KEY = 'youtubePlaybackSaver';
const LOG_PREFIX = '[YTPS]';
let playbackRestored = false;
let lastSavedTime = null;
let isSaving = false;

// Utility Functions
const currentVideoId = () => new URL(location.href).searchParams.get('v');
const getVideoElement = () => document.querySelector('video.html5-main-video');

// Format seconds to hh:mm:ss
const formatTime = (seconds) => new Date(seconds * 1000).toISOString().substr(11, 8);

// Local Storage Operations with Race Condition Prevention
const saveData = (videoId, time) => {
    if (isSaving) return; // Prevent concurrent saves
    isSaving = true;

    try {
        const playbackData = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
        playbackData[videoId] = { time };
        localStorage.setItem(STORAGE_KEY, JSON.stringify(playbackData));
    } finally {
        isSaving = false;
    }
};

const loadData = (videoId) => {
    return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')[videoId] || { time: 0 };
};

// Validates the restored time by checking video.currentTime within 500ms
const validateRestoredTime = (video, targetTime, attempts = 5) => {
    return new Promise((resolve) => {
        const checkTime = (remainingAttempts) => {
            if (Math.abs(video.currentTime - targetTime) < 0.5 || remainingAttempts <= 0) {
                resolve();
            } else {
                video.currentTime = targetTime; // Retry setting the currentTime
                setTimeout(() => checkTime(remainingAttempts - 1), 100); // Check every 100ms
            }
        };
        checkTime(attempts);
    });
};

// Restore saved playback time on load with validation
const restorePlayback = async () => {
    if (playbackRestored) return;

    const video = getVideoElement();
    if (!video) return;

    const videoId = currentVideoId();
    const savedTime = loadData(videoId).time;
    if (savedTime > 0 && savedTime < video.duration) {
        video.currentTime = savedTime;
        await validateRestoredTime(video, savedTime);
        playbackRestored = true;
        console.info(`${LOG_PREFIX} Restored playback to ${formatTime(savedTime)}`);
    }
};

// Save current playback time if it has changed
const savePlayback = () => {
    const video = getVideoElement();
    if (!video) return;

    const videoId = currentVideoId();
    const timeToSave = Math.floor(video.currentTime);
    if (timeToSave > 0 && timeToSave !== lastSavedTime) {
        saveData(videoId, timeToSave);
        lastSavedTime = timeToSave;
        const url = new URL(location.href);
        url.searchParams.set('t', `${timeToSave}s`);
        history.replaceState(history.state, document.title, url.toString());
        console.info(`${LOG_PREFIX} Saved playback at ${formatTime(timeToSave)}`);
    }
};

// Monitor video for events with enhanced checks
const monitorVideo = () => {
    const video = getVideoElement();
    if (!video || playbackRestored) return;

    restorePlayback(); // Try restoring playback time on load

    video.addEventListener('timeupdate', () => {
        if (Math.floor(video.currentTime) % SAVE_INTERVAL === 0) savePlayback();
    });

    video.addEventListener('seeked', savePlayback);
    video.addEventListener('pause', savePlayback); // Save on pause

    video.addEventListener('play', () => {
        if (!playbackRestored) restorePlayback(); // Ensure restore on play
    }, { once: true });

    video.addEventListener('ratechange', () => {
        console.info(`${LOG_PREFIX} Playback rate changed to ${video.playbackRate}`);
    });

    video.addEventListener('ended', () => {
        console.info(`${LOG_PREFIX} Video ended, resetting saved time.`);
        saveData(currentVideoId(), 0); // Reset playback position on video end
    });

    video.addEventListener('loadedmetadata', restorePlayback); // Restore when metadata is loaded
    video.addEventListener('canplay', restorePlayback); // Restore when video can play without buffering
};

// Re-run monitor if video ID changes, preventing redundant calls
let lastVideoId = null;
setInterval(() => {
    const videoId = currentVideoId();
    if (videoId && videoId !== lastVideoId) {
        lastVideoId = videoId;
        playbackRestored = false;
        monitorVideo();
    }
}, 1000);