From 72ec855fe64d663a7971377021df5f723fee523b Mon Sep 17 00:00:00 2001 From: drunkwinter <38593134+drunkwinter@users.noreply.github.com> Date: Sat, 16 Nov 2024 11:05:42 +0100 Subject: [PATCH] [refactor] cleanup Probably one of my last commits --- .github/scripts/publish_to_greasyfork/main.ts | 15 +- account-proxy/.gitignore | 1 - account-proxy/README.md | 49 ---- account-proxy/controllers/proxyController.js | 94 -------- account-proxy/controllers/statsController.js | 13 - account-proxy/lib/innertubeApi.js | 63 ----- account-proxy/lib/stats.js | 140 ----------- account-proxy/lib/types.js | 46 ---- account-proxy/lib/utils.js | 66 ----- account-proxy/package.json | 13 - account-proxy/server.js | 49 ---- package.json | 6 +- scripts/build.js | 55 +++-- src/components/confirmation.js | 24 -- src/components/endpoints/index.js | 2 - src/components/endpoints/innertube.js | 32 --- src/components/endpoints/proxy.js | 61 ----- src/components/errorScreen/index.js | 32 --- .../errorScreen/templates/button.html | 3 - src/components/innertube.js | 27 +++ src/components/inspectors/googlevideo.js | 11 - src/components/inspectors/index.js | 4 - src/components/inspectors/next.js | 19 -- src/components/inspectors/player.js | 27 --- src/components/inspectors/search.js | 7 - src/components/interceptors/generic.js | 14 -- src/components/interceptors/index.js | 5 - src/components/interceptors/initialData.js | 61 ----- src/components/interceptors/json.js | 10 - src/components/interceptors/natives.js | 2 - src/components/interceptors/request.js | 32 --- src/components/interceptors/xhrOpen.js | 27 --- src/components/requestPreprocessor.js | 107 --------- src/components/storage.js | 13 - src/components/strategies/index.js | 2 - src/components/strategies/next.js | 54 ----- src/components/strategies/player.js | 121 ---------- src/components/thumbnailFix.js | 44 +++- src/components/toast/templates/desktop.html | 1 - src/components/toast/templates/mobile.html | 5 - src/components/ui/errorScreen.js | 48 ++++ .../{toast/index.js => ui/toast.js} | 17 +- src/components/unlocker/index.js | 4 - src/components/unlocker/next.js | 98 ++++++-- src/components/unlocker/player.js | 209 ++++++++++++---- src/config.js | 42 +--- src/{utils => }/logger.js | 18 +- src/main.js | 227 +++++++++++++++--- src/utils.js | 94 ++++++++ src/utils/index.js | 156 ------------ userscript.config.js | 30 --- 51 files changed, 707 insertions(+), 1593 deletions(-) delete mode 100644 account-proxy/.gitignore delete mode 100644 account-proxy/README.md delete mode 100644 account-proxy/controllers/proxyController.js delete mode 100644 account-proxy/controllers/statsController.js delete mode 100644 account-proxy/lib/innertubeApi.js delete mode 100644 account-proxy/lib/stats.js delete mode 100644 account-proxy/lib/types.js delete mode 100644 account-proxy/lib/utils.js delete mode 100644 account-proxy/package.json delete mode 100644 account-proxy/server.js delete mode 100644 src/components/confirmation.js delete mode 100644 src/components/endpoints/index.js delete mode 100644 src/components/endpoints/innertube.js delete mode 100644 src/components/endpoints/proxy.js delete mode 100644 src/components/errorScreen/index.js delete mode 100644 src/components/errorScreen/templates/button.html create mode 100644 src/components/innertube.js delete mode 100644 src/components/inspectors/googlevideo.js delete mode 100644 src/components/inspectors/index.js delete mode 100644 src/components/inspectors/next.js delete mode 100644 src/components/inspectors/player.js delete mode 100644 src/components/inspectors/search.js delete mode 100644 src/components/interceptors/generic.js delete mode 100644 src/components/interceptors/index.js delete mode 100644 src/components/interceptors/initialData.js delete mode 100644 src/components/interceptors/json.js delete mode 100644 src/components/interceptors/natives.js delete mode 100644 src/components/interceptors/request.js delete mode 100644 src/components/interceptors/xhrOpen.js delete mode 100644 src/components/requestPreprocessor.js delete mode 100644 src/components/storage.js delete mode 100644 src/components/strategies/index.js delete mode 100644 src/components/strategies/next.js delete mode 100644 src/components/strategies/player.js delete mode 100644 src/components/toast/templates/desktop.html delete mode 100644 src/components/toast/templates/mobile.html create mode 100644 src/components/ui/errorScreen.js rename src/components/{toast/index.js => ui/toast.js} (76%) delete mode 100644 src/components/unlocker/index.js rename src/{utils => }/logger.js (60%) create mode 100644 src/utils.js delete mode 100644 src/utils/index.js delete mode 100644 userscript.config.js diff --git a/.github/scripts/publish_to_greasyfork/main.ts b/.github/scripts/publish_to_greasyfork/main.ts index 423ce69..82bacf3 100644 --- a/.github/scripts/publish_to_greasyfork/main.ts +++ b/.github/scripts/publish_to_greasyfork/main.ts @@ -18,9 +18,12 @@ async function main() { const greasyfork_script_type = (() => { switch (process.env.GREASYFORK_SCRIPT_TYPE) { - case 'public': return '1'; - case 'unlisted': return '2'; - case 'library': return '3'; + case 'public': + return '1'; + case 'unlisted': + return '2'; + case 'library': + return '3'; } })(); if (!greasyfork_script_type) { @@ -56,7 +59,7 @@ async function publish_to_greasyfork(user_email: string, user_pass: string, scri const BASE_URL = 'https://greasyfork.org'; // "/en/search" appears to be the lightest page - const LIGHTEST_PAGE_URL= `${BASE_URL}/en/search`; + const LIGHTEST_PAGE_URL = `${BASE_URL}/en/search`; // Get initial page to retrieve the initial tokens const initial_response = await fetch(LIGHTEST_PAGE_URL); @@ -70,7 +73,7 @@ async function publish_to_greasyfork(user_email: string, user_pass: string, scri throw new Error('Could not retrieve initial authenticity token'); } - const login_request_url= `${BASE_URL}/en/users/sign_in`; + const login_request_url = `${BASE_URL}/en/users/sign_in`; const login_request_options: RequestInit = { method: 'POST', @@ -130,7 +133,7 @@ async function publish_to_greasyfork(user_email: string, user_pass: string, scri update_body.set('script[adult_content_self_report]', '0'); update_body.set('commit', 'Post new version'); - const upload_request_url= `${BASE_URL}/en/scripts/${script_id}/versions`; + const upload_request_url = `${BASE_URL}/en/scripts/${script_id}/versions`; const upload_request_options: RequestInit = { method: 'POST', diff --git a/account-proxy/.gitignore b/account-proxy/.gitignore deleted file mode 100644 index 497d582..0000000 --- a/account-proxy/.gitignore +++ /dev/null @@ -1 +0,0 @@ -stats.json diff --git a/account-proxy/README.md b/account-proxy/README.md deleted file mode 100644 index 6d3ea95..0000000 --- a/account-proxy/README.md +++ /dev/null @@ -1,49 +0,0 @@ -## YouTube Account Proxy - -Note: This is not a part of the browser script! -
If you only want to watch age-restricted videos, follow these instructions. - -This is the account proxy server to access age-restricted videos via an age-verified or US-located YouTube account. You can run your own account proxy server instance and include the cookies of a YouTube account in the configuration. This allows you to share the account with others to access age-restricted videos. - -### Installation -1. Install required dependencies with `npm install`. -2. Create the `.env` configuration (see below) and paste the required cookies from your YouTube account. -> The YouTube account must be verified in order to unlock some age-restricted videos. -3. Start the server with `npm run proxy:start` or `npm start` in `/account-proxy`. - -> If your account is not age-verified and you don't have an IP from the USA, you might need to use an http(s) proxy located in that region. - -``.env`` configuration: - -````ini -PORT=8089 - -# Trust X-Forwarded-* headers? set this to 1 if you use a reverse proxy like Nginx with a corresponding configuration. -ENABLE_TRUST_PROXY=0 - -# Define if you want to collect anonymous usage statistics to monitor the server load -# The statistics can be retrieved via the `/getStats` endpoint in JSON format -ENABLE_STATS=1 - -# Cookie values (required) -SID= -HSID= -SSID= -APISID= -SAPISID= -PSIDTS= - -# Proxy (https) -# PROXY= -```` - -### Logging - -If you run your own proxy instance then you should respect the privacy of the users and reduce all log activities to a minimum. - -Concretely this means: -- Do not log IP Addresses -- Do not log Video-IDs - -The account proxy itself does not log any user-related data by default. However, it could be that your reverse proxy is logging the requests (if you use one). -For Nginx you can disable the log functionality as follows: https://thisinterestsme.com/disable-nginx-access-log/ diff --git a/account-proxy/controllers/proxyController.js b/account-proxy/controllers/proxyController.js deleted file mode 100644 index 58e4759..0000000 --- a/account-proxy/controllers/proxyController.js +++ /dev/null @@ -1,94 +0,0 @@ -import dotenv from 'dotenv'; -import { ProxyAgent } from 'undici'; - -import { extractAttributes, getYoutubeResponseStatus, checkForGcrFlag } from '../lib/utils.js'; -import { YouTubeCredentials, YouTubeClientParams } from '../lib/types.js'; -import innertubeApi from '../lib/innertubeApi.js'; -import stats from '../lib/stats.js'; - -dotenv.config(); - -const credentials = new YouTubeCredentials(); -const proxy = process.env.PROXY; -const proxyAgent = proxy ? new ProxyAgent(proxy) : undefined; - -function getPlayer(req, res) { - handleProxyRequest(req, res, 'player'); -} - -function getNext(req, res) { - handleProxyRequest(req, res, 'next'); -} - -async function handleProxyRequest(req, res, endpoint) { - const tsStart = new Date().getTime(); - - try { - const clientParams = new YouTubeClientParams(); - clientParams.fromRequest(req); - clientParams.validate(); - - stats.countRequest(clientParams.reason, clientParams.clientName, req.headers['cf-ipcountry'], req.headers['origin']); - - // Hotfix for embed player - if (clientParams.clientName === 'WEB_EMBEDDED_PLAYER') { - clientParams.clientName = 'WEB'; - clientParams.clientVersion = '2.20220228.01.00'; - } - - const pRequests = [innertubeApi.sendApiRequest(endpoint, clientParams, credentials, proxyAgent)]; - - // Include /next (sidebar) if flag set - if (clientParams.includeNext) { - pRequests.push(innertubeApi.sendApiRequest('next', clientParams, credentials, proxyAgent)); - } - - const [playerResponse, nextResponse] = await Promise.all(pRequests); - - if (!playerResponse || typeof playerResponse !== 'object') { - throw new Error(`Invalid YouTube response received for endpoint /player`); - } - - if (clientParams.includeNext && (!nextResponse || typeof nextResponse !== 'object')) { - throw new Error(`Invalid YouTube response received for endpoint /next`); - } - - const youtubeStatus = getYoutubeResponseStatus(playerResponse); - const youtubeGcrFlagSet = checkForGcrFlag(playerResponse); - - stats.countResponse('player', youtubeStatus, youtubeGcrFlagSet); - - const responseData = extractAttributes(playerResponse, ['playabilityStatus', 'videoDetails', 'streamingData']); - - responseData.proxy = { clientParams, youtubeGcrFlagSet, youtubeStatus }; - - /** - * Workaround: when we provide `adaptiveFormats` the client cannot playback the video. - * - * It seems the URLs we get here or the one the client constructs from these URLs are tied to the requesting account. - * The low quality `formats` URLs seem fine. - */ - delete responseData.streamingData.adaptiveFormats; - - if (nextResponse) { - stats.countResponse('next', getYoutubeResponseStatus(nextResponse), null); - responseData.nextResponse = extractAttributes(nextResponse, ['contents', 'engagementPanels']); - } - - res.code(200).send(responseData); - } catch (err) { - if (err instanceof Error) { - console.error(endpoint, err.message); - res.code(500).send({ errorMessage: err.message }); - stats.countResponse(endpoint, 'EXCEPTION'); - stats.countException(endpoint, err.message); - } - } - - stats.countLatency(new Date().getTime() - tsStart); -} - -export default { - getPlayer, - getNext -} diff --git a/account-proxy/controllers/statsController.js b/account-proxy/controllers/statsController.js deleted file mode 100644 index 681e816..0000000 --- a/account-proxy/controllers/statsController.js +++ /dev/null @@ -1,13 +0,0 @@ -import stats from '../lib/stats.js'; - -function getStats(req, res) { - res.header('Cache-Control', 'no-store'); - - return req.query.historical - ? stats.getHistoricalStats() - : stats.getTodayStats(); -} - -export default { - getStats -} diff --git a/account-proxy/lib/innertubeApi.js b/account-proxy/lib/innertubeApi.js deleted file mode 100644 index 7c12784..0000000 --- a/account-proxy/lib/innertubeApi.js +++ /dev/null @@ -1,63 +0,0 @@ -import crypto from 'node:crypto'; - -import { fetch } from 'undici'; - -const generateSidBasedAuth = function (sapisid) { - const timestamp = Math.floor(new Date().getTime() / 1000); - const hashInput = timestamp + " " + sapisid + " " + "https://www.youtube.com"; - const hashDigest = crypto.createHash("sha1").update(hashInput).digest("hex"); - return `SAPISIDHASH ${timestamp}_${hashDigest}`; -} - -const generateApiRequestData = function (clientParams) { - return { - "videoId": clientParams.videoId, - "context": { - "client": { - "hl": clientParams.hl, - "gl": "US", - "clientName": clientParams.clientName, - "clientVersion": clientParams.clientVersion, - "userInterfaceTheme": clientParams.userInterfaceTheme, - }, - }, - "playbackContext": { - "contentPlaybackContext": { - "signatureTimestamp": clientParams.signatureTimestamp, - } - }, - "racyCheckOk": true, - "contentCheckOk": true, - "startTimeSecs": clientParams.startTimeSecs, - } -} - -const generateApiRequestHeaders = function (credentials) { - return { - "cookie": `SID=${credentials.SID}; HSID=${credentials.HSID}; SSID=${credentials.SSID}; APISID=${credentials.APISID}; SAPISID=${credentials.SAPISID}; __Secure-1PSIDTS=${credentials.PSIDTS};`, - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", - "content-type": "application/json", - "authorization": generateSidBasedAuth(credentials.SAPISID), - "origin": "https://www.youtube.com", - } -} - -const sendApiRequest = async function (endpoint, clientParams, credentials, proxyAgent) { - const url = `https://www.youtube.com/youtubei/v1/${endpoint}?prettyPrint=false`; - const headers = generateApiRequestHeaders(credentials); - const data = generateApiRequestData(clientParams); - - const res = await fetch(url, { - method: 'POST', - headers, - body: JSON.stringify(data), - dispatcher: proxyAgent, - signal: AbortSignal.timeout(5000), - }); - - return res.json(); -} - -export default { - sendApiRequest -} diff --git a/account-proxy/lib/stats.js b/account-proxy/lib/stats.js deleted file mode 100644 index f31c845..0000000 --- a/account-proxy/lib/stats.js +++ /dev/null @@ -1,140 +0,0 @@ -import fs from 'node:fs'; - -const statsFileName = 'stats.json'; - -let initialized = false; - -let stats = {}; - -let requestsLastMinute = 0; -let requestsThisMinute = 0; - -let latencyMs = 0; -let latencyMsSumUp = 0; -let latencyRequestCount = 0; - -let playabilityHistory = []; - -function init() { - // Load stats from file - try { - stats = JSON.parse(fs.readFileSync(statsFileName)); - } catch (err) { } - - // Save stats to file - setInterval(() => { - try { - fs.writeFile(statsFileName, JSON.stringify(stats), () => { }); - } catch (err) { } - }, 10 * 60 * 1000) - - - setInterval(() => { - // Reset request count every minute - requestsLastMinute = requestsThisMinute; - requestsThisMinute = 0; - - // Calc latency every minute - latencyMs = latencyRequestCount > 0 ? (latencyMsSumUp / latencyRequestCount) : 0; - latencyMsSumUp = 0; - latencyRequestCount = 0; - }, 60 * 1000) - - initialized = true; -} - -function getTodayKey() { - return (new Date()).toISOString().split('T')[0]; -} - -function countDayMetric(metric, key) { - const day = getTodayKey(); - - if (!stats.days) stats.days = {}; - if (!stats.days[day]) stats.days[day] = {}; - if (!stats.days[day][metric]) stats.days[day][metric] = {}; - if (!stats.days[day][metric][key]) stats.days[day][metric][key] = 0; - - stats.days[day][metric][key] += 1; -} - - -function countRequest(reason, clientName, country, origin) { - if (!initialized) return; - - requestsThisMinute += 1; - - if (typeof reason === 'string' && reason !== 'UNKNOWN') { - countDayMetric('reasons', reason.toUpperCase()); - } - - if (typeof clientName == 'string') { - countDayMetric('clients', clientName.toUpperCase()); - } - - if (typeof country === 'string') { - countDayMetric('countries', country.toUpperCase()); - } - - if (typeof origin === 'string') { - countDayMetric('origins', origin); - } -} - -function countResponse(endpoint, status, containsGcrFlag) { - if (!initialized) return; - - const key = `${endpoint.toUpperCase()}:${status}`; - countDayMetric('responseResults', key); - - if (typeof containsGcrFlag === 'boolean') { - countDayMetric('responseContainsGcrFlag', containsGcrFlag ? "YES" : "NO"); - } - - if (endpoint === 'player') { - playabilityHistory.push(status); - playabilityHistory = playabilityHistory.slice(-50); - } -} - -function countException(endpoint, message) { - if (!initialized) return; - - const key = `${endpoint.toUpperCase()}:${message}`; - countDayMetric('exceptions', key); -} - -function countLatency(milliSecs) { - if (!initialized) return; - - latencyMsSumUp += milliSecs; - latencyRequestCount += 1; -} - -function getRequestsPerMinute() { - return requestsLastMinute; -} - -function getHistoricalStats() { - return stats; -} - -function getTodayStats() { - return { - latencyMs: Math.round(latencyMs), - requestsPerMinute: getRequestsPerMinute(), - ...(stats.days?.[getTodayKey()] || {}), - playabilityHistory - }; -} - -export default { - init, - countRequest, - countResponse, - countException, - countLatency, - getRequestsPerMinute, - getHistoricalStats, - getTodayStats -} diff --git a/account-proxy/lib/types.js b/account-proxy/lib/types.js deleted file mode 100644 index 0370ece..0000000 --- a/account-proxy/lib/types.js +++ /dev/null @@ -1,46 +0,0 @@ -import dotenv from 'dotenv'; - -import { fillObjectFromRequest, validateObjectAttributes } from './utils.js'; - -dotenv.config(); - -class YouTubeCredentials { - constructor() { - this.SID = process.env.SID; - this.HSID = process.env.HSID; - this.SSID = process.env.SSID; - this.APISID = process.env.APISID; - this.SAPISID = process.env.SAPISID; - this.PSIDTS = process.env.PSIDTS; - - validateObjectAttributes(this); - } -} - -class YouTubeClientParams { - constructor() { - // Default values - this.videoId = null; - this.reason = 'UNKNOWN'; - this.clientName = 'WEB'; - this.clientVersion = '2.20210721.00.00'; - this.signatureTimestamp = 18834; - this.hl = 'en'; - this.startTimeSecs = 0; - this.userInterfaceTheme = 'USER_INTERFACE_THEME_DARK'; - this.includeNext = false; - } - - fromRequest(request) { - fillObjectFromRequest(this, request); - } - - validate() { - validateObjectAttributes(this); - } -} - -export { - YouTubeCredentials, - YouTubeClientParams -} diff --git a/account-proxy/lib/utils.js b/account-proxy/lib/utils.js deleted file mode 100644 index 9a6b753..0000000 --- a/account-proxy/lib/utils.js +++ /dev/null @@ -1,66 +0,0 @@ -function fillObjectFromRequest(obj, request) { - for (const propName in obj) { - if (!(propName in request.query) || request.query[propName] === 'undefined') { - continue; - } - - switch(typeof obj[propName]) { - case 'number': - obj[propName] = parseInt(request.query[propName]); - break; - case 'boolean': - obj[propName] = request.query[propName] === '1' || request.query[propName] === 'true'; - break; - default: - obj[propName] = request.query[propName]; - } - } -} - -function validateObjectAttributes(obj) { - for (const propName in obj) { - if (obj[propName] === null || obj[propName] === '') { - throw new Error(`Missing value for ${propName}`); - } - - if (typeof obj[propName] === 'number' && isNaN(obj[propName])) { - throw new Error(`Invalid number value for ${propName}`); - } - } -} - -function extractAttributes(obj, attributesArray) { - let newObj = {}; - - for (const i in attributesArray) { - let attr = attributesArray[i]; - if (obj[attr]) { - newObj[attr] = obj[attr]; - } - } - - return newObj; -} - -function getYoutubeResponseStatus(youtubeResponse) { - return youtubeResponse.playabilityStatus?.status - ?? (youtubeResponse.status ? `HTTP${youtubeResponse.status}` : 'unknown'); -} - -function checkForGcrFlag(youtubeData) { - if (typeof youtubeData.streamingData !== 'object') { - return; - } - - let streamingDataJson = JSON.stringify(youtubeData.streamingData); - - return streamingDataJson.includes('gcr=') || streamingDataJson.includes('gcr%3D'); -} - -export { - fillObjectFromRequest, - validateObjectAttributes, - extractAttributes, - getYoutubeResponseStatus, - checkForGcrFlag -} diff --git a/account-proxy/package.json b/account-proxy/package.json deleted file mode 100644 index b8b747f..0000000 --- a/account-proxy/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "type": "module", - "scripts": { - "start": "node server.js" - }, - "dependencies": { - "@fastify/cors": "^9.0.1", - "@fastify/rate-limit": "^9.1.0", - "dotenv": "^16.4.5", - "fastify": "^4.28.1", - "undici": "^6.19.5" - } -} diff --git a/account-proxy/server.js b/account-proxy/server.js deleted file mode 100644 index 16ae781..0000000 --- a/account-proxy/server.js +++ /dev/null @@ -1,49 +0,0 @@ -import process from 'node:process'; - -import dotenv from 'dotenv'; -import Fastify from 'fastify'; -import cors from '@fastify/cors'; -import rateLimit from '@fastify/rate-limit'; - -import proxyController from './controllers/proxyController.js'; - -dotenv.config(); - -const GITHUB_URL = 'https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass'; - -// Limit requests to 10 per 30s for a single IP -const RATE_LIMIT_MAX = 10; -const RATE_LIMIT_TIME_WINDOW = 30000; - -const app = Fastify({ - trustProxy: process.env.ENABLE_TRUST_PROXY === '1', -}); - -// Enable CORS -app.register(cors, { origin: true }); - -// Enable rate limit -app.register(rateLimit, { - max: RATE_LIMIT_MAX, - timeWindow: RATE_LIMIT_TIME_WINDOW, -}); - -// Routes -app.get('/', (_, res) => res.redirect(GITHUB_URL)); -app.get('/getPlayer', proxyController.getPlayer); -app.get('/getNext', proxyController.getNext); - -if (process.env.ENABLE_STATS === '1') { - const { default: statsController } = await import('./controllers/statsController.js'); - const { default: stats } = await import('./lib/stats.js'); - stats.init(); - app.get('/getStats', statsController.getStats); -} - -app.listen({ port: process.env.PORT }, (err, address) => { - if (err) { - console.error(err); - process.exit(1); - } - console.info(`Server listening at ${address}`); -}); diff --git a/package.json b/package.json index a3b2978..427e37d 100644 --- a/package.json +++ b/package.json @@ -17,14 +17,10 @@ "files": [ "dist/Simple-YouTube-Age-Restriction-Bypass.user.js" ], - "workspaces": [ - "account-proxy" - ], "scripts": { "build": "node --no-warnings scripts/build.js", "format": "dprint fmt", - "check-format": "dprint check", - "proxy:start": "npm start --workspaces account-proxy" + "check-format": "dprint check" }, "devDependencies": { "@babel/preset-env": "^7.26.0", diff --git a/scripts/build.js b/scripts/build.js index 03ee9d9..41054f4 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -1,5 +1,5 @@ import child_process from 'node:child_process'; -import fs from 'node:fs'; +import fs, { readFileSync } from 'node:fs'; import path from 'node:path'; import { getBabelOutputPlugin } from '@rollup/plugin-babel'; @@ -7,24 +7,55 @@ import commonjs from '@rollup/plugin-commonjs'; import nodeResolve from '@rollup/plugin-node-resolve'; import replace from '@rollup/plugin-replace'; import { rollup } from 'rollup'; -import html from 'rollup-plugin-html'; import archiver from 'archiver'; -import pkg from '../package.json' assert { type: 'json' }; -import manifest from '../src/extension/mv3/manifest.json' assert { type: 'json' }; +const pkg = JSON.parse(readFileSync('./package.json', 'utf8')); +const manifest = JSON.parse(readFileSync('./src/extension/mv3/manifest.json', 'utf8')); const OUT_PATH = 'dist'; -const USERSCRIPT_OUT_DIR = OUT_PATH; -const USERSCRIPT_OUT_NAME = 'Simple-YouTube-Age-Restriction-Bypass.user.js'; - const WEB_EXTENSION_OUT_DIR = `${OUT_PATH}/extension`; const WEB_EXTENSION_OUT_MAIN_SCRIPT_NAME = manifest.content_scripts[0].js[0]; const WEB_EXTENSION_OUT_WEB_SCRIPT_NAME = manifest.web_accessible_resources[0].resources[0]; const WEB_EXTENSION_OUT_MV2_ZIP_NAME = 'extension_mv2_firefox.zip'; const WEB_EXTENSION_OUT_MV3_ZIP_NAME = 'extension_mv3_chromium.zip'; +const USERSCRIPT_OUT_DIR = OUT_PATH; +const USERSCRIPT_OUT_NAME = 'Simple-YouTube-Age-Restriction-Bypass.user.js'; +const USERSCRIPT_CONFIG = ` +// ==UserScript== +// @name Simple YouTube Age Restriction Bypass +// @description Watch age restricted videos on YouTube without login and without age verification 😎 +// @description:de Schaue YouTube Videos mit Altersbeschränkungen ohne Anmeldung und ohne dein Alter zu bestätigen 😎 +// @description:fr Regardez des vidéos YouTube avec des restrictions d'âge sans vous inscrire et sans confirmer votre âge 😎 +// @description:it Guarda i video con restrizioni di età su YouTube senza login e senza verifica dell'età 😎 +// @icon https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/raw/v2.5.4/src/extension/icon/icon_64.png +// @version __BUILD_VERSION__ +// @author Zerody (https://github.com/zerodytrash) +// @namespace https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/ +// @supportURL https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/issues +// @updateURL https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/raw/main/dist/Simple-YouTube-Age-Restriction-Bypass.user.js +// @license MIT +// @match https://www.youtube.com/* +// @match https://www.youtube-nocookie.com/* +// @match https://m.youtube.com/* +// @match https://music.youtube.com/* +// @grant none +// @run-at document-start +// @compatible chrome +// @compatible firefox +// @compatible opera +// @compatible edge +// @compatible safari +// ==/UserScript== + +/* + This is a transpiled version to achieve a clean code base and better browser compatibility. + You can find the nicely readable source code at https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass +*/ +`; + console.time('\nTotal build time'); console.time('Cleaning /dist'); @@ -54,9 +85,7 @@ function cleanOutput() { } async function buildUserscript() { - function userscript(path) { - const config = fs.readFileSync(path, 'utf8'); - + function userscript() { const iife = (() => { const [top, bottom] = (() => { (function iife(ranOnce) { @@ -76,7 +105,7 @@ async function buildUserscript() { return { name: 'userscript', - banner: config + iife.top, + banner: USERSCRIPT_CONFIG + iife.top, footer: iife.bottom, }; } @@ -88,10 +117,9 @@ async function buildUserscript() { format: 'esm', }, plugins: [ - html(), nodeResolve(), commonjs(), - userscript('userscript.config.js'), + userscript(), replace({ __BUILD_TARGET__: `'USERSCRIPT'`, __BUILD_VERSION__: pkg.version, @@ -173,7 +201,6 @@ async function buildWebExtension() { format: 'iife', }, plugins: [ - html(), nodeResolve(), commonjs(), replace({ __BUILD_TARGET__: `'WEB_EXTENSION'`, preventAssignment: true }), diff --git a/src/components/confirmation.js b/src/components/confirmation.js deleted file mode 100644 index fff778b..0000000 --- a/src/components/confirmation.js +++ /dev/null @@ -1,24 +0,0 @@ -import Config from '../config'; -import { isConfirmed, isEmbed, setUrlParams } from '../utils'; -import { addButton, removeButton } from './errorScreen'; - -const confirmationButtonId = 'confirmButton'; -const confirmationButtonText = 'Click to unlock'; - -export function isConfirmationRequired() { - return !isConfirmed && isEmbed && Config.ENABLE_UNLOCK_CONFIRMATION_EMBED; -} - -export function requestConfirmation() { - addButton(confirmationButtonId, confirmationButtonText, null, () => { - removeButton(confirmationButtonId); - confirm(); - }); -} - -function confirm() { - setUrlParams({ - unlock_confirmed: 1, - autoplay: 1, - }); -} diff --git a/src/components/endpoints/index.js b/src/components/endpoints/index.js deleted file mode 100644 index b50e3b1..0000000 --- a/src/components/endpoints/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default as innertube } from './innertube.js'; -export { default as proxy } from './proxy.js'; diff --git a/src/components/endpoints/innertube.js b/src/components/endpoints/innertube.js deleted file mode 100644 index 2ad0a6b..0000000 --- a/src/components/endpoints/innertube.js +++ /dev/null @@ -1,32 +0,0 @@ -import Config from '../../config'; -import { getYtcfgValue, isUserLoggedIn } from '../../utils'; -import { nativeJSONParse } from '../interceptors/natives'; -import * as storage from '../storage'; - -function getPlayer(payload, useAuth) { - return sendInnertubeRequest('v1/player', payload, useAuth); -} - -function getNext(payload, useAuth) { - return sendInnertubeRequest('v1/next', payload, useAuth); -} - -function sendInnertubeRequest(endpoint, payload, useAuth) { - const xmlhttp = new XMLHttpRequest(); - xmlhttp.open('POST', `/youtubei/${endpoint}?key=${getYtcfgValue('INNERTUBE_API_KEY')}&prettyPrint=false`, false); - - if (useAuth && isUserLoggedIn()) { - xmlhttp.withCredentials = true; - Config.GOOGLE_AUTH_HEADER_NAMES.forEach((headerName) => { - xmlhttp.setRequestHeader(headerName, storage.get(headerName)); - }); - } - - xmlhttp.send(JSON.stringify(payload)); - return nativeJSONParse(xmlhttp.responseText); -} - -export default { - getPlayer, - getNext, -}; diff --git a/src/components/endpoints/proxy.js b/src/components/endpoints/proxy.js deleted file mode 100644 index f613461..0000000 --- a/src/components/endpoints/proxy.js +++ /dev/null @@ -1,61 +0,0 @@ -import Config from '../../config'; -import { isEmbed, isMusic } from '../../utils'; -import * as logger from '../../utils/logger'; -import { nativeJSONParse } from '../interceptors/natives'; - -let nextResponseCache = {}; - -function getGoogleVideoUrl(originalUrl) { - return Config.VIDEO_PROXY_SERVER_HOST + '/direct/' + btoa(originalUrl.toString()); -} - -function getPlayer(payload) { - // Also request the /next response if a later /next request is likely. - if (!nextResponseCache[payload.videoId] && !isMusic && !isEmbed) { - payload.includeNext = 1; - } - - return sendRequest('getPlayer', payload); -} - -function getNext(payload) { - // Next response already cached? => Return cached content - if (nextResponseCache[payload.videoId]) { - return nextResponseCache[payload.videoId]; - } - - return sendRequest('getNext', payload); -} - -function sendRequest(endpoint, payload) { - const queryParams = new URLSearchParams(payload); - const proxyUrl = `${Config.ACCOUNT_PROXY_SERVER_HOST}/${endpoint}?${queryParams}&client=js`; - - try { - const xmlhttp = new XMLHttpRequest(); - xmlhttp.open('GET', proxyUrl, false); - xmlhttp.send(null); - - const proxyResponse = nativeJSONParse(xmlhttp.responseText); - - // Mark request as 'proxied' - proxyResponse.proxied = true; - - // Put included /next response in the cache - if (proxyResponse.nextResponse) { - nextResponseCache[payload.videoId] = proxyResponse.nextResponse; - delete proxyResponse.nextResponse; - } - - return proxyResponse; - } catch (err) { - logger.error(err, 'Proxy API Error'); - return { errorMessage: 'Proxy Connection failed' }; - } -} - -export default { - getPlayer, - getNext, - getGoogleVideoUrl, -}; diff --git a/src/components/errorScreen/index.js b/src/components/errorScreen/index.js deleted file mode 100644 index d2ef2e5..0000000 --- a/src/components/errorScreen/index.js +++ /dev/null @@ -1,32 +0,0 @@ -import { createElement, waitForElement } from '../../utils'; -import buttonTemplate from './templates/button.html'; - -let buttons = {}; - -export async function addButton(id, text, backgroundColor, onClick) { - const errorScreenElement = await waitForElement('.ytp-error', 2000); - const buttonElement = createElement('div', { class: 'button-container', innerHTML: buttonTemplate }); - buttonElement.getElementsByClassName('button-text')[0].innerText = text; - - if (backgroundColor) { - buttonElement.querySelector(':scope > div').style['background-color'] = backgroundColor; - } - - if (typeof onClick === 'function') { - buttonElement.addEventListener('click', onClick); - } - - // Button already attached? - if (buttons[id] && buttons[id].isConnected) { - return; - } - - buttons[id] = buttonElement; - errorScreenElement.append(buttonElement); -} - -export function removeButton(id) { - if (buttons[id] && buttons[id].isConnected) { - buttons[id].remove(); - } -} diff --git a/src/components/errorScreen/templates/button.html b/src/components/errorScreen/templates/button.html deleted file mode 100644 index 2ab9241..0000000 --- a/src/components/errorScreen/templates/button.html +++ /dev/null @@ -1,3 +0,0 @@ -
-
-
\ No newline at end of file diff --git a/src/components/innertube.js b/src/components/innertube.js new file mode 100644 index 0000000..59325ae --- /dev/null +++ b/src/components/innertube.js @@ -0,0 +1,27 @@ +import { getYtcfgValue, GOOGLE_AUTH_HEADER_NAMES, isUserLoggedIn, nativeJSONParse } from '../utils.js'; + +const getPlayer = sendInnertubeRequest.bind(null, 'v1/player'); +const getNext = sendInnertubeRequest.bind(null, 'v1/next'); + +function sendInnertubeRequest(endpoint, payload, useAuth) { + const xmlhttp = new XMLHttpRequest(); + xmlhttp.open('POST', `/youtubei/${endpoint}?key=${getYtcfgValue('INNERTUBE_API_KEY')}&prettyPrint=false`, false); + + if (useAuth && isUserLoggedIn()) { + xmlhttp.withCredentials = true; + GOOGLE_AUTH_HEADER_NAMES.forEach((headerName) => { + const value = localStorage.getItem('SYARB_' + headerName); + if (value) { + xmlhttp.setRequestHeader(headerName, JSON.parse(value)); + } + }); + } + + xmlhttp.send(JSON.stringify(payload)); + return nativeJSONParse(xmlhttp.responseText); +} + +export default { + getPlayer, + getNext, +}; diff --git a/src/components/inspectors/googlevideo.js b/src/components/inspectors/googlevideo.js deleted file mode 100644 index 5cc70b7..0000000 --- a/src/components/inspectors/googlevideo.js +++ /dev/null @@ -1,11 +0,0 @@ -export function isGoogleVideoUrl(url) { - return url.host.includes('.googlevideo.com'); -} - -export function isGoogleVideoUnlockRequired(googleVideoUrl, lastProxiedGoogleVideoId) { - const urlParams = new URLSearchParams(googleVideoUrl.search); - const hasGcrFlag = urlParams.get('gcr'); - const wasUnlockedByAccountProxy = urlParams.get('id') === lastProxiedGoogleVideoId; - - return hasGcrFlag && wasUnlockedByAccountProxy; -} diff --git a/src/components/inspectors/index.js b/src/components/inspectors/index.js deleted file mode 100644 index cc46f15..0000000 --- a/src/components/inspectors/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export * as googlevideo from './googlevideo'; -export * as next from './next'; -export * as player from './player'; -export * as search from './search'; diff --git a/src/components/inspectors/next.js b/src/components/inspectors/next.js deleted file mode 100644 index 12c44b2..0000000 --- a/src/components/inspectors/next.js +++ /dev/null @@ -1,19 +0,0 @@ -import { isDesktop } from '../../utils'; - -export function isWatchNextObject(parsedData) { - if (!parsedData?.contents || !parsedData?.currentVideoEndpoint?.watchEndpoint?.videoId) return false; - return !!parsedData.contents.twoColumnWatchNextResults || !!parsedData.contents.singleColumnWatchNextResults; -} - -export function isWatchNextSidebarEmpty(parsedData) { - if (isDesktop) { - // WEB response layout - const result = parsedData.contents?.twoColumnWatchNextResults?.secondaryResults?.secondaryResults?.results; - return !result; - } - - // MWEB response layout - const content = parsedData.contents?.singleColumnWatchNextResults?.results?.results?.contents; - const result = content?.find((e) => e.itemSectionRenderer?.targetId === 'watch-next-feed')?.itemSectionRenderer; - return typeof result !== 'object'; -} diff --git a/src/components/inspectors/player.js b/src/components/inspectors/player.js deleted file mode 100644 index fae7da7..0000000 --- a/src/components/inspectors/player.js +++ /dev/null @@ -1,27 +0,0 @@ -import Config from '../../config'; -import { isEmbed } from '../../utils'; - -export function isPlayerObject(parsedData) { - return parsedData?.videoDetails && parsedData?.playabilityStatus; -} - -export function isEmbeddedPlayerObject(parsedData) { - return typeof parsedData?.previewPlayabilityStatus === 'object'; -} - -export function isAgeRestricted(playabilityStatus) { - if (!playabilityStatus?.status) return false; - if (playabilityStatus.desktopLegacyAgeGateReason) return true; - if (Config.UNLOCKABLE_PLAYABILITY_STATUSES.includes(playabilityStatus.status)) return true; - - // Fix to detect age restrictions on embed player - // see https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/issues/85#issuecomment-946853553 - return ( - isEmbed - && playabilityStatus.errorScreen?.playerErrorMessageRenderer?.reason?.runs?.find((x) => x.navigationEndpoint)?.navigationEndpoint?.urlEndpoint?.url?.includes('/2802167') - ); -} - -export function isUnplayable(playabilityStatus) { - return playabilityStatus?.status === 'UNPLAYABLE'; -} diff --git a/src/components/inspectors/search.js b/src/components/inspectors/search.js deleted file mode 100644 index 21da231..0000000 --- a/src/components/inspectors/search.js +++ /dev/null @@ -1,7 +0,0 @@ -export function isSearchResult(parsedData) { - return ( - typeof parsedData?.contents?.twoColumnSearchResultsRenderer === 'object' // Desktop initial results - || parsedData?.contents?.sectionListRenderer?.targetId === 'search-feed' // Mobile initial results - || parsedData?.onResponseReceivedCommands?.find((x) => x.appendContinuationItemsAction)?.appendContinuationItemsAction?.targetId === 'search-feed' // Desktop & Mobile scroll continuation - ); -} diff --git a/src/components/interceptors/generic.js b/src/components/interceptors/generic.js deleted file mode 100644 index ca92c06..0000000 --- a/src/components/interceptors/generic.js +++ /dev/null @@ -1,14 +0,0 @@ -export default function attach(obj, prop, onCall) { - if (!obj || typeof obj[prop] !== 'function') { - return; - } - - let original = obj[prop]; - - obj[prop] = function() { - try { - onCall(arguments); - } catch {} - original.apply(this, arguments); - }; -} diff --git a/src/components/interceptors/index.js b/src/components/interceptors/index.js deleted file mode 100644 index 3da07b4..0000000 --- a/src/components/interceptors/index.js +++ /dev/null @@ -1,5 +0,0 @@ -export { default as attachGenericInterceptor } from './generic'; -export { default as attachInitialDataInterceptor } from './initialData'; -export { default as attachJsonInterceptor } from './json'; -export { default as attachRequestInterceptor } from './request'; -export { default as attachXhrOpenInterceptor } from './xhrOpen'; diff --git a/src/components/interceptors/initialData.js b/src/components/interceptors/initialData.js deleted file mode 100644 index 6e030c9..0000000 --- a/src/components/interceptors/initialData.js +++ /dev/null @@ -1,61 +0,0 @@ -import { createDeepCopy } from '../../utils'; -import { isObject } from '../../utils'; -import * as logger from '../../utils/logger'; - -/** - * And here we deal with YouTube's crappy initial data (present in page source) and the problems that occur when intercepting that data. - * YouTube has some protections in place that make it difficult to intercept and modify the global ytInitialPlayerResponse variable. - * The easiest way would be to set a descriptor on that variable to change the value directly on declaration. - * But some adblockers define their own descriptors on the ytInitialPlayerResponse variable, which makes it hard to register another descriptor on it. - * As a workaround only the relevant playerResponse property of the ytInitialPlayerResponse variable will be intercepted. - * This is achieved by defining a descriptor on the object prototype for that property, which affects any object with a `playerResponse` property. - */ -export default function attach(onInitialData) { - interceptObjectProperty('playerResponse', (obj, playerResponse) => { - logger.info(`playerResponse property set, contains sidebar: ${!!obj.response}`); - - // The same object also contains the sidebar data and video description - if (isObject(obj.response)) onInitialData(obj.response); - - // If the script is executed too late and the bootstrap data has already been processed, - // a reload of the player can be forced by creating a deep copy of the object. - // This is especially relevant if the userscript manager does not handle the `@run-at document-start` correctly. - playerResponse.unlocked = false; - onInitialData(playerResponse); - return playerResponse.unlocked ? createDeepCopy(playerResponse) : playerResponse; - }); - - // The global `ytInitialData` variable can be modified on the fly. - // It contains search results, sidebar data and meta information - // Not really important but fixes https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/issues/127 - window.addEventListener('DOMContentLoaded', () => { - if (isObject(window.ytInitialData)) { - onInitialData(window.ytInitialData); - } - }); -} - -function interceptObjectProperty(prop, onSet) { - // Allow other userscripts to decorate this descriptor, if they do something similar - const dataKey = '__SYARB_' + prop; - const { get: getter, set: setter } = Object.getOwnPropertyDescriptor(Object.prototype, prop) ?? { - set(value) { - this[dataKey] = value; - }, - get() { - return this[dataKey]; - }, - }; - - // Intercept the given property on any object - // The assigned attribute value and the context (enclosing object) are passed to the onSet function. - Object.defineProperty(Object.prototype, prop, { - set(value) { - setter.call(this, isObject(value) ? onSet(this, value) : value); - }, - get() { - return getter.call(this); - }, - configurable: true, - }); -} diff --git a/src/components/interceptors/json.js b/src/components/interceptors/json.js deleted file mode 100644 index f077394..0000000 --- a/src/components/interceptors/json.js +++ /dev/null @@ -1,10 +0,0 @@ -import { isObject } from '../../utils'; -import { nativeJSONParse } from './natives'; - -// Intercept, inspect and modify JSON-based communication to unlock player responses by hijacking the JSON.parse function -export default function attach(onJsonDataReceived) { - window.JSON.parse = function() { - const data = nativeJSONParse.apply(this, arguments); - return isObject(data) ? onJsonDataReceived(data) : data; - }; -} diff --git a/src/components/interceptors/natives.js b/src/components/interceptors/natives.js deleted file mode 100644 index 88cb33c..0000000 --- a/src/components/interceptors/natives.js +++ /dev/null @@ -1,2 +0,0 @@ -export const nativeJSONParse = window.JSON.parse; -export const nativeXMLHttpRequestOpen = window.XMLHttpRequest.prototype.open; diff --git a/src/components/interceptors/request.js b/src/components/interceptors/request.js deleted file mode 100644 index d2b8815..0000000 --- a/src/components/interceptors/request.js +++ /dev/null @@ -1,32 +0,0 @@ -import * as logger from '../../utils/logger'; - -export default function attach(onRequestCreate) { - if (typeof window.Request !== 'function') { - return; - } - - window.Request = new Proxy(window.Request, { - construct(target, args) { - let [url, options] = args; - try { - if (typeof url === 'string') { - if (url.indexOf('/') === 0) { - url = window.location.origin + url; - } - - if (url.indexOf('https://') !== -1) { - const modifiedUrl = onRequestCreate(url, options); - - if (modifiedUrl) { - args[0] = modifiedUrl; - } - } - } - } catch (err) { - logger.error(err, `Failed to intercept Request()`); - } - - return Reflect.construct(target, args); - }, - }); -} diff --git a/src/components/interceptors/xhrOpen.js b/src/components/interceptors/xhrOpen.js deleted file mode 100644 index cf54615..0000000 --- a/src/components/interceptors/xhrOpen.js +++ /dev/null @@ -1,27 +0,0 @@ -import * as logger from '../../utils/logger'; -import { nativeXMLHttpRequestOpen } from './natives'; - -export default function attach(onXhrOpenCalled) { - XMLHttpRequest.prototype.open = function(...args) { - let [method, url] = args; - try { - if (typeof url === 'string') { - if (url.indexOf('/') === 0) { - url = window.location.origin + url; - } - - if (url.indexOf('https://') !== -1) { - const modifiedUrl = onXhrOpenCalled(method, url, this); - - if (modifiedUrl) { - args[1] = modifiedUrl; - } - } - } - } catch (err) { - logger.error(err, `Failed to intercept XMLHttpRequest.open()`); - } - - nativeXMLHttpRequestOpen.apply(this, args); - }; -} diff --git a/src/components/requestPreprocessor.js b/src/components/requestPreprocessor.js deleted file mode 100644 index 41ade8a..0000000 --- a/src/components/requestPreprocessor.js +++ /dev/null @@ -1,107 +0,0 @@ -import Config from '../config'; -import { isObject } from '../utils'; -import { proxy } from './endpoints'; -import * as inspectors from './inspectors'; -import * as interceptors from './interceptors'; -import * as storage from './storage'; -import * as unlocker from './unlocker'; - -/** - * Handles XMLHttpRequests and - * - Rewrite Googlevideo URLs to Proxy URLs (if necessary) - * - Store auth headers for the authentication of further unlock requests. - * - Add "content check ok" flags to request bodys - */ -export function handleXhrOpen(method, url, xhr) { - const url_obj = new URL(url); - let proxyUrl = unlockGoogleVideo(url_obj); - if (proxyUrl) { - // Exclude credentials from XMLHttpRequest - Object.defineProperty(xhr, 'withCredentials', { - set: () => {}, - get: () => false, - }); - return proxyUrl.toString(); - } - - if (url_obj.pathname.indexOf('/youtubei/') === 0) { - // Store auth headers in storage for further usage. - interceptors.attachGenericInterceptor(xhr, 'setRequestHeader', ([headerName, headerValue]) => { - if (Config.GOOGLE_AUTH_HEADER_NAMES.includes(headerName)) { - storage.set(headerName, headerValue); - } - }); - } - - if (Config.SKIP_CONTENT_WARNINGS && method === 'POST' && ['/youtubei/v1/player', '/youtubei/v1/next'].includes(url_obj.pathname)) { - // Add content check flags to player and next request (this will skip content warnings) - interceptors.attachGenericInterceptor(xhr, 'send', (args) => { - if (typeof args[0] === 'string') { - args[0] = setContentCheckOk(args[0]); - } - }); - } -} - -/** - * Handles Fetch requests and - * - Rewrite Googlevideo URLs to Proxy URLs (if necessary) - * - Store auth headers for the authentication of further unlock requests. - * - Add "content check ok" flags to request bodys - */ -export function handleFetchRequest(url, requestOptions) { - const url_obj = new URL(url); - const newGoogleVideoUrl = unlockGoogleVideo(url_obj); - if (newGoogleVideoUrl) { - // Exclude credentials from Fetch Request - if (requestOptions.credentials) { - requestOptions.credentials = 'omit'; - } - return newGoogleVideoUrl.toString(); - } - - if (url_obj.pathname.indexOf('/youtubei/') === 0 && isObject(requestOptions.headers)) { - // Store auth headers in authStorage for further usage. - for (let headerName in requestOptions.headers) { - if (Config.GOOGLE_AUTH_HEADER_NAMES.includes(headerName)) { - storage.set(headerName, requestOptions.headers[headerName]); - } - } - } - - if (Config.SKIP_CONTENT_WARNINGS && ['/youtubei/v1/player', '/youtubei/v1/next'].includes(url_obj.pathname)) { - // Add content check flags to player and next request (this will skip content warnings) - requestOptions.body = setContentCheckOk(requestOptions.body); - } -} - -/** - * If the account proxy was used to retrieve the video info, the following applies: - * some video files (mostly music videos) can only be accessed from IPs in the same country as the innertube api request (/youtubei/v1/player) was made. - * to get around this, the googlevideo URL will be replaced with a web-proxy URL in the same country (US). - * this is only required if the "gcr=[countrycode]" flag is set in the googlevideo-url... - * @returns The rewitten url (if a proxy is required) - */ -function unlockGoogleVideo(url) { - if (Config.VIDEO_PROXY_SERVER_HOST && inspectors.googlevideo.isGoogleVideoUrl(url)) { - if (inspectors.googlevideo.isGoogleVideoUnlockRequired(url, unlocker.getLastProxiedGoogleVideoId())) { - return proxy.getGoogleVideoUrl(url); - } - } -} - -/** - * Adds `contentCheckOk` and `racyCheckOk` to the given json data (if the data contains a video id) - * @returns {string} The modified json - */ -function setContentCheckOk(bodyJson) { - try { - let parsedBody = JSON.parse(bodyJson); - if (parsedBody.videoId) { - parsedBody.contentCheckOk = true; - parsedBody.racyCheckOk = true; - return JSON.stringify(parsedBody); - } - } catch {} - return bodyJson; -} diff --git a/src/components/storage.js b/src/components/storage.js deleted file mode 100644 index b4390ca..0000000 --- a/src/components/storage.js +++ /dev/null @@ -1,13 +0,0 @@ -const localStoragePrefix = 'SYARB_'; - -export function set(key, value) { - localStorage.setItem(localStoragePrefix + key, JSON.stringify(value)); -} - -export function get(key) { - try { - return JSON.parse(localStorage.getItem(localStoragePrefix + key)); - } catch { - return null; - } -} diff --git a/src/components/strategies/index.js b/src/components/strategies/index.js deleted file mode 100644 index 96b96b4..0000000 --- a/src/components/strategies/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default as getNextUnlockStrategies } from './next.js'; -export { default as getPlayerUnlockStrategies } from './player.js'; diff --git a/src/components/strategies/next.js b/src/components/strategies/next.js deleted file mode 100644 index 6343d9d..0000000 --- a/src/components/strategies/next.js +++ /dev/null @@ -1,54 +0,0 @@ -import { getYtcfgValue, isConfirmed, isEmbed } from '../../utils'; -import { innertube, proxy } from '../endpoints'; - -export default function getUnlockStrategies(videoId, lastPlayerUnlockReason) { - const clientName = getYtcfgValue('INNERTUBE_CLIENT_NAME') || 'WEB'; - const clientVersion = getYtcfgValue('INNERTUBE_CLIENT_VERSION') || '2.20220203.04.00'; - const hl = getYtcfgValue('HL'); - const userInterfaceTheme = getYtcfgValue('INNERTUBE_CONTEXT').client.userInterfaceTheme - ?? (document.documentElement.hasAttribute('dark') ? 'USER_INTERFACE_THEME_DARK' : 'USER_INTERFACE_THEME_LIGHT'); - - return [ - /** - * Retrieve the sidebar and video description by just adding `racyCheckOk` and `contentCheckOk` params - * This strategy can be used to bypass content warnings - */ - { - name: 'Content Warning Bypass', - skip: !lastPlayerUnlockReason || !lastPlayerUnlockReason.includes('CHECK_REQUIRED'), - optionalAuth: true, - payload: { - context: { - client: { - clientName, - clientVersion, - hl, - userInterfaceTheme, - }, - }, - videoId, - racyCheckOk: true, - contentCheckOk: true, - }, - endpoint: innertube, - }, - /** - * Retrieve the sidebar and video description from an account proxy server. - * Session cookies of an age-verified Google account are stored on server side. - * See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/tree/main/account-proxy - */ - { - name: 'Account Proxy', - payload: { - videoId, - clientName, - clientVersion, - hl, - userInterfaceTheme, - isEmbed: +isEmbed, - isConfirmed: +isConfirmed, - }, - endpoint: proxy, - }, - ]; -} diff --git a/src/components/strategies/player.js b/src/components/strategies/player.js deleted file mode 100644 index 42ae597..0000000 --- a/src/components/strategies/player.js +++ /dev/null @@ -1,121 +0,0 @@ -import { getCurrentVideoStartTime, getSignatureTimestamp, getYtcfgValue, isConfirmed, isEmbed } from '../../utils'; -import { innertube, proxy } from '../endpoints'; - -export default function getUnlockStrategies(videoId, reason) { - const clientName = getYtcfgValue('INNERTUBE_CLIENT_NAME') || 'WEB'; - const clientVersion = getYtcfgValue('INNERTUBE_CLIENT_VERSION') || '2.20220203.04.00'; - const signatureTimestamp = getSignatureTimestamp(); - const startTimeSecs = getCurrentVideoStartTime(videoId); - const hl = getYtcfgValue('HL'); - - return [ - /** - * Retrieve the video info by just adding `racyCheckOk` and `contentCheckOk` params - * This strategy can be used to bypass content warnings - */ - { - name: 'Content Warning Bypass', - skip: !reason || !reason.includes('CHECK_REQUIRED'), - optionalAuth: true, - payload: { - context: { - client: { - clientName: clientName, - clientVersion: clientVersion, - hl, - }, - }, - playbackContext: { - contentPlaybackContext: { - signatureTimestamp, - }, - }, - videoId, - startTimeSecs, - racyCheckOk: true, - contentCheckOk: true, - }, - endpoint: innertube, - }, - /** - * Retrieve the video info by using the TVHTML5 Embedded client - * This client has no age restrictions in place (2022-03-28) - * See https://github.com/zerodytrash/YouTube-Internal-Clients - */ - { - name: 'TV Embedded Player', - requiresAuth: false, - payload: { - context: { - client: { - clientName: 'TVHTML5_SIMPLY_EMBEDDED_PLAYER', - clientVersion: '2.0', - clientScreen: 'WATCH', - hl, - }, - thirdParty: { - embedUrl: 'https://www.youtube.com/', - }, - }, - playbackContext: { - contentPlaybackContext: { - signatureTimestamp, - }, - }, - videoId, - startTimeSecs, - racyCheckOk: true, - contentCheckOk: true, - }, - endpoint: innertube, - }, - /** - * Retrieve the video info by using the WEB_CREATOR client in combination with user authentication - * Requires that the user is logged in. Can bypass the tightened age verification in the EU. - * See https://github.com/yt-dlp/yt-dlp/pull/600 - */ - { - name: 'Creator + Auth', - requiresAuth: true, - payload: { - context: { - client: { - clientName: 'WEB_CREATOR', - clientVersion: '1.20210909.07.00', - hl, - }, - }, - playbackContext: { - contentPlaybackContext: { - signatureTimestamp, - }, - }, - videoId, - startTimeSecs, - racyCheckOk: true, - contentCheckOk: true, - }, - endpoint: innertube, - }, - /** - * Retrieve the video info from an account proxy server. - * Session cookies of an age-verified Google account are stored on server side. - * See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/tree/main/account-proxy - */ - { - name: 'Account Proxy', - payload: { - videoId, - reason, - clientName, - clientVersion, - signatureTimestamp, - startTimeSecs, - hl, - isEmbed: +isEmbed, - isConfirmed: +isConfirmed, - }, - endpoint: proxy, - }, - ]; -} diff --git a/src/components/thumbnailFix.js b/src/components/thumbnailFix.js index 3463143..c6a44ec 100644 --- a/src/components/thumbnailFix.js +++ b/src/components/thumbnailFix.js @@ -1,9 +1,22 @@ -import Config from '../config'; -import { findNestedObjectsByAttributeNames } from '../utils'; -import * as logger from '../utils/logger'; +import * as logger from '../logger.js'; + +/** + * The SQP parameter length is different for blurred thumbnails. + * They contain much less information, than normal thumbnails. + * The thumbnail SQPs tend to have a long and a short version. + */ +const BLURRED_THUMBNAIL_SQP_LENGTHS = [ + 32, // Mobile (SHORT) + 48, // Desktop Playlist (SHORT) + 56, // Desktop (SHORT) + 68, // Mobile (LONG) + 72, // Mobile Shorts + 84, // Desktop Playlist (LONG) + 88, // Desktop (LONG) +]; export function processThumbnails(responseObject) { - const thumbnails = findNestedObjectsByAttributeNames(responseObject, ['url', 'height']); + const thumbnails = findObjectsByInnerKeys(responseObject, ['url', 'height']); let blurredThumbnailCount = 0; @@ -25,7 +38,28 @@ function isThumbnailBlurred(thumbnail) { } const SQPLength = new URL(thumbnail.url).searchParams.get('sqp').length; - const isBlurred = Config.BLURRED_THUMBNAIL_SQP_LENGTHS.includes(SQPLength); + const isBlurred = BLURRED_THUMBNAIL_SQP_LENGTHS.includes(SQPLength); return isBlurred; } + +function findObjectsByInnerKeys(object, keys) { + const result = []; + const stack = [object]; + + for (const obj of stack) { + // Check current object in the stack for keys + if (keys.every((key) => typeof obj[key] !== 'undefined')) { + result.push(obj); + } + + // Put nested objects in the stack + for (const key in obj) { + if (obj[key] && typeof object[key] === 'object') { + stack.push(obj[key]); + } + } + } + + return result; +} diff --git a/src/components/toast/templates/desktop.html b/src/components/toast/templates/desktop.html deleted file mode 100644 index ebb067f..0000000 --- a/src/components/toast/templates/desktop.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/components/toast/templates/mobile.html b/src/components/toast/templates/mobile.html deleted file mode 100644 index a6ccfa1..0000000 --- a/src/components/toast/templates/mobile.html +++ /dev/null @@ -1,5 +0,0 @@ - - -
-
-
diff --git a/src/components/ui/errorScreen.js b/src/components/ui/errorScreen.js new file mode 100644 index 0000000..e758ee1 --- /dev/null +++ b/src/components/ui/errorScreen.js @@ -0,0 +1,48 @@ +import * as config from '../../config.js'; +import { createElement, isConfirmed, isEmbed, waitForElement } from '../../utils.js'; + +const confirmationButtonId = 'confirmButton'; +const confirmationButtonText = 'Click to unlock'; + +const buttons = {}; + +const buttonTemplate = ` +
+
+
+`; + +export function isConfirmationRequired() { + return !isConfirmed && isEmbed && config.ENABLE_UNLOCK_CONFIRMATION_EMBED; +} + +export async function requestConfirmation() { + const errorScreenElement = await waitForElement('.ytp-error', 2000); + const buttonElement = createElement('div', { class: 'button-container', innerHTML: buttonTemplate }); + buttonElement.getElementsByClassName('button-text')[0].innerText = confirmationButtonText; + buttonElement.addEventListener('click', () => { + removeButton(confirmationButtonId); + confirm(); + }); + + // Button already attached? + if (buttons[confirmationButtonId] && buttons[confirmationButtonId].isConnected) { + return; + } + + buttons[confirmationButtonId] = buttonElement; + errorScreenElement.append(buttonElement); +} + +function confirm() { + const urlParams = new URLSearchParams(window.location.search); + urlParams.set('unlock_confirmed', '1'); + urlParams.set('autoplay', '1'); + location.search = urlParams.toString(); +} + +function removeButton(id) { + if (buttons[id] && buttons[id].isConnected) { + buttons[id].remove(); + } +} diff --git a/src/components/toast/index.js b/src/components/ui/toast.js similarity index 76% rename from src/components/toast/index.js rename to src/components/ui/toast.js index 90ebc9e..3b27564 100644 --- a/src/components/toast/index.js +++ b/src/components/ui/toast.js @@ -1,8 +1,15 @@ -import Config from '../../config'; -import { createElement, isDesktop, isEmbed, isMusic, pageLoaded } from '../../utils'; +import * as config from '../../config.js'; +import { createElement, isDesktop, isEmbed, isMusic, pageLoaded } from '../../utils.js'; -import tDesktop from './templates/desktop.html'; -import tMobile from './templates/mobile.html'; +const tDesktop = ``; + +const tMobile = ` + + +
+
+
+`; const template = isDesktop ? tDesktop : tMobile; @@ -26,7 +33,7 @@ if (!isDesktop) { } async function show(message, duration = 5) { - if (!Config.ENABLE_UNLOCK_NOTIFICATION) return; + if (!config.ENABLE_UNLOCK_NOTIFICATION) return; if (isEmbed) return; await pageLoaded(); diff --git a/src/components/unlocker/index.js b/src/components/unlocker/index.js deleted file mode 100644 index 4836e1b..0000000 --- a/src/components/unlocker/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export { default as unlockNextResponse } from './next.js'; -export { default as unlockPlayerResponse } from './player.js'; - -export { getLastProxiedGoogleVideoId, lastPlayerUnlockReason, lastPlayerUnlockVideoId } from './player.js'; diff --git a/src/components/unlocker/next.js b/src/components/unlocker/next.js index 932ba67..423271c 100644 --- a/src/components/unlocker/next.js +++ b/src/components/unlocker/next.js @@ -1,13 +1,14 @@ -import { createDeepCopy, isDesktop } from '../../utils'; -import * as logger from '../../utils/logger'; -import { next as nextInspector } from '../inspectors'; -import { getNextUnlockStrategies } from '../strategies'; -import { lastPlayerUnlockReason, lastPlayerUnlockVideoId } from './player'; +import * as logger from '../../logger.js'; +import { createDeepCopy, getYtcfgValue, isDesktop } from '../../utils.js'; +import innertube from '../innertube.js'; +import { lastPlayerUnlockReason, lastPlayerUnlockVideoId } from './player.js'; let cachedNextResponse = {}; -export default function unlockResponse(originalNextResponse) { - const videoId = originalNextResponse.currentVideoEndpoint.watchEndpoint.videoId; +export function unlockResponse(ytData) { + const response = ytData.response ?? ytData; + + const videoId = response.currentVideoEndpoint.watchEndpoint.videoId; if (!videoId) { throw new Error(`Missing videoId in nextResponse`); @@ -21,41 +22,39 @@ export default function unlockResponse(originalNextResponse) { const unlockedNextResponse = getUnlockedNextResponse(videoId); // check if the sidebar of the unlocked response is still empty - if (nextInspector.isWatchNextSidebarEmpty(unlockedNextResponse)) { + if (isWatchNextSidebarEmpty(unlockedNextResponse)) { throw new Error(`Sidebar Unlock Failed`); } // Transfer some parts of the unlocked response to the original response - mergeNextResponse(originalNextResponse, unlockedNextResponse); + mergeNextResponse(response, unlockedNextResponse); } function getUnlockedNextResponse(videoId) { // Check if response is cached if (cachedNextResponse.videoId === videoId) return createDeepCopy(cachedNextResponse); - const unlockStrategies = getNextUnlockStrategies(videoId, lastPlayerUnlockReason); + const unlockStrategies = getUnlockStrategies(videoId, lastPlayerUnlockReason); let unlockedNextResponse = {}; - // Try every strategy until one of them works - unlockStrategies.every((strategy, index) => { - if (strategy.skip) return true; + for (const strategy of unlockStrategies) { + if (strategy.skip) continue; - logger.info(`Trying Next Unlock Method #${index + 1} (${strategy.name})`); + logger.info(`Trying Next Unlock Method ${strategy.name}`); try { unlockedNextResponse = strategy.endpoint.getNext(strategy.payload, strategy.optionalAuth); } catch (err) { - logger.error(err, `Next Unlock Method ${index + 1} failed with exception`); + logger.error(`Next unlock Method "${strategy.name}" failed with exception:`, err); } - return nextInspector.isWatchNextSidebarEmpty(unlockedNextResponse); - }); - - // Cache response to prevent a flood of requests in case youtube processes a blocked response mutiple times. - cachedNextResponse = { videoId, ...createDeepCopy(unlockedNextResponse) }; - - return unlockedNextResponse; + if (!isWatchNextSidebarEmpty(unlockedNextResponse)) { + // Cache response to prevent a flood of requests in case youtube processes a blocked response mutiple times. + cachedNextResponse = { videoId, ...createDeepCopy(unlockedNextResponse) }; + return unlockedNextResponse; + } + } } function mergeNextResponse(originalNextResponse, unlockedNextResponse) { @@ -100,3 +99,58 @@ function mergeNextResponse(originalNextResponse, unlockedNextResponse) { originalStructuredDescriptionContentRenderer.expandableVideoDescriptionBodyRenderer = unlockedStructuredDescriptionContentRenderer.expandableVideoDescriptionBodyRenderer; } } + +function getUnlockStrategies(videoId, lastPlayerUnlockReason) { + const clientName = getYtcfgValue('INNERTUBE_CLIENT_NAME') || 'WEB'; + const clientVersion = getYtcfgValue('INNERTUBE_CLIENT_VERSION') || '2.20220203.04.00'; + const hl = getYtcfgValue('HL'); + const userInterfaceTheme = getYtcfgValue('INNERTUBE_CONTEXT').client.userInterfaceTheme + ?? (document.documentElement.hasAttribute('dark') ? 'USER_INTERFACE_THEME_DARK' : 'USER_INTERFACE_THEME_LIGHT'); + + return [ + /** + * Retrieve the sidebar and video description by just adding `racyCheckOk` and `contentCheckOk` params + * This strategy can be used to bypass content warnings + */ + { + name: 'Content Warning Bypass', + skip: !lastPlayerUnlockReason || !lastPlayerUnlockReason.includes('CHECK_REQUIRED'), + optionalAuth: true, + payload: { + context: { + client: { + clientName, + clientVersion, + hl, + userInterfaceTheme, + }, + }, + videoId, + racyCheckOk: true, + contentCheckOk: true, + }, + endpoint: innertube, + }, + ]; +} + +export function isWatchNextObject(ytData) { + const response = ytData.response ?? ytData; + if (!response?.contents || !response?.currentVideoEndpoint?.watchEndpoint?.videoId) return false; + return !!response.contents.twoColumnWatchNextResults || !!response.contents.singleColumnWatchNextResults; +} + +export function isWatchNextSidebarEmpty(ytData) { + const response = ytData.response ?? ytData; + + if (isDesktop) { + // WEB response layout + const result = response.contents?.twoColumnWatchNextResults?.secondaryResults?.secondaryResults?.results; + return !result; + } + + // MWEB response layout + const content = response.contents?.singleColumnWatchNextResults?.results?.results?.contents; + const result = content?.find((e) => e.itemSectionRenderer?.targetId === 'watch-next-feed')?.itemSectionRenderer; + return typeof result !== 'object'; +} diff --git a/src/components/unlocker/player.js b/src/components/unlocker/player.js index a1cae2a..0efcb46 100644 --- a/src/components/unlocker/player.js +++ b/src/components/unlocker/player.js @@ -1,15 +1,18 @@ -import Config from '../../config'; -import { createDeepCopy, getYtcfgValue, isUserLoggedIn } from '../../utils'; -import * as logger from '../../utils/logger'; -import { isConfirmationRequired, requestConfirmation } from '../confirmation'; -import { getPlayerUnlockStrategies } from '../strategies'; -import Toast from '../toast'; +import * as config from '../../config.js'; +import * as logger from '../../logger.js'; +import { createDeepCopy, getSignatureTimestamp, getYtcfgValue, isEmbed, isUserLoggedIn } from '../../utils.js'; +import innertube from '../innertube.js'; +import { isConfirmationRequired, requestConfirmation } from '../ui/errorScreen.js'; +import Toast from '../ui/toast.js'; const messagesMap = { success: 'Age-restricted video successfully unlocked!', fail: 'Unable to unlock this video 🙁 - More information in the developer console', }; +const UNLOCKABLE_PLAYABILITY_STATUSES = ['AGE_VERIFICATION_REQUIRED', 'AGE_CHECK_REQUIRED', 'CONTENT_CHECK_REQUIRED', 'LOGIN_REQUIRED']; +const VALID_PLAYABILITY_STATUSES = ['OK', 'LIVE_STREAM_OFFLINE']; + export let lastPlayerUnlockVideoId = null; export let lastPlayerUnlockReason = null; @@ -20,7 +23,7 @@ export function getLastProxiedGoogleVideoId() { return lastProxiedGoogleVideoUrlParams?.get('id'); } -export default function unlockResponse(playerResponse) { +export function unlockResponse(playerResponse) { // Check if the user has to confirm the unlock first if (isConfirmationRequired()) { logger.info('Unlock confirmation required.'); @@ -31,7 +34,7 @@ export default function unlockResponse(playerResponse) { const videoId = playerResponse.videoDetails?.videoId || getYtcfgValue('PLAYER_VARS').video_id; const reason = playerResponse.playabilityStatus?.status || playerResponse.previewPlayabilityStatus?.status; - if (!Config.SKIP_CONTENT_WARNINGS && reason.includes('CHECK_REQUIRED')) { + if (!config.SKIP_CONTENT_WARNINGS && reason.includes('CHECK_REQUIRED')) { logger.info(`SKIP_CONTENT_WARNINGS disabled and ${reason} status detected.`); return; } @@ -41,26 +44,12 @@ export default function unlockResponse(playerResponse) { const unlockedPlayerResponse = getUnlockedPlayerResponse(videoId, reason); - // account proxy error? - if (unlockedPlayerResponse.errorMessage) { - Toast.show(`${messagesMap.fail} (ProxyError)`, 10); - throw new Error(`Player Unlock Failed, Proxy Error Message: ${unlockedPlayerResponse.errorMessage}`); - } - // check if the unlocked response isn't playable - if (!Config.VALID_PLAYABILITY_STATUSES.includes(unlockedPlayerResponse.playabilityStatus?.status)) { + if (!VALID_PLAYABILITY_STATUSES.includes(unlockedPlayerResponse.playabilityStatus?.status)) { Toast.show(`${messagesMap.fail} (PlayabilityError)`, 10); throw new Error(`Player Unlock Failed, playabilityStatus: ${unlockedPlayerResponse.playabilityStatus?.status}`); } - // if the video info was retrieved via proxy, store the URL params from the url-attribute to detect later if the requested video file (googlevideo.com) need a proxy. - if (unlockedPlayerResponse.proxied && unlockedPlayerResponse.streamingData?.adaptiveFormats) { - const cipherText = unlockedPlayerResponse.streamingData.adaptiveFormats.find((x) => x.signatureCipher)?.signatureCipher; - const videoUrl = cipherText ? new URLSearchParams(cipherText).get('url') : unlockedPlayerResponse.streamingData.adaptiveFormats.find((x) => x.url)?.url; - - lastProxiedGoogleVideoUrlParams = videoUrl ? new URLSearchParams(new window.URL(videoUrl).search) : null; - } - // Overwrite the embedded (preview) playabilityStatus with the unlocked one if (playerResponse.previewPlayabilityStatus) { playerResponse.previewPlayabilityStatus = unlockedPlayerResponse.playabilityStatus; @@ -69,33 +58,34 @@ export default function unlockResponse(playerResponse) { // Transfer all unlocked properties to the original player response Object.assign(playerResponse, unlockedPlayerResponse); - playerResponse.unlocked = true; - Toast.show(messagesMap.success); + + return true; } function getUnlockedPlayerResponse(videoId, reason) { // Check if response is cached if (cachedPlayerResponse.videoId === videoId) return createDeepCopy(cachedPlayerResponse); - const unlockStrategies = getPlayerUnlockStrategies(videoId, reason); + const unlockStrategies = getUnlockStrategies(videoId, reason); let unlockedPlayerResponse = {}; - // Try every strategy until one of them works - unlockStrategies.every((strategy, index) => { + for (const strategy of unlockStrategies) { + if (strategy.skip) continue; + // Skip strategy if authentication is required and the user is not logged in - if (strategy.skip || (strategy.requiresAuth && !isUserLoggedIn())) return true; + if (strategy.requiresAuth && !isUserLoggedIn()) continue; - logger.info(`Trying Player Unlock Method #${index + 1} (${strategy.name})`); + logger.info(`Trying Player Unlock Method ${strategy.name}`); try { unlockedPlayerResponse = strategy.endpoint.getPlayer(strategy.payload, strategy.requiresAuth || strategy.optionalAuth); } catch (err) { - logger.error(err, `Player Unlock Method ${index + 1} failed with exception`); + logger.error(`Player unlock Method "${strategy.name}" failed with exception:`, err); } - const isStatusValid = Config.VALID_PLAYABILITY_STATUSES.includes(unlockedPlayerResponse?.playabilityStatus?.status); + const isStatusValid = VALID_PLAYABILITY_STATUSES.includes(unlockedPlayerResponse?.playabilityStatus?.status); if (isStatusValid) { /** @@ -115,25 +105,146 @@ function getUnlockedPlayerResponse(videoId, reason) { }; } - /** - * Workaround: Account proxy response currently does not include `playerConfig` - * - * Stays here until we rewrite the account proxy to only include the necessary and bare minimum response - */ - if (strategy.payload.startTimeSecs && strategy.name === 'Account Proxy') { - unlockedPlayerResponse.playerConfig = { - playbackStartConfig: { - startSeconds: strategy.payload.startTimeSecs, - }, - }; - } + // Cache response to prevent a flood of requests in case youtube processes a blocked response mutiple times. + cachedPlayerResponse = { videoId, ...createDeepCopy(unlockedPlayerResponse) }; + + return unlockedPlayerResponse; } + } +} + +export function isPlayerObject(parsedData) { + return (parsedData?.videoDetails && parsedData?.playabilityStatus) || typeof parsedData?.previewPlayabilityStatus === 'object'; +} - return !isStatusValid; - }); +export function isAgeRestricted(ytData) { + const playabilityStatus = ytData.previewPlayabilityStatus ?? ytData.playabilityStatus; - // Cache response to prevent a flood of requests in case youtube processes a blocked response mutiple times. - cachedPlayerResponse = { videoId, ...createDeepCopy(unlockedPlayerResponse) }; + if (!playabilityStatus?.status) return false; + if (playabilityStatus.desktopLegacyAgeGateReason) return true; + if (UNLOCKABLE_PLAYABILITY_STATUSES.includes(playabilityStatus.status)) return true; + + // Fix to detect age restrictions on embed player + // see https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/issues/85#issuecomment-946853553 + return ( + isEmbed + && playabilityStatus.errorScreen?.playerErrorMessageRenderer?.reason?.runs?.find((x) => x.navigationEndpoint)?.navigationEndpoint?.urlEndpoint?.url?.includes('/2802167') + ); +} - return unlockedPlayerResponse; +function getCurrentVideoStartTime(currentVideoId) { + // Check if the URL corresponds to the requested video + // This is not the case when the player gets preloaded for the next video in a playlist. + if (window.location.href.includes(currentVideoId)) { + // "t"-param on youtu.be urls + // "start"-param on embed player + // "time_continue" when clicking "watch on youtube" on embedded player + const urlParams = new URLSearchParams(window.location.search); + const startTimeString = (urlParams.get('t') || urlParams.get('start') || urlParams.get('time_continue'))?.replace('s', ''); + + if (startTimeString && !isNaN(startTimeString)) { + return parseInt(startTimeString); + } + } + + return 0; +} + +function getUnlockStrategies(videoId, reason) { + const clientName = getYtcfgValue('INNERTUBE_CLIENT_NAME') || 'WEB'; + const clientVersion = getYtcfgValue('INNERTUBE_CLIENT_VERSION') || '2.20220203.04.00'; + const signatureTimestamp = getSignatureTimestamp(); + const startTimeSecs = getCurrentVideoStartTime(videoId); + const hl = getYtcfgValue('HL'); + + return [ + /** + * Retrieve the video info by just adding `racyCheckOk` and `contentCheckOk` params + * This strategy can be used to bypass content warnings + */ + { + name: 'Content Warning Bypass', + skip: !reason || !reason.includes('CHECK_REQUIRED'), + optionalAuth: true, + payload: { + context: { + client: { + clientName: clientName, + clientVersion: clientVersion, + hl, + }, + }, + playbackContext: { + contentPlaybackContext: { + signatureTimestamp, + }, + }, + videoId, + startTimeSecs, + racyCheckOk: true, + contentCheckOk: true, + }, + endpoint: innertube, + }, + /** + * Retrieve the video info by using the TVHTML5 Embedded client + * This client has no age restrictions in place (2022-03-28) + * See https://github.com/zerodytrash/YouTube-Internal-Clients + */ + { + name: 'TV Embedded Player', + requiresAuth: false, + payload: { + context: { + client: { + clientName: 'TVHTML5_SIMPLY_EMBEDDED_PLAYER', + clientVersion: '2.0', + clientScreen: 'WATCH', + hl, + }, + thirdParty: { + embedUrl: 'https://www.youtube.com/', + }, + }, + playbackContext: { + contentPlaybackContext: { + signatureTimestamp, + }, + }, + videoId, + startTimeSecs, + racyCheckOk: true, + contentCheckOk: true, + }, + endpoint: innertube, + }, + /** + * Retrieve the video info by using the WEB_CREATOR client in combination with user authentication + * Requires that the user is logged in. Can bypass the tightened age verification in the EU. + * See https://github.com/yt-dlp/yt-dlp/pull/600 + */ + { + name: 'Creator + Auth', + requiresAuth: true, + payload: { + context: { + client: { + clientName: 'WEB_CREATOR', + clientVersion: '1.20210909.07.00', + hl, + }, + }, + playbackContext: { + contentPlaybackContext: { + signatureTimestamp, + }, + }, + videoId, + startTimeSecs, + racyCheckOk: true, + contentCheckOk: true, + }, + endpoint: innertube, + }, + ]; } diff --git a/src/config.js b/src/config.js index 08c53dc..b21f0f5 100644 --- a/src/config.js +++ b/src/config.js @@ -1,51 +1,17 @@ -// Script configuration variables -const UNLOCKABLE_PLAYABILITY_STATUSES = ['AGE_VERIFICATION_REQUIRED', 'AGE_CHECK_REQUIRED', 'CONTENT_CHECK_REQUIRED', 'LOGIN_REQUIRED']; -const VALID_PLAYABILITY_STATUSES = ['OK', 'LIVE_STREAM_OFFLINE']; - -// These are the proxy servers that are sometimes required to unlock videos with age restrictions. -// You can host your own account proxy instance. See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/tree/main/account-proxy -// To learn what information is transferred, please read: https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#privacy -const ACCOUNT_PROXY_SERVER_HOST = 'https://youtube-proxy.zerody.one'; -const VIDEO_PROXY_SERVER_HOST = 'https://ny.4everproxy.com'; - // User needs to confirm the unlock process on embedded player? -let ENABLE_UNLOCK_CONFIRMATION_EMBED = true; +export let ENABLE_UNLOCK_CONFIRMATION_EMBED = true; // Show notification? -let ENABLE_UNLOCK_NOTIFICATION = true; +export let ENABLE_UNLOCK_NOTIFICATION = true; // Disable content warnings? -let SKIP_CONTENT_WARNINGS = true; - -// Some Innertube bypass methods require the following authentication headers of the currently logged in user. -const GOOGLE_AUTH_HEADER_NAMES = ['Authorization', 'X-Goog-AuthUser', 'X-Origin']; - -/** - * The SQP parameter length is different for blurred thumbnails. - * They contain much less information, than normal thumbnails. - * The thumbnail SQPs tend to have a long and a short version. - */ -const BLURRED_THUMBNAIL_SQP_LENGTHS = [ - 32, // Mobile (SHORT) - 48, // Desktop Playlist (SHORT) - 56, // Desktop (SHORT) - 68, // Mobile (LONG) - 72, // Mobile Shorts - 84, // Desktop Playlist (LONG) - 88, // Desktop (LONG) -]; +export let SKIP_CONTENT_WARNINGS = true; -// small hack to prevent tree shaking on these exports +// WORKAROUND: Do not treeshake export default window[Symbol()] = { - UNLOCKABLE_PLAYABILITY_STATUSES, - VALID_PLAYABILITY_STATUSES, - ACCOUNT_PROXY_SERVER_HOST, - VIDEO_PROXY_SERVER_HOST, ENABLE_UNLOCK_CONFIRMATION_EMBED, ENABLE_UNLOCK_NOTIFICATION, SKIP_CONTENT_WARNINGS, - GOOGLE_AUTH_HEADER_NAMES, - BLURRED_THUMBNAIL_SQP_LENGTHS, }; if (__BUILD_TARGET__ === 'WEB_EXTENSION') { diff --git a/src/utils/logger.js b/src/logger.js similarity index 60% rename from src/utils/logger.js rename to src/logger.js index 868a0dc..d562f7d 100644 --- a/src/utils/logger.js +++ b/src/logger.js @@ -1,11 +1,9 @@ -import { getYtcfgValue } from '../utils'; - const logPrefix = '%cSimple-YouTube-Age-Restriction-Bypass:'; const logPrefixStyle = 'background-color: #1e5c85; color: #fff; font-size: 1.2em;'; const logSuffix = '\uD83D\uDC1E You can report bugs at: https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/issues'; export function error(err, msg) { - console.error(logPrefix, logPrefixStyle, msg, err, getYtcfgDebugString(), '\n\n', logSuffix); + console.error(logPrefix, logPrefixStyle, msg, err, '\n\n', logSuffix); if (window.SYARB_CONFIG) { window.dispatchEvent( new CustomEvent('SYARB_LOG_ERROR', { @@ -30,17 +28,3 @@ export function info(msg) { ); } } - -export function getYtcfgDebugString() { - try { - return ( - `InnertubeConfig: ` - + `innertubeApiKey: ${getYtcfgValue('INNERTUBE_API_KEY')} ` - + `innertubeClientName: ${getYtcfgValue('INNERTUBE_CLIENT_NAME')} ` - + `innertubeClientVersion: ${getYtcfgValue('INNERTUBE_CLIENT_VERSION')} ` - + `loggedIn: ${getYtcfgValue('LOGGED_IN')} ` - ); - } catch (err) { - return `Failed to access config: ${err}`; - } -} diff --git a/src/main.js b/src/main.js index 187b962..91fec64 100644 --- a/src/main.js +++ b/src/main.js @@ -1,42 +1,91 @@ -import './config'; -import * as inspectors from './components/inspectors'; -import * as interceptors from './components/interceptors'; -import * as requestPreprocessor from './components/requestPreprocessor'; -import * as thumbnailFix from './components/thumbnailFix'; -import * as unlocker from './components/unlocker'; -import * as logger from './utils/logger'; - -try { - interceptors.attachInitialDataInterceptor(processYtData); - interceptors.attachJsonInterceptor(processYtData); - interceptors.attachXhrOpenInterceptor(requestPreprocessor.handleXhrOpen); - interceptors.attachRequestInterceptor(requestPreprocessor.handleFetchRequest); -} catch (err) { - logger.error(err, 'Error while attaching data interceptors'); -} +// Leave config on top +import './config.js'; +import * as thumbnailFix from './components/thumbnailFix.js'; +import * as nextUnlocker from './components/unlocker/next.js'; +import * as playerUnlocker from './components/unlocker/player.js'; +import * as config from './config.js'; +import * as logger from './logger.js'; +import { createDeepCopy, GOOGLE_AUTH_HEADER_NAMES } from './utils.js'; + +/** + * And here we deal with YouTube's crappy initial data (present in page source) and the problems that occur when intercepting that data. + * YouTube has some protections in place that make it difficult to intercept and modify the global ytInitialPlayerResponse variable. + * The easiest way would be to set a descriptor on that variable to change the value directly on declaration. + * But some adblockers define their own descriptors on the ytInitialPlayerResponse variable, which makes it hard to register another descriptor on it. + * As a workaround only the relevant playerResponse property of the ytInitialPlayerResponse variable will be intercepted. + * This is achieved by defining a descriptor on the object prototype for that property, which affects any object with a `playerResponse` property. + */ +interceptObjectProperty('playerResponse', (obj, playerResponse) => { + logger.info(`playerResponse property set, contains sidebar: ${!!obj.response}`); + + // The same object also contains the sidebar data and video description + if (obj.response) processYtData(obj.response); + + // If the script is executed too late and the bootstrap data has already been processed, + // a reload of the player can be forced by creating a deep copy of the object. + // This is especially relevant if the userscript manager does not handle the `@run-at document-start` correctly. + return processYtData(playerResponse) ? createDeepCopy(playerResponse) : playerResponse; +}); + +// The global `ytInitialData` variable can be modified on the fly. +// It contains search results, sidebar data and meta information +// Not really important but fixes https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/issues/127 +window.addEventListener('DOMContentLoaded', () => { + if (window.ytInitialData) { + processYtData(window.ytInitialData); + } +}); + +JSON.parse = new Proxy(JSON.parse, { + construct(target, args) { + const data = Reflect.construct(target, args); + processYtData(data); + return data; + }, +}); + +XMLHttpRequest.prototype.open = new Proxy(XMLHttpRequest.prototype.open, { + construct(target, args) { + const [method, url] = args; + try { + if (typeof url === 'string' && url.indexOf('https://') !== -1) { + handleXhrOpen(method, url, this); + } + } catch (err) { + logger.error(err, `Failed to intercept XMLHttpRequest.open()`); + } + + return Reflect.construct(target, args); + }, +}); + +Request = new Proxy(Request, { + construct(target, args) { + const [url, options] = args; + try { + if (typeof url === 'string' && url.indexOf('https://') !== -1) { + handleFetchRequest(url, options); + } + } catch (err) { + logger.error(err, `Failed to intercept Request()`); + } + + return Reflect.construct(target, args); + }, +}); function processYtData(ytData) { try { - // Player Unlock #1: Initial page data structure and response from `/youtubei/v1/player` XHR request - if (inspectors.player.isPlayerObject(ytData) && inspectors.player.isAgeRestricted(ytData.playabilityStatus)) { - unlocker.unlockPlayerResponse(ytData); - } // Player Unlock #2: Embedded Player inital data structure - else if (inspectors.player.isEmbeddedPlayerObject(ytData) && inspectors.player.isAgeRestricted(ytData.previewPlayabilityStatus)) { - unlocker.unlockPlayerResponse(ytData); + if (playerUnlocker.isPlayerObject(ytData) && playerUnlocker.isAgeRestricted(ytData)) { + return playerUnlocker.unlockResponse(ytData); } } catch (err) { logger.error(err, 'Video unlock failed'); } try { - // Unlock sidebar watch next feed (sidebar) and video description - if (inspectors.next.isWatchNextObject(ytData) && inspectors.next.isWatchNextSidebarEmpty(ytData)) { - unlocker.unlockNextResponse(ytData); - } - - // Mobile version - if (inspectors.next.isWatchNextObject(ytData.response) && inspectors.next.isWatchNextSidebarEmpty(ytData.response)) { - unlocker.unlockNextResponse(ytData.response); + if (nextUnlocker.isWatchNextObject(ytData) && nextUnlocker.isWatchNextSidebarEmpty(ytData)) { + nextUnlocker.unlockResponse(ytData); } } catch (err) { logger.error(err, 'Sidebar unlock failed'); @@ -44,12 +93,126 @@ function processYtData(ytData) { try { // Unlock blurry video thumbnails in search results - if (inspectors.search.isSearchResult(ytData)) { + if (isSearchResult(ytData)) { thumbnailFix.processThumbnails(ytData); } } catch (err) { logger.error(err, 'Thumbnail unlock failed'); } +} + +function interceptObjectProperty(prop, onSet) { + // Allow other userscripts to decorate this descriptor, if they do something similar + const dataKey = '__SYARB_' + prop; + const { get: getter, set: setter } = Object.getOwnPropertyDescriptor(Object.prototype, prop) ?? { + set(value) { + this[dataKey] = value; + }, + get() { + return this[dataKey]; + }, + }; + + // Intercept the given property on any object + // The assigned attribute value and the context (enclosing object) are passed to the onSet function. + Object.defineProperty(Object.prototype, prop, { + set(value) { + setter.call(this, value ? onSet(this, value) : value); + }, + get() { + return getter.call(this); + }, + configurable: true, + }); +} + +function isSearchResult(parsedData) { + return ( + typeof parsedData?.contents?.twoColumnSearchResultsRenderer === 'object' // Desktop initial results + || parsedData?.contents?.sectionListRenderer?.targetId === 'search-feed' // Mobile initial results + || parsedData?.onResponseReceivedCommands?.find((x) => x.appendContinuationItemsAction)?.appendContinuationItemsAction?.targetId === 'search-feed' // Desktop & Mobile scroll continuation + ); +} - return ytData; +function attachGenericInterceptor(obj, prop, onCall) { + if (!obj || typeof obj[prop] !== 'function') { + return; + } + + const original = obj[prop]; + + obj[prop] = function(...args) { + try { + onCall(args); + } catch {} + original.apply(this, args); + }; +} + +/** + * Handles XMLHttpRequests and + * - Rewrite Googlevideo URLs to Proxy URLs (if necessary) + * - Store auth headers for the authentication of further unlock requests. + * - Add "content check ok" flags to request bodys + */ +function handleXhrOpen(method, url, xhr) { + const url_obj = new URL(url); + + if (url_obj.pathname.startsWith('/youtubei/')) { + // Store auth headers in storage for further usage. + attachGenericInterceptor(xhr, 'setRequestHeader', ([key, value]) => { + if (GOOGLE_AUTH_HEADER_NAMES.includes(key)) { + localStorage.setItem('SYARB_' + key, JSON.stringify(value)); + } + }); + } + + if (config.SKIP_CONTENT_WARNINGS && method === 'POST' && ['/youtubei/v1/player', '/youtubei/v1/next'].includes(url_obj.pathname)) { + // Add content check flags to player and next request (this will skip content warnings) + attachGenericInterceptor(xhr, 'send', (args) => { + if (typeof args[0] === 'string') { + args[0] = setContentCheckOk(args[0]); + } + }); + } +} + +/** + * Handles Fetch requests and + * - Rewrite Googlevideo URLs to Proxy URLs (if necessary) + * - Store auth headers for the authentication of further unlock requests. + * - Add "content check ok" flags to request bodys + */ +function handleFetchRequest(url, requestOptions) { + const url_obj = new URL(url); + + if (url_obj.pathname.startsWith('/youtubei/') && requestOptions.headers) { + // Store auth headers in authStorage for further usage. + for (const key in requestOptions.headers) { + if (GOOGLE_AUTH_HEADER_NAMES.includes(key)) { + localStorage.setItem('SYARB_' + key, JSON.stringify(requestOptions.headers[key])); + } + } + } + + if (config.SKIP_CONTENT_WARNINGS && ['/youtubei/v1/player', '/youtubei/v1/next'].includes(url_obj.pathname)) { + // Add content check flags to player and next request (this will skip content warnings) + requestOptions.body = setContentCheckOk(requestOptions.body); + } +} + +/** + * Adds `contentCheckOk` and `racyCheckOk` to the given json data (if the data contains a video id) + * @returns {string} The modified json + */ +function setContentCheckOk(bodyJson) { + try { + const parsedBody = JSON.parse(bodyJson); + if (parsedBody.videoId) { + parsedBody.contentCheckOk = true; + parsedBody.racyCheckOk = true; + return JSON.stringify(parsedBody); + } + } catch {} + return bodyJson; } diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..d56697d --- /dev/null +++ b/src/utils.js @@ -0,0 +1,94 @@ +export const nativeJSONParse = JSON.parse; +export const nativeXMLHttpRequestOpen = XMLHttpRequest.prototype.open; + +export const isDesktop = location.host !== 'm.youtube.com'; +export const isMusic = location.host === 'music.youtube.com'; +export const isEmbed = location.pathname.indexOf('/embed/') === 0; +export const isConfirmed = location.search.includes('unlock_confirmed'); + +// Some Innertube bypass methods require the following authentication headers of the currently logged in user. +export const GOOGLE_AUTH_HEADER_NAMES = ['Authorization', 'X-Goog-AuthUser', 'X-Origin']; + +// WORKAROUND: TypeError: Failed to set the 'innerHTML' property on 'Element': This document requires 'TrustedHTML' assignment. +if (window.trustedTypes) { + if (!window.trustedTypes.defaultPolicy) { + const passThroughFn = (x) => x; + window.trustedTypes.createPolicy('default', { + createHTML: passThroughFn, + createScriptURL: passThroughFn, + createScript: passThroughFn, + }); + } +} + +export function createElement(tagName, options) { + const node = document.createElement(tagName); + options && Object.assign(node, options); + return node; +} + +export function pageLoaded() { + if (document.readyState === 'complete') return Promise.resolve(); + + const { promise, resolve } = Promise.withResolvers(); + + window.addEventListener('load', resolve, { once: true }); + + return promise; +} + +export function createDeepCopy(obj) { + return nativeJSONParse(JSON.stringify(obj)); +} + +export function getYtcfgValue(name) { + return window.ytcfg?.get(name); +} + +export function getSignatureTimestamp() { + return ( + getYtcfgValue('STS') + || (() => { + // STS is missing on embedded player. Retrieve from player base script as fallback... + const playerBaseJsPath = document.querySelector('script[src*="/base.js"]')?.src; + + if (!playerBaseJsPath) return; + + const xmlhttp = new XMLHttpRequest(); + xmlhttp.open('GET', playerBaseJsPath, false); + xmlhttp.send(null); + + return parseInt(xmlhttp.responseText.match(/signatureTimestamp:([0-9]*)/)[1]); + })() + ); +} + +export function isUserLoggedIn() { + // LOGGED_IN doesn't exist on embedded page, use DELEGATED_SESSION_ID or SESSION_INDEX as fallback + if (typeof getYtcfgValue('LOGGED_IN') === 'boolean') return getYtcfgValue('LOGGED_IN'); + if (typeof getYtcfgValue('DELEGATED_SESSION_ID') === 'string') return true; + if (parseInt(getYtcfgValue('SESSION_INDEX')) >= 0) return true; + + return false; +} + +export function waitForElement(elementSelector, timeout) { + const { promise, resolve, reject } = Promise.withResolvers(); + + const checkDomInterval = setInterval(() => { + const elem = document.querySelector(elementSelector); + if (elem) { + clearInterval(checkDomInterval); + resolve(elem); + } + }, 100); + + if (timeout) { + setTimeout(() => { + clearInterval(checkDomInterval); + reject(); + }, timeout); + } + + return promise; +} diff --git a/src/utils/index.js b/src/utils/index.js deleted file mode 100644 index d3eac45..0000000 --- a/src/utils/index.js +++ /dev/null @@ -1,156 +0,0 @@ -import { nativeJSONParse } from '../components/interceptors/natives'; - -export const isDesktop = window.location.host !== 'm.youtube.com'; -export const isMusic = window.location.host === 'music.youtube.com'; -export const isEmbed = window.location.pathname.indexOf('/embed/') === 0; -export const isConfirmed = window.location.search.includes('unlock_confirmed'); - -export class Deferred { - constructor() { - return Object.assign( - new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }), - this, - ); - } -} - -// WORKAROUND: TypeError: Failed to set the 'innerHTML' property on 'Element': This document requires 'TrustedHTML' assignment. -if (window.trustedTypes && trustedTypes.createPolicy) { - if (!trustedTypes.defaultPolicy) { - const passThroughFn = (x) => x; - trustedTypes.createPolicy('default', { - createHTML: passThroughFn, - createScriptURL: passThroughFn, - createScript: passThroughFn, - }); - } -} - -export function createElement(tagName, options) { - const node = document.createElement(tagName); - options && Object.assign(node, options); - return node; -} - -export function isObject(obj) { - return obj !== null && typeof obj === 'object'; -} - -export function findNestedObjectsByAttributeNames(object, attributeNames) { - var results = []; - - // Does the current object match the attribute conditions? - if (attributeNames.every((key) => typeof object[key] !== 'undefined')) { - results.push(object); - } - - // Diggin' deeper for each nested object (recursive) - Object.keys(object).forEach((key) => { - if (object[key] && typeof object[key] === 'object') { - results.push(...findNestedObjectsByAttributeNames(object[key], attributeNames)); - } - }); - - return results; -} - -export function getCookie(name) { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts.pop().split(';').shift(); -} - -export function pageLoaded() { - if (document.readyState === 'complete') return Promise.resolve(); - - const deferred = new Deferred(); - - window.addEventListener('load', deferred.resolve, { once: true }); - - return deferred; -} - -export function createDeepCopy(obj) { - return nativeJSONParse(JSON.stringify(obj)); -} - -export function getYtcfgValue(name) { - return window.ytcfg?.get(name); -} - -export function getSignatureTimestamp() { - return ( - getYtcfgValue('STS') - || (() => { - // STS is missing on embedded player. Retrieve from player base script as fallback... - const playerBaseJsPath = document.querySelector('script[src*="/base.js"]')?.src; - - if (!playerBaseJsPath) return; - - const xmlhttp = new XMLHttpRequest(); - xmlhttp.open('GET', playerBaseJsPath, false); - xmlhttp.send(null); - - return parseInt(xmlhttp.responseText.match(/signatureTimestamp:([0-9]*)/)[1]); - })() - ); -} - -export function isUserLoggedIn() { - // LOGGED_IN doesn't exist on embedded page, use DELEGATED_SESSION_ID or SESSION_INDEX as fallback - if (typeof getYtcfgValue('LOGGED_IN') === 'boolean') return getYtcfgValue('LOGGED_IN'); - if (typeof getYtcfgValue('DELEGATED_SESSION_ID') === 'string') return true; - if (parseInt(getYtcfgValue('SESSION_INDEX')) >= 0) return true; - - return false; -} - -export function getCurrentVideoStartTime(currentVideoId) { - // Check if the URL corresponds to the requested video - // This is not the case when the player gets preloaded for the next video in a playlist. - if (window.location.href.includes(currentVideoId)) { - // "t"-param on youtu.be urls - // "start"-param on embed player - // "time_continue" when clicking "watch on youtube" on embedded player - const urlParams = new URLSearchParams(window.location.search); - const startTimeString = (urlParams.get('t') || urlParams.get('start') || urlParams.get('time_continue'))?.replace('s', ''); - - if (startTimeString && !isNaN(startTimeString)) { - return parseInt(startTimeString); - } - } - - return 0; -} - -export function setUrlParams(params) { - const urlParams = new URLSearchParams(window.location.search); - for (const paramName in params) { - urlParams.set(paramName, params[paramName]); - } - window.location.search = urlParams; -} - -export function waitForElement(elementSelector, timeout) { - const deferred = new Deferred(); - - const checkDomInterval = setInterval(() => { - const elem = document.querySelector(elementSelector); - if (elem) { - clearInterval(checkDomInterval); - deferred.resolve(elem); - } - }, 100); - - if (timeout) { - setTimeout(() => { - clearInterval(checkDomInterval); - deferred.reject(); - }, timeout); - } - - return deferred; -} diff --git a/userscript.config.js b/userscript.config.js deleted file mode 100644 index aaa925c..0000000 --- a/userscript.config.js +++ /dev/null @@ -1,30 +0,0 @@ -// ==UserScript== -// @name Simple YouTube Age Restriction Bypass -// @description Watch age restricted videos on YouTube without login and without age verification 😎 -// @description:de Schaue YouTube Videos mit Altersbeschränkungen ohne Anmeldung und ohne dein Alter zu bestätigen 😎 -// @description:fr Regardez des vidéos YouTube avec des restrictions d'âge sans vous inscrire et sans confirmer votre âge 😎 -// @description:it Guarda i video con restrizioni di età su YouTube senza login e senza verifica dell'età 😎 -// @icon https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/raw/v2.5.4/src/extension/icon/icon_64.png -// @version __BUILD_VERSION__ -// @author Zerody (https://github.com/zerodytrash) -// @namespace https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/ -// @supportURL https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/issues -// @updateURL https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/raw/main/dist/Simple-YouTube-Age-Restriction-Bypass.user.js -// @license MIT -// @match https://www.youtube.com/* -// @match https://www.youtube-nocookie.com/* -// @match https://m.youtube.com/* -// @match https://music.youtube.com/* -// @grant none -// @run-at document-start -// @compatible chrome -// @compatible firefox -// @compatible opera -// @compatible edge -// @compatible safari -// ==/UserScript== - -/* - This is a transpiled version to achieve a clean code base and better browser compatibility. - You can find the nicely readable source code at https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass -*/