YouTube: playlists playback tracker

This script helps watch playlists. It tracks the last video from a playlist that you've watched on this computer.

目前為 2023-02-04 提交的版本,檢視 最新版本

// ==UserScript==
// @name         YouTube: playlists playback tracker
// @namespace    http://tampermonkey.net/
// @version      3
// @description  This script helps watch playlists. It tracks the last video from a playlist that you've watched on this computer.
// @author       Andrei Rybak
// @license      MIT
// @match        https://www.youtube.com/playlist?list=*
// @match        https://www.youtube.com/watch?*&list=*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM.listValues
// @grant        GM.deleteValue
// ==/UserScript==

/*
 * Copyright (c) 2023 Andrei Rybak
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

(async function() {
    'use strict';

    const urlParams = new URLSearchParams(document.location.search);

    // never change -- used as part of IDs in storage in user's browser
    const STORAGE_KEY_PREFIX = "YT_PL_TRACKER_";
    const STORAGE_KEY_VIDEO_SUFFIX = "_VIDEO";
    const STORAGE_KEY_DATE_SUFFIX = "_DATE";

    // number of milliseconds to wait, until a video is considered "watched"
    const SAVE_DELAY = 60000;
    // hack to wait for necessary parts of the UI to load, in milliseconds
    const YOUTUBE_UI_LOAD_DELAY = 6000;

    function warn(...toLog) {
        console.warn("[playlist tracker]", ...toLog);
    }

    function log(...toLog) {
        console.log("[playlist tracker]", ...toLog);
    }

    function videoStorageKey(id) {
        return STORAGE_KEY_PREFIX + id + STORAGE_KEY_VIDEO_SUFFIX;
    }

    function dateStorageKey(id) {
        return STORAGE_KEY_PREFIX + id + STORAGE_KEY_DATE_SUFFIX;
    }

    function dateToString(d) {
        return d.toISOString().slice(0, "YYYY-MM-DD".length);
    }

    function videoInPlaylistUrl(videoId, listId) {
        return `https://www.youtube.com/watch?v=${videoId}&list=${listId}`;
    }

    function createVideoTitle(videoId) {
        let links = document.querySelectorAll("#contents.ytd-playlist-video-list-renderer h3 a");
        for (let i = 0; i < links.length; i++) {
            const link = links[i];
            if (link.href.includes(videoId)) {
                return link.title;
            }
        }
        // fallback is needed, because as of 2023-02-04 YouTube only loads 100 videos
        // into the playlist controls, unless the user scrolls through it
        return videoId;
    }

    function createLink(videoId, listId, date) {
        const newLink = document.createElement("a");
        newLink.id = 'YT_PL_TRACKER_LINK';
        newLink.href = videoInPlaylistUrl(videoId, listId);
        const videoTitle = createVideoTitle(videoId);
        newLink.innerText = `Continue watching "${videoTitle}" from ${date}.`;
        newLink.style = `color: white;`;
        return newLink;
    }


    async function displaySavedVideoIndex(listId) {
        log("Displaying saved video index...");
        if (!listId) {
            warn("Can't find parameter 'list' in the URL. Aborting.");
            return;
        }
        const maybeVideoId = await GM.getValue(videoStorageKey(listId));
        if (!maybeVideoId) {
            log(`No video stored for list ${listId} yet.`);
            return;
        }
        const date = await GM.getValue(dateStorageKey(listId));
        log(`Showing stored video ${maybeVideoId} from date ${date}. Waiting for ${YOUTUBE_UI_LOAD_DELAY} ms...`);
        setTimeout(() => { // stupid way of waiting until YouTube UI loads
            log("Starting actual HTML edit...");
            const header = document.querySelector(".metadata-buttons-wrapper");
            const newLink = createLink(maybeVideoId, listId, date);
            log("newLink =", newLink);
            header.appendChild(newLink);
            log("HTML edit finished.");
        }, YOUTUBE_UI_LOAD_DELAY);
    }

    async function storeVideo(listId, videoId) {
        log(`Storing ${videoId} as video for list ${listId}.`);
        await GM.setValue(videoStorageKey(listId), videoId);
        await storeDate(listId);
    }

    async function storeDate(listId) {
        const dateStr = dateToString(new Date());
        await GM.setValue(dateStorageKey(listId), dateStr);
    }

    function removePrefixSuffix(s, pref, suf) {
        return s.slice(pref.length, -suf.length);
    }

    async function clearOldVideos() {
        const keys = await GM.listValues();
        log("Clearing old videos...");
        const currentYear = new Date().getFullYear();
        for (const key of keys) {
            if (!key.endsWith(STORAGE_KEY_DATE_SUFFIX)) {
                continue;
            }
            const dateKey = key;
            const dateStr = await GM.getValue(dateKey);
            const listId = removePrefixSuffix(dateKey, STORAGE_KEY_PREFIX, STORAGE_KEY_DATE_SUFFIX);
            const videoKey = videoStorageKey(listId);
            if (!dateStr) {
                // clean up corrupted data, etc
                GM.deleteValue(dateKey);
                GM.deleteValue(videoKey);
                continue;
            }
            const year = parseInt(dateStr.slice(0, "YYYY".length));
            log(`Checking ${dateKey} -> ${dateStr} -> ${year} -> ${listId}`);
            if (year < currentYear - 3) {
                const videoId = await GM.getValue(videoKey);
                const url = videoInPlaylistUrl(videoId, listId);
                log(`Deleting outdated list ${listId} -> ${url} on date ${dateStr}`);
                GM.deleteValue(dateKey);
                GM.deleteValue(videoKey);
            }
        }
    }

    log("document.location.pathname =", document.location.pathname);

    const listId = urlParams.get('list');

    if (document.location.pathname == "/playlist") {
        displaySavedVideoIndex(listId);
        setTimeout(clearOldVideos, SAVE_DELAY);
    }

    const videoId = urlParams.get('v');
    if (document.location.pathname == "/watch" && videoId && listId) {
        // only store a video after it was watched for a minute (for debugging only 2-5 seconds)
        setTimeout(() => {
            storeVideo(listId, videoId);
            clearOldVideos();
        }, SAVE_DELAY);
    }

    log("Done");
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址