YouTube Helper API

YouTube Helper API.

Dieses Skript sollte nicht direkt installiert werden. Es handelt sich hier um eine Bibliothek für andere Skripte, welche über folgenden Befehl in den Metadaten eines Skriptes eingebunden wird // @require https://update.greasyforks.org/scripts/549881/1664588/YouTube%20Helper%20API.js

// ==UserScript==
// @name          YouTube Helper API
// @author        ElectroKnight22
// @namespace     electroknight22_helper_api_namespace
// @version       0.2.0
// @license       MIT
// @description   YouTube Helper API.
// ==/UserScript==

/*jshint esversion: 11 */

/**
 * @namespace youtubeHelperApi
 * @description A comprehensive helper API for interacting with the YouTube player and page.
 * It provides a stable interface for accessing player state, video data, and controlling playback.
 */
window.youtubeHelperApi = (function () {
    ('use strict');

    /**
     * @property {Object} SELECTORS - A centralized object for all CSS selectors used in the script.
     * @private
     */
    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',
    };

    /**
     * @property {Object} POSSIBLE_RESOLUTIONS - A manually updated map of all the possible YouTube's resolution options.
     * @public
     */
    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' },
    });

    /**
     * @property {Proxy} apiProxy - A safe proxy to interact with YouTube's internal player API.
     * Prevents errors when the API or its methods are not available.
     * @private
     */
    const apiProxy = new Proxy(
        {},
        {
            get(target, property) {
                return (...args) => {
                    if (player.api && typeof player.api[property] === 'function') {
                        return player.api[property](...args);
                    }
                };
            },
        },
    );

    /**
     * @property {Object} player - Internal state object for the YouTube player.
     * @property {Object|null} player.playerObject - The root HTML element of the player.
     * @property {Object|null} player.api - The internal YouTube player API object.
     * @property {HTMLVideoElement|null} player.videoElement - The <video> element.
     * @property {boolean} player.isFullscreen - Whether the player is in fullscreen mode.
     * @property {boolean} player.isTheater - Whether the player is in theater mode.
     * @private
     */
    const player = {
        playerObject: null,
        api: null,
        videoElement: null,
        isFullscreen: false,
        isTheater: false,
    };

    /**
     * @property {Object} video - Internal state object for the current video's data.
     * @private
     */
    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,
    };

    /**
     * @property {Object} chat - Internal state object for the live chat component.
     * @private
     */
    const chat = {
        container: null,
        iFrame: null,
        isCollapsed: false,
    };

    /**
     * @property {Object} page - Internal state object for the current YouTube page.
     * @private
     */
    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',
    };
    let detectedAds = false;

    /**
     * @description Finds the current YouTube player element on the page as a fallback.
     * @returns {HTMLElement|null} The player element or null if not found.
     * @private
     */
    function fallbackGetPlayerApi() {
        const pathname = window.location.pathname;
        if (pathname.startsWith('/shorts')) return document.querySelector(SELECTORS.shortsPlayer);
        if (pathname.startsWith('/watch')) return document.querySelector(SELECTORS.watchPlayer);
        return document.querySelector(SELECTORS.inlinePlayer);
    }

    /**
     * @description Saves data to localStorage.
     * @param {string} storageKey - The key to save the data under.
     * @param {*} data - The data to be saved (will be JSON stringified).
     */
    function saveToStorage(storageKey, data) {
        try {
            localStorage.setItem(storageKey, JSON.stringify(data));
        } catch (error) {
            console.error(`Error saving data for key "${storageKey}":`, error);
        }
    }

    /**
     * @description Loads data from localStorage, merging with default data.
     * @param {string} storageKey - The key to load data from.
     * @param {Object} defaultData - Default data to return if loading fails or key doesn't exist.
     * @returns {Object} The loaded and merged data.
     */
    function loadFromStorage(storageKey, defaultData) {
        try {
            const storedData = JSON.parse(localStorage.getItem(storageKey));
            return { ...defaultData, ...storedData };
        } catch (error) {
            console.error(`Error loading data for key "${storageKey}":`, error);
            return defaultData;
        }
    }

    /**
     * @description Loads data from localStorage and cleans it, ensuring only keys present in defaultData are kept.
     * @param {string} storageKey - The key to load data from.
     * @param {Object} defaultData - The object defining the desired shape of the loaded data.
     * @returns {Object} The loaded and cleaned data.
     */
    function loadAndCleanFromStorage(storageKey, defaultData) {
        try {
            const storedData = JSON.parse(localStorage.getItem(storageKey)) || {};
            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;
        }
    }

    /**
     * @description Calculates the best playable quality based on a target resolution string and the actual available resolutions.
     * @param {string} targetResolutionString - The desired quality (e.g., 'hd1080', 'large').
     * @param {boolean} [usePremium=true] - Whether to use premium bitrate versions if available.
     * @returns {Object|null} The optimal quality data object from the player API, or null.
     */
    function getOptimalResolution(targetResolutionString, usePremium = true) {
        const PREMIUM_INDICATOR = 'Premium';
        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.qualityLabel?.trim().endsWith(PREMIUM_INDICATOR)) premiumCandidate = quality;
                    else normalCandidate = quality;
                }
            }
            return premiumCandidate || normalCandidate;
        } catch (error) {
            console.error('Error when resolving optimal quality:', error);
            return null;
        }
    }

    /**
     * @description Sets the video playback quality.
     * @param {string} targetResolution - The desired quality string (e.g., 'hd1080').
     * @param {boolean} [ignoreAvailable=false] - If true, forces the quality without checking availability.
     * @param {boolean} [usePremium=true] - Whether to use premium bitrate versions if available.
     */
    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);
        }
    }

    /**
     * @description Dispatches a custom event to signal that the API is ready and has updated player data.
     * @private
     */
    function dispatchHelperApiReadyEvent() {
        if (!player.api) return;
        const event = new CustomEvent('yt-helper-api-ready', { detail: Object.freeze({ ...publicApi }) });
        document.dispatchEvent(event);
    }

    /**
     * @description Updates the video language states based on available and playing audio tracks.
     * @private
     */
    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;
        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;
    }

    /**
     * @description Updates the internal video state object with the latest data from the player response.
     * @private
     */
    function updateVideoState() {
        if (!player.api) return;
        const playerResponseObject = apiProxy.getPlayerResponse();
        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;
    }

    /**
     * @description Updates the internal player state object.
     * @param {Event|null} event - The event that triggered the update (e.g., 'yt-player-updated').
     * @private
     */
    function updatePlayerState(event) {
        player.api = event?.target?.player_ ?? fallbackGetPlayerApi();
        player.playerObject = event?.target?.playerContainer_?.children[0] ?? fallbackGetPlayerApi();
        player.videoElement = player.playerObject?.querySelector(SELECTORS.videoElement);
    }

    /**
     * @description Updates fullscreen state based on the document's fullscreen element.
     * @private
     */
    function updateFullscreenState() {
        player.isFullscreen = !!document.fullscreenElement;
    }

    /**
     * @description Updates theater mode state from a custom YouTube event.
     * @param {CustomEvent} event - The 'yt-set-theater-mode-enabled' event.
     * @private
     */
    function updateTheaterState(event) {
        player.isTheater = !!event?.detail?.enabled;
    }

    /**
     * @description Updates the chat state from a custom YouTube event.
     * @param {CustomEvent} event - The 'yt-chat-collapsed-changed' event.
     * @private
     */
    function updateChatState(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 }) }));
    }

    /**
     * @description Checks if the script is running inside an iframe and dispatches an event if so.
     * @private
     */
    function checkIsIframe() {
        if (page.isIframe) document.dispatchEvent(new Event('yt-helper-api-detected-iframe'));
    }

    /**
     * @description Checks for ad presence by inspecting CSS classes on the player container.
     * @returns {boolean|undefined} True if ad is present, otherwise false.
     */
    function checkAdPresense() {
        try {
            const shouldAvoid = player.container.classList.contains('unstarted-mode'); // YouTube doesn't update ad state fully until player is marked as started.
            const isAdPresent = player.container.classList.contains('ad-showing') || player.container.classList.contains('ad-interrupting');
            return !shouldAvoid && isAdPresent;
        } catch (error) {
            console.error('Error in checkAdState:', error);
            return false;
        }
    }

    /**
     * @description A fallback method to check for ads by comparing video duration from different API sources.
     * @returns {boolean} True if an ad is detected.
     */
    function fallbackCheckAdPresense() {
        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 hasAds = durationMismatch;
            if (hasAds) document.dispatchEvent(new CustomEvent('yt-helper-api-ad-detected'));
            if (hasAds !== detectedAds)
                document.dispatchEvent(new CustomEvent('yt-helper-api-ad-state-changed', { detail: Object.freeze({ adState: hasAds }) }));
            detectedAds = hasAds;
            return detectedAds;
        } catch (error) {
            console.error('Error during ad check:', error);
            return false;
        }
    }

    /**
     * @description Main handler for player updates. Triggers all necessary state update functions.
     * @param {Event|null} [event=null] - The event that triggered the update.
     * @private
     */
    function handlePlayerUpdate(event = null) {
        updatePlayerState(event);
        updateVideoState();
        updateVideoLanguage();
        fallbackCheckAdPresense();
        dispatchHelperApiReadyEvent();
    }

    /**
     * @description Handler for the 'yt-navigate-finish' event. Updates page state.
     * @param {CustomEvent} event - The navigation event.
     * @private
     */
    function handleNavigationFinish(event) {
        page.type = event?.detail?.pageType;
        page.manager = document.querySelector(SELECTORS.pageManager);
        page.watchFlexy = document.querySelector(SELECTORS.watchFlexy);
    }

    /**
     * @description Adds event listeners related to the player state.
     * @private
     */
    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);
    }

    /**
     * @description Adds event listeners related to the chat state.
     * @private
     */
    function addChatStateListeners() {
        document.addEventListener('yt-chat-collapsed-changed', updateChatState);
    }

    /**
     * @description Adds event listeners related to page navigation.
     * @private
     */
    function addNavigationListeners() {
        document.addEventListener('yt-navigate-finish', handleNavigationFinish);
    }

    /**
     * @description Initializes the script by setting up all event listeners.
     * @private
     */
    function initialize() {
        window.addEventListener('pageshow', handlePlayerUpdate);
        checkIsIframe();
        addNavigationListeners();
        addPlayerStateListeners();
        addChatStateListeners();
    }

    initialize();

    const publicApi = {
        /** @type {Object} A read-only copy of the player state object. */
        get player() {
            return { ...player };
        },
        /** @type {Object} A read-only copy of the video state object. */
        get video() {
            return { ...video };
        },
        /** @type {Object} A read-only copy of the chat state object. */
        get chat() {
            return { ...chat };
        },
        /** @type {Object} A read-only copy of the page state object. */
        get page() {
            return { ...page };
        },
        /** @type {boolean} A read-only flag indicating if an ad has been detected. */
        get isAdPlaying() {
            return detectedAds;
        },
        POSSIBLE_RESOLUTIONS,
        checkAdPresense,
        fallbackCheckAdPresense,
        getOptimalResolution,
        setPlaybackResolution,
        saveToStorage,
        loadFromStorage,
        loadAndCleanFromStorage,
        apiProxy,
    };

    return publicApi;
})();
长期地址
遇到问题?请前往 GitHub 提 Issues。