YouTube Helper API

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

目前為 2025-11-04 提交的版本,檢視 最新版本

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.cn-greasyfork.org/scripts/549881/1688974/YouTube%20Helper%20API.js

// ==UserScript==
// @name                YouTube Helper API
// @author              ElectroKnight22
// @namespace           electroknight22_helper_api_namespace
// @version             0.6.2.1
// @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 = (function () {
        'use strict';

        const privateEventTarget = new EventTarget();

        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) return;
                        if (typeof player.api[property] === 'function') {
                            return player.api[property](...args);
                        } else {
                            console.warn(`Method "${property}" does not exist on the YouTube player API.`);
                        }
                    };
                },
            },
        );

        const _readOnlyHandler = {
            get(target, property) {
                return target[property];
            },
            set(target, property) {
                console.warn(`[YouTube Helper API] Tried to set "${property}" on a read-only object.`);
                return false;
            },
        };

        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,
            isCurrentlyLive: false,
            isLiveOrVodContent: 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 = (() => {
            const _fallbackGetPageType = () => {
                const pathname = window.location.pathname;
                if (pathname.startsWith('/shorts')) return 'shorts';
                if (pathname.startsWith('/watch')) return 'watch';
                if (pathname.startsWith('/playlist')) return 'playlist';
                if (pathname.startsWith('/results')) return 'search';
                if (pathname === '/') return 'browse';
                return 'unknown';
            };

            let _type = 'unknown';
            return {
                get manager() {
                    return document.querySelector(SELECTORS.pageManager);
                },
                get watchFlexy() {
                    return document.querySelector(SELECTORS.watchFlexy);
                },
                isIframe: window.top !== window.self,
                isMobile: window.location.hostname === 'm.youtube.com',
                set type(newValue) {
                    _type = newValue;
                },
                get type() {
                    if (_type === 'unknown' || _type == null) return _fallbackGetPageType();
                    return _type;
                },
            };
        })();

        const readOnlyPlayer = new Proxy(player, _readOnlyHandler);
        const readOnlyVideo = new Proxy(video, _readOnlyHandler);
        const readOnlyChat = new Proxy(chat, _readOnlyHandler);
        const readOnlyPage = new Proxy(page, _readOnlyHandler);

        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 STORAGE_IMPLEMENTATIONS = {
                modern: {
                    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),
                },
                old: {
                    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(),
                },
                none: {
                    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),
                },
            };
            const gmType = (() => {
                if (typeof GM !== 'undefined') {
                    return 'modern';
                }
                if (typeof GM_info !== 'undefined') {
                    return 'old';
                }
                return 'none';
            })();
            return {
                ...STORAGE_IMPLEMENTATIONS[gmType],
                gmType,
            };
        })();

        async function _getSyncedStorageData(storageKey) {
            if (storageApi.gmType === 'none') return await storageApi.getValue(storageKey, null);
            const [gmData, localData] = await Promise.all([storageApi.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 storageApi.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 combinedData = await loadFromStorage(storageKey, defaultData);
                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 storageApi.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 [];
            }
        }

        function fallbackGetPlayerApi() {
            console.log('Trying to get player api using fallback...');
            if (page.isIframe || 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 }) });
            privateEventTarget.dispatchEvent(event);
        }

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

        function checkIsIframe() {
            if (page.isIframe) privateEventTarget.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();
            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.isCurrentlyLive = apiProxy.getVideoData().isLive;
            video.isLiveOrVodContent = playerResponseObject?.videoDetails?.isLiveContent;
            video.wasStreamedOrPremiered = !!playerResponseObject?.microformat?.playerMicroformatRenderer?.liveBroadcastDetails;
            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;
            privateEventTarget.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 reloadVideo(targetTime) {
            if (!player.api) return;
            apiProxy.loadVideoById(video.id, targetTime);
        }

        function reloadToCurrentProgress() {
            reloadVideo(video.realCurrentProgress);
        }

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

        const currentlyObservedContainers = new WeakMap();
        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 _handlePageDataUpdate(event) {
            page.type = event.detail?.pageType;
        }

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

        function _handlePageshowEvent() {
            const shouldTryEarly =
                window.location.pathname.startsWith('/watch') ||
                window.location.pathname.startsWith('/embed') ||
                window.location.pathname.startsWith('/shorts');
            if (shouldTryEarly) _handlePlayerUpdate();
        }

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

        function addPlayerStateListeners() {
            const PLAYER_UPDATE_EVENT = page.isMobile ? 'video-data-change' : '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 initialize() {
            window.addEventListener('pageshow', _handlePageshowEvent);
            checkIsIframe();
            if (!page.isIframe) {
                addPlayerStateListeners();
                addPageStateListeners();
                addChatStateListeners();
            }
        }

        initialize();

        const publicApi = {
            get player() {
                return readOnlyPlayer;
            },
            get video() {
                return readOnlyVideo;
            },
            get chat() {
                return readOnlyChat;
            },
            get page() {
                return readOnlyPage;
            },
            POSSIBLE_RESOLUTIONS,
            updateAdState,
            fallbackUpdateAdState,
            getOptimalResolution,
            setPlaybackResolution,
            saveToStorage,
            loadFromStorage,
            loadAndCleanFromStorage,
            deleteFromStorage,
            listFromStorage,
            reloadVideo,
            reloadToCurrentProgress,
            apiProxy,
            eventTarget: privateEventTarget,
        };

        return publicApi;
    })();