YouTube Helper API

A helper api for YouTube scripts that provides easy and consistent access for commonly needed functions, objects, and values.

当前为 2025-09-29 提交的版本,查看 最新版本

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.cn-greasyfork.org/scripts/549881/1669023/YouTube%20Helper%20API.js

// ==UserScript==
// @name                YouTube Helper API
// @author              ElectroKnight22
// @namespace           electroknight22_helper_api_namespace
// @version             0.5.3
// @license             MIT
// @description         A helper api for YouTube scripts that provides easy and consistent access for commonly needed functions, objects, and values.
// ==/UserScript==

/*jshint esversion: 11 */

window.youtubeHelperApi =
    window.youtubeHelperApi ??
    (function () {
        'use strict';

        const SELECTORS = {
            pageManager: 'ytd-page-manager',
            shortsPlayer: '#shorts-player',
            watchPlayer: '#movie_player',
            inlinePlayer: '.inline-preview-player',
            videoElement: 'video',
            watchFlexy: 'ytd-watch-flexy',
            chatFrame: 'ytd-live-chat-frame#chat',
            chatContainer: '#chat-container',
        };

        const POSSIBLE_RESOLUTIONS = Object.freeze({
            highres: { p: 4320, label: '8K' },
            hd2160: { p: 2160, label: '4K' },
            hd1440: { p: 1440, label: '1440p' },
            hd1080: { p: 1080, label: '1080p' },
            hd720: { p: 720, label: '720p' },
            large: { p: 480, label: '480p' },
            medium: { p: 360, label: '360p' },
            small: { p: 240, label: '240p' },
            tiny: { p: 144, label: '144p' },
        });

        const apiProxy = new Proxy(
            {},
            {
                get(target, property) {
                    return (...args) => {
                        if (player.api && typeof player.api[property] === 'function') {
                            return player.api[property](...args);
                        }
                    };
                },
            },
        );

        const player = {
            playerObject: null,
            api: null,
            videoElement: null,
            isFullscreen: false,
            isTheater: false,
            isPlayingAds: false,
        };

        const video = {
            id: '',
            title: '',
            channel: '',
            channelId: '',
            rawDescription: '',
            rawUploadDate: '',
            rawPublishDate: '',
            uploadDate: null,
            publishDate: null,
            lengthSeconds: 0,
            viewCount: 0,
            likeCount: 0,
            isLive: false,
            isFamilySafe: false,
            thumbnails: [],
            playingLanguage: null,
            originalLanguage: null,
            realCurrentProgress: 0, // YouTube can return the progress of the ad playing instead of the video content so we need implement our own progress tracking.
            isTimeSpecified: false,
            isInPlaylist: false,
            playlistId: '',
        };

        const chat = {
            container: null,
            iFrame: null,
            isCollapsed: false,
        };

        const page = {
            manager: document.querySelector(SELECTORS.pageManager),
            watchFlexy: document.querySelector(SELECTORS.watchFlexy),
            isIframe: window.top !== window.self,
            isMobile: window.location.hostname === 'm.youtube.com',
            type: 'unknown',
        };

        const localStorageApi = {
            get: (key, defaultValue) => {
                const value = localStorage.getItem(key);
                if (value === null) return defaultValue;
                try {
                    return JSON.parse(value);
                } catch (error) {
                    console.error(`Error parsing JSON for key "${key}":`, error);
                    return value;
                }
            },
            set: (key, value) => {
                localStorage.setItem(key, JSON.stringify(value));
            },
        };

        const storageApi = (() => {
            const gmType = (() => {
                if (typeof GM !== 'undefined') {
                    return 'modern';
                }
                if (typeof GM_info !== 'undefined') {
                    return 'old';
                }
                return 'none';
            })();

            switch (gmType) {
                case 'modern':
                    return {
                        getValue: async (...args) => await GM.getValue(...args),
                        setValue: async (...args) => await GM.setValue(...args),
                        deleteValue: async (...args) => await GM.deleteValue(...args),
                        listValues: async (...args) => await GM.listValues(...args),
                    };
                case 'old':
                    return {
                        getValue: async (key, defaultValue) => GM_getValue(key, defaultValue),
                        setValue: async (key, value) => GM_setValue(key, value),
                        deleteValue: async (key) => GM_deleteValue(key),
                        listValues: async () => GM_listValues(),
                    };
                case 'none':
                default:
                    return {
                        getValue: async (key, defaultValue) => localStorageApi.get(key, defaultValue),
                        setValue: async (key, value) => localStorageApi.set(key, value),
                        deleteValue: async (key) => localStorage.removeItem(key),
                        listValues: async () => Object.keys(localStorage),
                    };
            }
        })();

        async function _getSyncedStorageData(storageKey) {
            if (storageApi.gmType === 'none') return await storageApi.getValue(storageKey, null);
            const [gmData, localData] = await Promise.all([GM.getValue(storageKey, null), localStorageApi.get(storageKey, null)]);
            const gmTimestamp = gmData?.metadata?.timestamp ?? -1;
            const localTimestamp = localData?.metadata?.timestamp ?? -1;

            if (gmTimestamp > localTimestamp) {
                localStorageApi.set(storageKey, gmData);
                return gmData;
            } else if (localTimestamp > gmTimestamp) {
                await GM.setValue(storageKey, localData);
                return localData;
            }

            return gmData || localData;
        }

        async function saveToStorage(storageKey, data) {
            const dataToStore = {
                data: data,
                metadata: {
                    timestamp: Date.now(),
                },
            };
            try {
                if (storageApi.gmType !== 'none') await storageApi.setValue(storageKey, dataToStore);
                localStorageApi.set(storageKey, dataToStore);
            } catch (error) {
                console.error(`Error saving data for key "${storageKey}":`, error);
            }
        }

        async function loadFromStorage(storageKey, defaultData) {
            try {
                const syncedWrapper = await _getSyncedStorageData(storageKey);
                const storedData = syncedWrapper && !syncedWrapper.metadata ? syncedWrapper : syncedWrapper?.data ?? {};
                return { ...defaultData, ...storedData };
            } catch (error) {
                console.error(`Error loading data for key "${storageKey}":`, error);
                return defaultData;
            }
        }

        async function loadAndCleanFromStorage(storageKey, defaultData) {
            try {
                const syncedWrapper = await _getSyncedStorageData(storageKey);
                const storedData = syncedWrapper && !syncedWrapper.metadata ? syncedWrapper : syncedWrapper?.data ?? {};
                const combinedData = { ...defaultData, ...storedData };
                const cleanedData = Object.keys(defaultData).reduce((accumulator, currentKey) => {
                    accumulator[currentKey] = combinedData[currentKey];
                    return accumulator;
                }, {});
                return cleanedData;
            } catch (error) {
                console.error(`Error loading and cleaning data for key "${storageKey}":`, error);
                return defaultData;
            }
        }

        async function deleteFromStorage(storageKey) {
            try {
                if (storageApi.gmType !== 'none') await GM.deleteValue(storageKey);
                localStorage.removeItem(storageKey);
            } catch (error) {
                console.error(`Error deleting data for key "${storageKey}":`, error);
            }
        }

        async function listFromStorage() {
            try {
                const [greasemonkeyKeys, localStorageKeys] = await Promise.all([
                    storageApi.gmType !== 'none' ? storageApi.listValues() : Promise.resolve([]),
                    Promise.resolve(Object.keys(localStorage)),
                ]);
                const allUniqueKeys = new Set([...greasemonkeyKeys, ...localStorageKeys]);
                return Array.from(allUniqueKeys);
            } catch (error) {
                console.error('Error listing storage values:', error);
                return [];
            }
        }

        const currentlyObservedContainers = new WeakMap();

        function fallbackGetPlayerApi() {
            if (window.location.hostname === 'm.youtube.com') return document.querySelector(SELECTORS.watchPlayer);
            if (window.location.pathname.startsWith('/shorts')) return document.querySelector(SELECTORS.shortsPlayer);
            if (window.location.pathname.startsWith('/watch')) return document.querySelector(SELECTORS.watchPlayer);
            return document.querySelector(SELECTORS.inlinePlayer);
        }

        function getOptimalResolution(targetResolutionString, usePremium = true) {
            try {
                if (!targetResolutionString || !POSSIBLE_RESOLUTIONS[targetResolutionString])
                    throw new Error(`Invalid target resolution: ${targetResolutionString}`);
                const videoQualityData = apiProxy.getAvailableQualityData();
                const availableQualities = [...new Set(videoQualityData.map((q) => q.quality))];
                const targetValue = POSSIBLE_RESOLUTIONS[targetResolutionString].p;
                const bestQualityString = availableQualities
                    .filter((q) => POSSIBLE_RESOLUTIONS[q] && POSSIBLE_RESOLUTIONS[q].p <= targetValue)
                    .sort((a, b) => POSSIBLE_RESOLUTIONS[b].p - POSSIBLE_RESOLUTIONS[a].p)[0];
                if (!bestQualityString) return null;
                let normalCandidate = null;
                let premiumCandidate = null;
                for (const quality of videoQualityData) {
                    if (quality.quality === bestQualityString && quality.isPlayable) {
                        if (usePremium && quality.paygatedQualityDetails) premiumCandidate = quality;
                        else normalCandidate = quality;
                    }
                }
                return premiumCandidate || normalCandidate;
            } catch (error) {
                console.error('Error when resolving optimal quality:', error);
                return null;
            }
        }

        function setPlaybackResolution(targetResolution, ignoreAvailable = false, usePremium = true) {
            try {
                if (!player.api?.getAvailableQualityData) return;
                if (!usePremium && ignoreAvailable) {
                    apiProxy.setPlaybackQualityRange(targetResolution);
                } else {
                    const optimalQuality = getOptimalResolution(targetResolution, usePremium);
                    if (optimalQuality)
                        apiProxy.setPlaybackQualityRange(
                            optimalQuality.quality,
                            optimalQuality.quality,
                            usePremium ? optimalQuality.formatId : null,
                        );
                }
            } catch (error) {
                console.error('Error when setting resolution:', error);
            }
        }

        function dispatchHelperApiReadyEvent() {
            if (!player.api) return;
            const event = new CustomEvent('yt-helper-api-ready', { detail: Object.freeze({ ...publicApi }) });
            document.dispatchEvent(event);
        }

        function notifyAdDetected() {
            if (player.isPlayingAds)
                document.dispatchEvent(
                    new CustomEvent('yt-helper-api-ad-detected', { detail: Object.freeze({ isPlayingAds: player.isPlayingAds }) }),
                );
        }

        function checkIsIframe() {
            if (page.isIframe) document.dispatchEvent(new Event('yt-helper-api-detected-iframe'));
        }

        function updateVideoLanguage() {
            if (!player.api) return;
            const getAudioTrackId = (track) => Object.values(track ?? {}).find((p) => p?.id)?.id ?? null;
            const availableTracks = apiProxy.getAvailableAudioTracks();
            if (availableTracks?.length === 0) return; // Either no alternative languages exist or YouTube's API failed.
            const renderer = apiProxy.getPlayerResponse()?.captions?.playerCaptionsTracklistRenderer;
            const originalAudioId = renderer?.audioTracks?.[renderer?.defaultAudioTrackIndex]?.audioTrackId;
            const playingAudioTrack = apiProxy.getAudioTrack();
            const originalAudioTrack = availableTracks.find((track) => getAudioTrackId(track) === originalAudioId);
            video.playingLanguage = playingAudioTrack;
            video.originalLanguage = originalAudioTrack;
        }

        function updateVideoState() {
            if (!player.api) return;
            const playerResponseObject = apiProxy.getPlayerResponse();
            const searchParams = new URL(window.location.href).searchParams;
            video.id = playerResponseObject?.videoDetails?.videoId;
            video.title = playerResponseObject?.videoDetails?.title;
            video.channel = playerResponseObject?.videoDetails?.author;
            video.channelId = playerResponseObject?.videoDetails?.channelId;
            video.rawDescription = playerResponseObject?.videoDetails?.shortDescription;
            video.rawUploadDate = playerResponseObject?.microformat?.playerMicroformatRenderer?.uploadDate;
            video.rawPublishDate = playerResponseObject?.microformat?.playerMicroformatRenderer?.publishDate;
            video.uploadDate = video.rawUploadDate ? new Date(video.rawUploadDate) : null;
            video.publishDate = video.rawPublishDate ? new Date(video.rawPublishDate) : null;
            video.lengthSeconds = parseInt(playerResponseObject?.videoDetails?.lengthSeconds ?? '0', 10);
            video.viewCount = parseInt(playerResponseObject?.videoDetails?.viewCount ?? '0', 10);
            video.likeCount = parseInt(playerResponseObject?.microformat?.playerMicroformatRenderer?.likeCount ?? '0', 10);
            video.isLive = playerResponseObject?.videoDetails?.isLiveContent;
            video.isFamilySafe = playerResponseObject?.microformat?.playerMicroformatRenderer?.isFamilySafe;
            video.thumbnails = playerResponseObject?.microformat?.playerMicroformatRenderer?.thumbnail?.thumbnails;
            video.realCurrentProgress = apiProxy.getCurrentTime();
            video.isTimeSpecified = searchParams.has('t');
            video.playlistId = apiProxy.getPlaylistId();
        }

        function updatePlayerState(event) {
            player.api = event?.target?.player_ ?? fallbackGetPlayerApi();
            player.playerObject = event?.target?.playerContainer_?.children[0] ?? fallbackGetPlayerApi();
            player.videoElement = player.playerObject?.querySelector(SELECTORS.videoElement);
        }

        function updateFullscreenState() {
            player.isFullscreen = !!document.fullscreenElement;
        }

        function updateTheaterState(event) {
            player.isTheater = !!event?.detail?.enabled;
        }

        function updateChatStateUpdated(event) {
            chat.iFrame = event?.target ?? document.querySelector(SELECTORS.chatFrame);
            chat.container = chat.iFrame?.parentElement ?? document.querySelector(SELECTORS.chatContainer);
            chat.isCollapsed = event?.detail ?? true;
            document.dispatchEvent(new CustomEvent('yt-helper-api-chat-state-updated', { detail: Object.freeze({ ...chat }) }));
        }

        function updateAdState() {
            if (!player.playerObject) return;
            try {
                const shouldAvoid = player.playerObject.classList.contains('unstarted-mode'); // YouTube doesn't update ad state fully until player is marked as started.
                const isAdPresent =
                    player.playerObject.classList.contains('ad-showing') || player.playerObject.classList.contains('ad-interrupting');
                const isPlayingAds = !shouldAvoid && isAdPresent;
                player.isPlayingAds = isPlayingAds;
                notifyAdDetected();
            } catch (error) {
                console.error('Error in checkAdState:', error);
                return false;
            }
        }

        function fallbackUpdateAdState() {
            if (!player.api) return;
            try {
                const progressState = apiProxy.getProgressState();
                const reportedContentDuration = progressState.duration;
                const realContentDuration = apiProxy.getDuration() ?? -1;
                const durationMismatch = Math.trunc(realContentDuration) !== Math.trunc(reportedContentDuration);
                const isPlayingAds = durationMismatch;
                player.isPlayingAds = isPlayingAds;
                notifyAdDetected();
            } catch (error) {
                console.error('Error during ad check:', error);
                return false;
            }
        }

        function reloadToCurrentProgress() {
            if (!player.api) return;
            apiProxy.loadVideoById(video.id, Math.max(0, video.realCurrentProgress));
        }

        function trackPlaybackProgress() {
            if (!player.videoElement) return;
            const updateProgress = () => {
                if (!player.isPlayingAds && player.videoElement.currentTime > 0)
                    video.realCurrentProgress = player.videoElement.currentTime;
            };
            player.videoElement.addEventListener('timeupdate', updateProgress);
        }

        function trackAdState() {
            if (!player.playerObject) return;
            if (currentlyObservedContainers.has(player.playerObject)) return;
            const adStateObserver = new MutationObserver(updateAdState);
            adStateObserver.observe(player.playerObject, { attributes: true, attributeFilter: ['class'] });
            currentlyObservedContainers.set(player.playerObject, adStateObserver);
        }

        function handlePlayerUpdate(event = null) {
            updatePlayerState(event);
            updateVideoState();
            updateVideoLanguage();
            updateAdState();
            trackAdState();
            trackPlaybackProgress();
            dispatchHelperApiReadyEvent();
        }

        function handleNavigationFinish() {
            page.manager = document.querySelector(SELECTORS.pageManager);
            page.watchFlexy = document.querySelector(SELECTORS.watchFlexy);
        }

        function handlePageDataUpdate(event) {
            page.type = event.detail?.pageType;
        }

        function handlePageTypeChange(event) {
            page.type = event.detail?.newPageSubtype;
        }

        function addPageStateListeners() {
            document.addEventListener('yt-page-data-updated', handlePageDataUpdate);
            document.addEventListener('yt-page-type-changed', handlePageTypeChange);
        }

        function addPlayerStateListeners() {
            const PLAYER_UPDATE_EVENT = page.isMobile ? 'state-navigateend' : 'yt-player-updated';
            document.addEventListener(PLAYER_UPDATE_EVENT, handlePlayerUpdate);
            document.addEventListener('fullscreenchange', updateFullscreenState);
            document.addEventListener('yt-set-theater-mode-enabled', updateTheaterState);
        }

        function addChatStateListeners() {
            document.addEventListener('yt-chat-collapsed-changed', updateChatStateUpdated);
        }

        function addNavigationListeners() {
            document.addEventListener('yt-navigate-finish', handleNavigationFinish);
        }

        function initialize() {
            window.addEventListener('pageshow', handlePlayerUpdate);
            checkIsIframe();
            if (!page.isIframe) {
                addNavigationListeners();
                addPlayerStateListeners();
                addPageStateListeners();
                addChatStateListeners();
            }
        }

        initialize();

        const publicApi = {
            get player() {
                return { ...player };
            },
            get video() {
                return { ...video };
            },
            get chat() {
                return { ...chat };
            },
            get page() {
                return { ...page };
            },
            POSSIBLE_RESOLUTIONS,
            updateAdState,
            fallbackUpdateAdState,
            getOptimalResolution,
            setPlaybackResolution,
            saveToStorage,
            loadFromStorage,
            loadAndCleanFromStorage,
            deleteFromStorage,
            listFromStorage,
            reloadToCurrentProgress,
            apiProxy,
        };

        return publicApi;
    })();