YouTube Helper API

YouTube Helper API.

Tätä skriptiä ei tulisi asentaa suoraan. Se on kirjasto muita skriptejä varten sisällytettäväksi metadirektiivillä // @require https://update.greasyforks.org/scripts/549881/1667858/YouTube%20Helper%20API.js.

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

/*jshint esversion: 11 */

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: -1, // YouTube can return the progress of the ad playing instead of the video content so we need implement our own progress tracking.
    };

    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 saveToStorage(storageKey, data) {
        const dataToStore = {
            data: data,
            metadata: {
                timestamp: Date.now(),
            },
        };
        try {
            await storageApi.setValue(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 _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;
    }

    const currentlyObservedContainers = new WeakMap();

    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);
    }

    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;
        }
    }

    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 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();
        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;
    }

    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 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 }) }));
    }

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

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

    function updateAdState() {
        if (!player.container) return;
        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');
            const isPlayingAds = !shouldAvoid && isAdPresent;
            player.isPlayingAds = isPlayingAds;
            notifyAdState();
        } 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;
            notifyAdState();
        } 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.container) return;
        if (currentlyObservedContainers.has(player.container)) return;
        const adStateObserver = new MutationObserver(updateAdState);
        adStateObserver.observe(player.container, { attributes: true, attributeFilter: ['class'] });
        currentlyObservedContainers.set(player.container, adStateObserver);
    }

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

    function handleNavigationFinish(event) {
        page.type = event?.detail?.pageType;
        page.manager = document.querySelector(SELECTORS.pageManager);
        page.watchFlexy = document.querySelector(SELECTORS.watchFlexy);
    }

    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', updateChatState);
    }

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

    function initialize() {
        window.addEventListener('pageshow', handlePlayerUpdate);
        checkIsIframe();
        if (!page.isIframe) {
            addNavigationListeners();
            addPlayerStateListeners();
            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,
        reloadToCurrentProgress,
        apiProxy,
    };

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