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