Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion mods/features/adblock.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { timelyAction, longPressData, MenuServiceItemRenderer, ShelfRenderer, Ti
import { PatchSettings } from '../ui/customYTSettings.js';
import { t } from 'i18next';
import './logServer.js';
import { scheduleCollectAfterNativeSettles, getCachedFullPlaylist, noteInitialPlaylistContents, noteContinuationBatch } from './playlistBatchCollect.js';
import { scheduleCollectAfterNativeSettles, getCachedFullPlaylist, consumeCachedFullPlaylist, noteInitialPlaylistContents, noteContinuationBatch } from './playlistBatchCollect.js';
import {
appendFileOnlyLog,
detectAndStorePage,
Expand Down Expand Up @@ -944,6 +944,12 @@ JSON.parse = function () {
});
topPlaylistRenderer.contents = cachedFull.slice();
topPlaylistRenderer.continuations = null;
// One-shot: the cache exists only to carry the full list across the
// reload it triggered. Keeping it would serve these same renderers on
// every later visit, and their embedded watch-progress overlays are
// frozen at collection time — so anything watched since would come
// back looking unwatched.
consumeCachedFullPlaylist(playlistKey);
} else {
// Hand the raw first batch to the collector: _collectAll only returns
// the CONTINUATION batches, so the cached full list needs this to be
Expand Down
58 changes: 47 additions & 11 deletions mods/features/playlistBatchCollect.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ function _clearState() {
clearTimeout(window.__ttNativeSettleTimer);
window.__ttNativeSettleTimer = null;
window.__ttLatestContinuations = null;
// Per-visit guards: cleared on real navigation so the next visit can run
// its own collect+reload, while staying set across a SOFT_RELOAD_PAGE
// (which does not navigate) so that reload cannot repeat.
window.__ttServedFromCache = null;
window.__ttFullReloadDone = new Set();
}
window.addEventListener('hashchange', _clearState);
window.addEventListener('popstate', _clearState);
Expand Down Expand Up @@ -375,16 +380,46 @@ function playlistKeyFromHash() {
try { return String(window.location?.hash || ''); } catch (_) { return ''; }
}

// Backed by a Map, not a plain object, because the key is the location hash.
// That is attacker-influenceable via a crafted URL, and writing cache[key] on
// a plain object would let a hash of #__proto__ reach Object.prototype
// (flagged by CodeQL as remote property injection). A Map treats every key as
// ordinary data, with no prototype chain to walk into.
function getFullCache() {
if (!window.__ttPlaylistFullCache) window.__ttPlaylistFullCache = {};
if (!(window.__ttPlaylistFullCache instanceof Map)) window.__ttPlaylistFullCache = new Map();
return window.__ttPlaylistFullCache;
}

export function getCachedFullPlaylist(key) {
const entry = getFullCache()[key];
const entry = getFullCache().get(key);
return entry && Array.isArray(entry.contents) ? entry.contents : null;
}

// Drop a cached playlist once it has been injected. The cache is strictly a
// hand-off from the pre-reload pass to the reloaded page, NOT a store to reuse
// on later visits.
//
// It holds raw renderer objects captured at collection time, and
// getWatchPercent() reads watched state primarily from data embedded in those
// renderers (thumbnailOverlayResumePlaybackRenderer and friends), consulting
// the live _ttVideoProgressCache only as a fallback. So a cached item keeps
// whatever progress it had when it was captured: watch a video, come back, and
// the injected copy still presents it as unwatched, which is exactly what was
// reported. Serving a fresh response instead costs one more collect+reload
// cycle per visit, which is worth it for correct watched state.
export function consumeCachedFullPlaylist(key) {
const cache = getFullCache();
if (!cache.has(key)) return;
cache.delete(key);
// Mark this page load as already complete so the scheduler stands down:
// it has the whole playlist and no continuation token, and letting it
// collect again would store a new cache entry and reload a second time.
// The reload guard is NOT cleared here — it is cleared on real navigation
// (_clearState), which is what lets the next visit run its own cycle
// without allowing a loop within this one.
window.__ttServedFromCache = key;
}

// adblock.js hands us the raw, unfiltered contents of the initial response so
// the cached list can start with batch 1 — _collectAll only ever returns the
// CONTINUATION batches (53 of 68 on the measured playlist), never the first.
Expand All @@ -401,17 +436,17 @@ function storeFullPlaylist(key, collectedContents) {
}
const cache = getFullCache();
// Bound the cache: these are full renderer objects and this runs on a TV.
const keys = Object.keys(cache);
if (keys.length >= FULL_CACHE_MAX_PLAYLISTS && !cache[key]) {
const oldest = keys.reduce((a, b) => (cache[a].ts <= cache[b].ts ? a : b));
delete cache[oldest];
if (cache.size >= FULL_CACHE_MAX_PLAYLISTS && !cache.has(key)) {
let oldestKey = null, oldestTs = Infinity;
for (const [k2, v] of cache) { if (v.ts < oldestTs) { oldestTs = v.ts; oldestKey = k2; } }
if (oldestKey !== null) cache.delete(oldestKey);
}
cache[key] = { contents: initial.contents.concat(collectedContents), ts: Date.now() };
cache.set(key, { contents: initial.contents.concat(collectedContents), ts: Date.now() });
_log('playlist.full_cache.stored', {
key,
initial: initial.contents.length,
collected: collectedContents.length,
total: cache[key].contents.length,
total: cache.get(key).contents.length,
});
return true;
}
Expand All @@ -420,16 +455,16 @@ function storeFullPlaylist(key, collectedContents) {
// list would render as a truncated playlist with no way to load the rest,
// since the injected response carries no continuation token.
function maybeReloadForFullPlaylist(key) {
if (!window.__ttFullReloadDone) window.__ttFullReloadDone = {};
if (window.__ttFullReloadDone[key]) {
if (!(window.__ttFullReloadDone instanceof Set)) window.__ttFullReloadDone = new Set();
if (window.__ttFullReloadDone.has(key)) {
_log('playlist.full_cache.reload_skipped', { key, reason: 'already_reloaded' });
return;
}
if (playlistKeyFromHash() !== key) {
_log('playlist.full_cache.reload_skipped', { key, reason: 'navigated_away' });
return;
}
window.__ttFullReloadDone[key] = true;
window.__ttFullReloadDone.add(key);
_log('playlist.full_cache.reloading', { key });
try {
// Reached through a window global rather than an ES import. This module
Expand Down Expand Up @@ -533,6 +568,7 @@ export function scheduleCollectAfterNativeSettles(continuations, reason) {

const key = playlistKeyFromHash();
if (getCachedFullPlaylist(key)) return; // reloaded pass: already complete
if (window.__ttServedFromCache === key) return; // page already holds the full list

// Newest token wins — each native continuation supersedes the last.
if (_getToken(continuations)) window.__ttLatestContinuations = continuations;
Expand Down