YouTube Helper API

YouTube Helper API.

Tính đến 27-09-2025. Xem phiên bản mới nhất.

Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta // @require https://update.greasyfork.org/scripts/549881/1667655/YouTube%20Helper%20API.js

// ==UserScript==
// @name                YouTube Helper API
// @author              ElectroKnight22
// @namespace           electroknight22_helper_api_namespace
// @version             0.4.0
// @grant               GM.getValue
// @grant               GM.setValue
// @grant               GM.deleteValue
// @grant               GM.listValues
// @grant               GM_getValue
// @grant               GM_setValue
// @grant               GM_deleteValue
// @grant               GM_listValues
// @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) {
                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) {
        try {
            await storageApi.setValue(storageKey, data);
        } catch (error) {
            console.error(`Error saving data for key "${storageKey}":`, error);
        }
    }

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

    async function loadAndCleanFromStorage(storageKey, defaultData) {
        try {
            const storedData = (await storageApi.getValue(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;
        }
    }

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