From ee07a744d857f28a5fc419f39c49659055d79108 Mon Sep 17 00:00:00 2001 From: KrX3D Date: Sat, 29 Aug 2026 21:27:42 +0200 Subject: [PATCH] feat: serve fully-collected playlists from cache so no helper is ever needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the helper tiles entirely for playlists the background collector manages to fetch in full, which is the only way left to be rid of the stranded blank slots. Why this shape. A helper only exists because a continuation response must append at least one item or YouTube stops asking for more (proved by the previous PR, which returned [] and stalled a 68-video playlist after two batches). The initial page response is the one place that constraint does not apply: give it the WHOLE playlist and null its continuations, and there is nothing left to load, so no keep-one branch runs and no helper is ever created. Nothing then gets stranded in the virtual list's data model, which is what made every DOM-side attempt futile (654577e). The ordering problem is that on a first visit the full list only exists after collection finishes, long after the initial response rendered. So: collect as now, cache the result keyed by playlist hash, and reload the page once. The reloaded initial response is served from cache, complete, with continuations nulled. Revisiting that playlist later in the session hits the cache immediately and skips both the collection and the reload. Reloading per batch — the obvious variant — cannot work: a reload re-fetches the FIRST page, so it would loop on batch 1 forever. Details: - _collectAll only ever returns the CONTINUATION batches (53 of 68 on the measured playlist), so adblock.js hands the raw initial batch over via noteInitialPlaylistContents to make the cached list complete from item 1. - Only a COMPLETE collection is cached (no continuation token left, not aborted). Caching a partial list would render a truncated playlist with no token to load the rest. - Reload fires at most once per playlist key, and not at all if the user navigated away while collecting. - Cache is bounded to 3 playlists, evicting oldest — these are full renderer objects and this runs on a TV. - resolveCommand is reached through a window global rather than an ES import: playlistBatchCollect.js must evaluate before adblock.js to capture JSON.parse ahead of its patch, and importing resolveCommand (which pulls in settings/UI) would risk reordering that. Cost is one visible reload the first time a playlist is opened. Only active with enablePlaylistBatchCollect on. --- mods/features/adblock.js | 28 ++++++- mods/features/playlistBatchCollect.js | 112 ++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/mods/features/adblock.js b/mods/features/adblock.js index 4d3ec378..1f6d2e99 100644 --- a/mods/features/adblock.js +++ b/mods/features/adblock.js @@ -5,7 +5,7 @@ import { timelyAction, longPressData, MenuServiceItemRenderer, ShelfRenderer, Ti import { PatchSettings } from '../ui/customYTSettings.js'; import { t } from 'i18next'; import './logServer.js'; -import { autoStartCollect } from './playlistBatchCollect.js'; +import { autoStartCollect, getCachedFullPlaylist, noteInitialPlaylistContents } from './playlistBatchCollect.js'; import { appendFileOnlyLog, detectAndStorePage, @@ -158,6 +158,7 @@ function attemptPlaylistAutoLoad(reason = 'playlist.auto_load', attempt = 0) { appendFileOnlyLog('playlist.auto_load.trigger', { reason, attempt, method: 'no_trigger' }); } window.__ttAttemptPlaylistAutoLoad = attemptPlaylistAutoLoad; +window.__ttResolveCommand = resolveCommand; function schedulePlaylistAutoLoad(reason = 'playlist.auto_load') { if ((window.__ttLastDetectedPage || detectCurrentPage()) !== 'playlist') return; @@ -917,10 +918,35 @@ JSON.parse = function () { const topPlaylistRenderer = r?.contents?.tvBrowseRenderer?.content?.tvSurfaceContentRenderer?.content?.twoColumnRenderer?.rightColumn?.playlistVideoListRenderer; if (topPlaylistRenderer?.contents) { + // If a previous visit (or the pre-reload pass) already collected this + // playlist in full, serve it here instead. Nulling continuations is the + // whole point: with no continuation token there is no keep-one branch, + // so no helper tile is created and nothing gets stranded in the virtual + // list's data model. Starving YouTube's refill loop is safe here + // precisely because the list is already complete — nothing further + // needs loading. + const playlistKey = String(window.location?.hash || ''); + const cachedFull = getCachedFullPlaylist(playlistKey); + if (cachedFull) { + appendFileOnlyLog('playlist.full_cache.injected', { + key: playlistKey, + items: cachedFull.length, + replaced: topPlaylistRenderer.contents.length, + }); + topPlaylistRenderer.contents = cachedFull.slice(); + topPlaylistRenderer.continuations = null; + } else { + // Hand the raw first batch to the collector: _collectAll only returns + // the CONTINUATION batches, so the cached full list needs this to be + // complete from item 1. + noteInitialPlaylistContents(playlistKey, topPlaylistRenderer.contents); + } storePlaylistContinuationToken(topPlaylistRenderer.continuations, 'topPlaylist'); // Start collecting the rest of the playlist in the background right // now, instead of waiting for the user to scroll down once to trigger // it — see playlistBatchCollect.js's autoStartCollect() doc comment. + // No-ops when serving from cache: continuations is null, so there is no + // token to collect with. autoStartCollect(topPlaylistRenderer.continuations); filterPlaylistRendererContents(topPlaylistRenderer, detectedPage, 'playlist.renderer'); } diff --git a/mods/features/playlistBatchCollect.js b/mods/features/playlistBatchCollect.js index 3e963a3d..92705812 100644 --- a/mods/features/playlistBatchCollect.js +++ b/mods/features/playlistBatchCollect.js @@ -370,6 +370,103 @@ async function _collectAll(url, plc, context, headers) { return { allContents, continuations, aborted: abort.signal.aborted }; } +// ── Full-playlist cache and one-shot reload ────────────────────────────────── +// Goal: get the ENTIRE playlist into the INITIAL page response, because that +// response is the only one where continuations can be nulled without starving +// YouTube's refill loop — and with no continuation there is no keep-one branch, +// so no helper tile is ever created. Helpers are what leave the permanently +// stranded blank slots (the virtual list's data model is unreachable on Tizen +// 5.0, 654577e), so removing the need for them is the only way to be rid of +// them. +// +// The catch is ordering: on a first visit the full list only exists AFTER the +// background collector finishes, which is well after the initial response has +// already been rendered. Injecting into a later continuation cannot fix it +// either — the initial batch has already kept its helper by then. So once +// collection completes we cache the whole playlist and reload the page once; +// the reloaded initial response is served from cache, complete, with +// continuations nulled. +// +// Reloading per batch (rather than after collection) would not work: a reload +// re-fetches the FIRST page, so it would loop on batch 1 forever. +const FULL_CACHE_MAX_PLAYLISTS = 3; + +function playlistKeyFromHash() { + try { return String(window.location?.hash || ''); } catch (_) { return ''; } +} + +function getFullCache() { + if (!window.__ttPlaylistFullCache) window.__ttPlaylistFullCache = {}; + return window.__ttPlaylistFullCache; +} + +export function getCachedFullPlaylist(key) { + const entry = getFullCache()[key]; + return entry && Array.isArray(entry.contents) ? entry.contents : null; +} + +// 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. +export function noteInitialPlaylistContents(key, contents) { + if (!Array.isArray(contents) || !contents.length) return; + window.__ttInitialPlaylistContents = { key, contents: contents.slice() }; +} + +function storeFullPlaylist(key, collectedContents) { + const initial = window.__ttInitialPlaylistContents; + if (!initial || initial.key !== key || !Array.isArray(initial.contents)) { + _log('playlist.full_cache.skip_no_initial', { key }); + return false; + } + 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]; + } + cache[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, + }); + return true; +} + +// Reload once per playlist, and only after a COMPLETE collection — a partial +// 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]) { + _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; + _log('playlist.full_cache.reloading', { key }); + try { + // Reached through a window global rather than an ES import. This module + // must evaluate BEFORE adblock.js so it can capture JSON.parse ahead of + // adblock's patch; importing resolveCommand (which pulls in settings/UI) + // would risk reordering that. adblock.js publishes the global. + const rc = window.__ttResolveCommand; + if (typeof rc !== 'function') { + _log('playlist.full_cache.reload_error', { key, err: 'resolveCommand unavailable' }); + return; + } + rc({ signalAction: { signal: 'SOFT_RELOAD_PAGE' } }); + } catch (err) { + _log('playlist.full_cache.reload_error', { key, err: String(err?.message || err) }); + } +} + // ── Auto-trigger on playlist page load ──────────────────────────────────────── // Called by adblock.js right after the initial playlist page's own // continuation token is parsed (topPlaylistRenderer.continuations) — starts @@ -401,6 +498,7 @@ export function autoStartCollect(continuations) { const context = _lastBrowseContext; const url = _lastBrowseUrl; const headers = _lastBrowseHeaders; + const startKey = playlistKeyFromHash(); ;(async () => { let collected = null; @@ -428,6 +526,20 @@ export function autoStartCollect(continuations) { hasMore: !!collected.continuations, auto: true, }); + + // Complete collection (no continuation token left) means we now hold the + // whole playlist. Cache it and reload once, so the initial response can be + // served complete with continuations nulled — and with no continuation + // there is no keep-one branch, hence no helper tile at all. + // A partial collection is deliberately NOT cached: the injected response + // carries no continuation token, so a truncated list would have no way to + // ever load its remainder. + if (!collected.continuations && !collected.aborted) { + if (storeFullPlaylist(startKey, collected.allContents)) { + maybeReloadForFullPlaylist(startKey); + return; + } + } _triggerReveal('playlist.batch_collect.auto_reveal'); })(); }