youtube time-machine. pick a date, see videos from that era. filters out all videos made after that date. V3 VORAPIS REQUIRED!
// ==UserScript==
// @name bygone-yt
// @namespace http://tampermonkey.net/
// @license MIT
// @version 386
// @description youtube time-machine. pick a date, see videos from that era. filters out all videos made after that date. V3 VORAPIS REQUIRED!
// @author relicofatime
// @match https://www.youtube.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @connect youtube.com
// @connect worldtimeapi.org
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
// ============================================================
// bygone-yt v2 — V3-only rewrite
// Modules (in declaration order):
// Interceptor (IIFE, document-start) — patches Response.json/.text;
// owns videoPool, mapVideo, sweep,
// sidebar sweep, click hijack.
// Config + VERSION
// Store — GM_* persistence + profiles + clock.
// DateHelper — relative-text ↔ Date.
// InterestModel — watch-history scoring.
// YouTubeAPI — InnerTube client (auth-trick).
// FeedEngine — merge 5 sources, dedup, weight.
// UI — panel + FAB + styles.
// App — wire everything.
// ============================================================
// ============================================================
// INTERCEPTOR (runs at document-start; before V3)
// Owns: videoPool, mapVideo, sweep, sidebar sweep, click hijack.
// Exposes setVideos / appendVideos / setLazyFetcher / mapVideo / sweep.
// References to Store, DateHelper, etc. resolve at CALL time, not
// install time — those classes are declared later in this file but
// exist before any YouTube response arrives.
// ============================================================
let _v3Detected = false;
function _checkV3() {
if (_v3Detected) return true;
// V3/VORAPIS runs in the PAGE context (@grant none). When bygone runs in a
// userscript-manager SANDBOX (e.g. the Android kiosk), page-context globals
// live on unsafeWindow, NOT the sandbox `window`, and PeakyTube's mobile
// layout drops the desktop .lohp-* shelves the old DOM check relied on — so
// a single hard-coded marker misses V3 entirely. Scan every cross-context
// signal instead and cache the first positive.
const mark = () => { _v3Detected = true; return true; };
const RX = /vlturbo|vorapis|turbopipe/i;
const de = document.documentElement;
// 1) documentElement attribute / class markers (DOM = cross-context).
try {
if (de.hasAttribute('v3') || de.hasAttribute('vorapis')) return mark();
for (const a of de.attributes) {
if (a.name === 'v3' || RX.test(a.name) || RX.test(a.value || '')) return mark();
}
if (RX.test(de.className || '')) return mark();
} catch (_) {}
// 2) localStorage keys V3 writes (same-origin → visible from any context).
try {
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && (RX.test(k) || /^v3[_-]/i.test(k))) return mark();
}
} catch (_) {}
// 3) page-context globals — on unsafeWindow when bygone is sandboxed.
try {
const pw = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window;
for (const w of [pw, window]) {
if (w && (w.PatcherJSC_TURBOPIPE || w.VLTURBO || w.VORAPIS)) return mark();
}
} catch (_) {}
// 4) DOM fallback for both desktop and mobile (PeakyTube) layouts.
try {
if (document.querySelector('.lohp-large-shelf-container, .lohp-medium-shelf, ' +
'ytm-browse, [class*="vorapis" i], [id*="vorapis" i], [class*="vlturbo" i]')) return mark();
} catch (_) {}
return false;
}
function _checkStarTube() {
try {
if (window.globalDataPoints || document.globalDataPoints) return true;
if (localStorage.getItem('ST_STABLE_SETTINGS')) return true;
if (localStorage.getItem('starTubeConfigCreated')) return true;
} catch (_) {}
return !!document.querySelector(
'#startube-settings-window-entity, #startube-settings-window, ' +
'#st-watch-below, #st-actions-info-row, [id^="st-"], [class^="st-"], [class*=" st-"]'
);
}
const Interceptor = (() => {
const POOL_LS_KEY = 'bygone_v3_pool';
// Migrate old localStorage key
try {
const old = localStorage.getItem('wbt_v3_pool');
if (old && !localStorage.getItem(POOL_LS_KEY)) localStorage.setItem(POOL_LS_KEY, old);
} catch (_) {}
let videoPool = [];
let active = false;
let _poolReadyCbs = [];
function _onPoolReady(fn) { if (active && videoPool.length) fn(); else _poolReadyCbs.push(fn); }
function _firePoolReady() { for (const fn of _poolReadyCbs) try { fn(); } catch (_) {} _poolReadyCbs = []; }
// _idMap (origId → video) gives STABLE mapping across responses.
// _responseSeen (video.id set) gives DEDUP WITHIN ONE response.
// _usedReplacements tracks all-time usage (for fresh-pick preference).
// _poolIdsSet is the set of pool video IDs (fast membership test).
const _idMap = new Map();
const _usedReplacements = new Set();
let _sweepStats = {}; // per-run branch counters, surfaced by __bygoneDiag
let _lastHomeChannelPromoPruned = 0;
let _lastHomeChannelPromoLeft = 0;
let _responseSeen = new Set();
let _displayedIdsCache = null;
let _poolIdsSet = new Set();
const _keptNaturalIds = new Set();
let _poolCursor = 0;
function startResponseScope() {
_responseSeen = new Set();
_displayedIdsCache = null;
}
// Build a set of every videoId currently visible in the page DOM.
// Cached for the lifetime of one response/sweep scope.
function _getDisplayedIds() {
if (_displayedIdsCache) return _displayedIdsCache;
const ids = new Set();
document.querySelectorAll('a[href*="/watch"]').forEach(a => {
const h = a.getAttribute('href') || '';
const m = h.match(/[?&]v=([A-Za-z0-9_-]+)/);
if (m) ids.add(m[1]);
});
_displayedIdsCache = ids;
return ids;
}
// ---- mapVideo: the heart of the system ---------------------
// opts.dedupInResponse:
// true → enforce per-response uniqueness via _responseSeen.
// opts.avoidDisplayedOnFreshPick:
// true → fresh picks avoid videos already visible.
// (Stable mappings are NEVER rejected for being on-screen.
// Returning the same answer for the same origId is what keeps
// cards stable across sweep ticks — rejecting it on screen
// grounds reintroduces the v189-era once-per-second rotation.)
function mapVideo(origId, opts) {
if (!videoPool.length) return null;
const dedupInResponse = !!(opts && opts.dedupInResponse);
const avoidDisplayed = !!(opts && opts.avoidDisplayedOnFreshPick);
const displayed = (dedupInResponse || avoidDisplayed) ? _getDisplayedIds() : null;
const stableClash = (id) => dedupInResponse && _responseSeen.has(id);
const freshClash = (id) =>
(dedupInResponse && _responseSeen.has(id)) ||
(displayed && displayed.has(id));
// origId is already a pool video → return as-is.
if (origId && _poolIdsSet.has(origId)) {
const found = videoPool.find(p => p.id === origId);
if (found) {
_usedReplacements.add(found.id);
if (dedupInResponse) _responseSeen.add(found.id);
return found;
}
}
// Stable mapping for this origId — never rejected for on-screen.
if (origId && _idMap.has(origId)) {
const stable = _idMap.get(origId);
if (!stableClash(stable.id)) {
_usedReplacements.add(stable.id);
if (dedupInResponse) _responseSeen.add(stable.id);
return stable;
}
}
// Fresh pick. Avoid videos already mapped to some other origId,
// otherwise two origIds end up with the same replacement and we
// see the same video twice in the grid (v203 fix).
const alreadyMapped = new Set();
for (const v of _idMap.values()) if (v && v.id) alreadyMapped.add(v.id);
// 1) not-mapped AND not-clashing
// 2) not-mapped (may clash)
// 3) not-clashing (pool saturated — allow remap, avoid on-screen/response dup)
// 4) anything (fully saturated — round-robin repeat)
// Tiers 3 & 4 are what stop a modern video leaking through: once
// every pool video is already mapped to some other slot (pool
// smaller than the number of card slots — e.g. 109 pool vs 360
// cards), tiers 1-2 fail. Returning null there left the ORIGINAL
// present-day video on the card. A repeated era video is always
// preferable to a modern hole, so never return null while the pool
// is non-empty.
let v = _findVideo(c => !alreadyMapped.has(c.id) && !freshClash(c.id));
if (!v) v = _findVideo(c => !alreadyMapped.has(c.id));
if (!v) v = _findVideo(c => !freshClash(c.id));
if (!v) v = _findVideo(() => true);
if (!v) return null;
if (origId) _idMap.set(origId, v);
_usedReplacements.add(v.id);
if (dedupInResponse) _responseSeen.add(v.id);
_maybeFetchMore();
return v;
}
function _findVideo(pred) {
for (let i = 0; i < videoPool.length; i++) {
const cand = videoPool[(_poolCursor + i) % videoPool.length];
if (pred(cand)) {
_poolCursor = (_poolCursor + i + 1) % videoPool.length;
return cand;
}
}
return null;
}
// ---- Pool management --------------------------------------
function hydrateFromLocalStorage() {
try {
const raw = localStorage.getItem(POOL_LS_KEY);
if (!raw) return false;
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.videos) && parsed.videos.length) {
videoPool = parsed.videos;
_poolIdsSet = new Set(videoPool.map(v => v.id));
active = true;
_firePoolReady();
return true;
}
} catch (_) {}
return false;
}
function _persistPool() {
try {
localStorage.setItem(POOL_LS_KEY, JSON.stringify({
videos: videoPool,
savedAt: Date.now(),
}));
} catch (_) {}
}
const _VALID_VID = /^[A-Za-z0-9_-]{11}$/;
function _pruneMappingStateForPool() {
for (const [origId, mapped] of _idMap) {
if (!mapped || !mapped.id || !_poolIdsSet.has(mapped.id)) _idMap.delete(origId);
}
for (const id of Array.from(_usedReplacements)) {
if (!_poolIdsSet.has(id)) _usedReplacements.delete(id);
}
_displayedIdsCache = null;
}
function setVideos(videos) {
if (!Array.isArray(videos) || !videos.length) return;
const _seen = new Set();
videoPool = videos.filter(v => {
if (!v || !v.id || !_VALID_VID.test(v.id) || _seen.has(v.id)) return false;
_seen.add(v.id);
return true;
});
if (!videoPool.length) return;
_poolIdsSet = new Set(videoPool.map(v => v.id));
_poolCursor = 0;
_pruneMappingStateForPool();
active = true;
_persistPool();
_firePoolReady();
}
function appendVideos(videos) {
if (!Array.isArray(videos) || !videos.length) return 0;
let added = 0;
for (const v of videos) {
if (!v || !v.id || !_VALID_VID.test(v.id)) continue;
if (_poolIdsSet.has(v.id)) continue;
videoPool.push(v);
_poolIdsSet.add(v.id);
added++;
}
if (added > 0) _persistPool();
return added;
}
// Lazy infinite-scroll wiring. App.init calls setLazyFetcher with a
// function that returns the next page of videos. When the pool hits
// 70% used, _maybeFetchMore fires the fetcher in the background.
let _lazyFetcher = null;
let _fetchingMore = false;
let _morePage = 2;
function setLazyFetcher(fn) { _lazyFetcher = fn; }
function _maybeFetchMore() {
if (_fetchingMore || !_lazyFetcher || !videoPool.length) return;
if (_usedReplacements.size / videoPool.length < 0.7) return;
_fetchingMore = true;
const page = _morePage++;
Promise.resolve()
.then(() => _lazyFetcher(page))
.then(more => { if (more && more.length) appendVideos(more); })
.catch(() => { _morePage--; })
.then(() => { _fetchingMore = false; });
}
function isInnerTubeUrl(url) {
return !!url && url.indexOf('/youtubei/v1/') !== -1;
}
// ---- Renderer mutation -------------------------------------
const RENDERER_KEYS = [
'videoRenderer',
'gridVideoRenderer',
'compactVideoRenderer',
'lockupViewModel',
];
// ---- "Keep naturally-old videos" --------------------------
// If YouTube's own algorithm surfaces a video that was uploaded
// at or before the set date, leave it ALONE — YouTube's recs for
// genuinely old content are good and worth keeping. Only videos
// newer than the set date get replaced.
const _REL_RE = /(\d+)\s*(year|month|week|day|hour|minute|second)s?\s+ago/i;
function _relTextAtOrBeforeSet(relText) {
if (!relText) return false;
let setDateStr;
try { setDateStr = Store.getCurrentDate(); } catch { return false; }
if (!setDateStr) return false;
const setDate = new Date(setDateStr);
if (isNaN(setDate.getTime())) return false;
const approx = DateHelper.approxPublishDate(relText);
if (!approx) return false;
return approx.getTime() <= setDate.getTime();
}
function _rendererRelText(r) {
const p = r.publishedTimeText;
if (!p) return '';
return p.simpleText || (p.runs && p.runs[0] && p.runs[0].text) || '';
}
function _lockupRelText(r) {
try {
const rows = r.metadata?.lockupMetadataViewModel?.metadata
?.contentMetadataViewModel?.metadataRows || [];
for (const row of rows) {
for (const part of (row.metadataParts || [])) {
const txt = (part && part.text && part.text.content) || '';
if (_REL_RE.test(txt)) return txt;
}
}
} catch {}
return '';
}
// Overwrite the date metadataPart of a lockup in place.
function _rewriteLockupDate(r, newText) {
try {
const rows = r.metadata?.lockupMetadataViewModel?.metadata
?.contentMetadataViewModel?.metadataRows || [];
for (const row of rows) {
for (const part of (row.metadataParts || [])) {
const txt = (part && part.text && part.text.content) || '';
if (_REL_RE.test(txt)) { part.text.content = newText; return; }
}
}
} catch {}
}
let _replaceCount = 0;
function replaceRenderer(r) {
if (!r || typeof r !== 'object') return;
const origId = r.videoId || '';
if (!origId) return;
if (_poolIdsSet.has(origId)) return;
const _keepRel = _rendererRelText(r);
console.log('[bygone] renderer', origId, 'date="' + _keepRel + '"', _keepRel ? ('old=' + _relTextAtOrBeforeSet(_keepRel)) : 'NO-DATE');
if (_relTextAtOrBeforeSet(_keepRel)) {
_keptNaturalIds.add(origId);
try {
const sd = Store.getCurrentDate();
if (sd && r.publishedTimeText) {
const nd = DateHelper.recalcForFeed(_keepRel, sd, origId);
if (nd) {
if (r.publishedTimeText.simpleText !== undefined) r.publishedTimeText.simpleText = nd;
else if (r.publishedTimeText.runs) r.publishedTimeText.runs = [{ text: nd }];
}
}
} catch (_) {}
return;
}
const v = mapVideo(origId, { dedupInResponse: true, avoidDisplayedOnFreshPick: true });
if (!v) return;
const vid = v.id;
_replaceCount++;
r.videoId = vid;
r.title = { simpleText: v.title || '', runs: [{ text: v.title || '' }] };
r.thumbnail = { thumbnails: [
{ url: 'https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg', width: 480, height: 360 },
{ url: 'https://i.ytimg.com/vi/' + vid + '/mqdefault.jpg', width: 320, height: 180 },
] };
if (v.viewCountFormatted || v.viewCount) {
const viewsText = v.viewCountFormatted || (v.viewCount + ' views');
r.viewCountText = { simpleText: viewsText };
r.shortViewCountText = { simpleText: viewsText };
}
// Re-relativize date so "11 years ago" doesn't appear for a video
// that's RECENT relative to the simulated time-machine date.
let dateStr = v.relativeDate || '';
try {
const setDate = Store.getCurrentDate();
if (setDate && dateStr) {
dateStr = DateHelper.recalcForFeed(dateStr, setDate, vid) || dateStr;
}
} catch (_) {}
if (dateStr) r.publishedTimeText = { simpleText: dateStr };
const dur = v.duration || '';
r.lengthText = { simpleText: dur, accessibility: { accessibilityData: { label: dur } } };
r.lengthSeconds = v.lengthSeconds || 0;
const chan = v.channel || '';
const cid = v.channelId || '';
const byline = { runs: [{
text: chan,
navigationEndpoint: cid ? {
browseEndpoint: { browseId: cid, canonicalBaseUrl: '/channel/' + cid },
commandMetadata: { webCommandMetadata: { url: '/channel/' + cid, webPageType: 'WEB_PAGE_TYPE_CHANNEL' } },
} : undefined,
}] };
if (chan) {
r.shortBylineText = byline;
r.longBylineText = byline;
r.ownerText = byline;
}
r.navigationEndpoint = {
watchEndpoint: { videoId: vid },
commandMetadata: { webCommandMetadata: { url: '/watch?v=' + vid, webPageType: 'WEB_PAGE_TYPE_WATCH' } },
};
r.thumbnailOverlays = dur ? [{
thumbnailOverlayTimeStatusRenderer: {
text: { simpleText: dur, accessibility: { accessibilityData: { label: dur } } },
style: 'DEFAULT',
},
}] : [];
// Strip fields V3 might use to re-derive originals.
delete r.descriptionSnippet;
delete r.detailedMetadataSnippets;
delete r.richThumbnail;
delete r.menu;
delete r.badges;
delete r.ownerBadges;
}
// Modern InnerTube viewModel format (V3 home feed uses this).
function replaceLockupViewModel(r) {
if (!r || typeof r !== 'object') return;
const origId = r.contentId || '';
if (!origId) return;
if (_poolIdsSet.has(origId)) return;
const _keepRel = _lockupRelText(r);
console.log('[bygone] lockup', origId, 'date="' + _keepRel + '"', _keepRel ? ('old=' + _relTextAtOrBeforeSet(_keepRel)) : 'NO-DATE');
if (_relTextAtOrBeforeSet(_keepRel)) {
_keptNaturalIds.add(origId);
try {
const sd = Store.getCurrentDate();
if (sd) {
const nd = DateHelper.recalcForFeed(_keepRel, sd, origId);
if (nd) _rewriteLockupDate(r, nd);
}
} catch (_) {}
return;
}
const v = mapVideo(origId, { dedupInResponse: true, avoidDisplayedOnFreshPick: true });
if (!v) return;
const vid = v.id;
_replaceCount++;
const thumbUrl = 'https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg';
r.contentId = vid;
r.contentType = 'LOCKUP_CONTENT_TYPE_VIDEO';
if (!r.metadata) r.metadata = {};
if (!r.metadata.lockupMetadataViewModel) r.metadata.lockupMetadataViewModel = {};
const meta = r.metadata.lockupMetadataViewModel;
meta.title = { content: v.title || '', styleRuns: [] };
let dateStr = v.relativeDate || '';
try {
const setDate = Store.getCurrentDate();
if (setDate && dateStr) dateStr = DateHelper.recalcForFeed(dateStr, setDate, vid) || dateStr;
} catch (_) {}
const viewsText = v.viewCountFormatted || (v.viewCount ? v.viewCount + ' views' : '');
meta.metadata = { contentMetadataViewModel: { metadataRows: [
{ metadataParts: [{
text: {
content: v.channel || '',
commandRuns: v.channelId ? [{
startIndex: 0,
length: (v.channel || '').length,
onTap: { innertubeCommand: {
browseEndpoint: { browseId: v.channelId, canonicalBaseUrl: '/channel/' + v.channelId },
commandMetadata: { webCommandMetadata: { url: '/channel/' + v.channelId, webPageType: 'WEB_PAGE_TYPE_CHANNEL' } },
} },
}] : [],
},
}] },
{ metadataParts: [
viewsText ? { text: { content: viewsText } } : null,
dateStr ? { text: { content: dateStr } } : null,
].filter(Boolean) },
] } };
r.contentImage = { thumbnailViewModel: {
image: { sources: [{ url: thumbUrl, width: 480, height: 360 }] },
overlays: v.duration ? [{
thumbnailOverlayBadgeViewModel: { thumbnailBadges: [{
thumbnailBadgeViewModel: { text: v.duration, badgeStyle: 'THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT' },
}] },
}] : [],
} };
r.rendererContext = { commandContext: { onTap: { innertubeCommand: {
watchEndpoint: { videoId: vid },
commandMetadata: { webCommandMetadata: { url: '/watch?v=' + vid, webPageType: 'WEB_PAGE_TYPE_WATCH' } },
} } } };
}
// Walk a parsed JSON response and replace every renderer in place.
function walkAndReplace(node, depth) {
if (!node || depth > 40 || typeof node !== 'object') return;
if (Array.isArray(node)) {
for (let i = 0; i < node.length; i++) walkAndReplace(node[i], depth + 1);
return;
}
for (const k in node) {
if (RENDERER_KEYS.indexOf(k) !== -1) {
const r = node[k];
if (r) {
if (k === 'lockupViewModel') { if (r.contentId) replaceLockupViewModel(r); }
else if (r.videoId) replaceRenderer(r);
}
}
walkAndReplace(node[k], depth + 1);
}
}
// ---- Watch-page video date re-relativization --------------
// Data-level: rewrites the big "X ago" date under the player so
// it reads relative to the sim date. COMMENT filtering is NOT
// done here — comments lazy-load in continuation responses V3
// re-renders from caches the interceptor never sees, so the
// comment filter is a DOM sweep instead (see _commentSweep).
function _relTextOf(obj) {
if (!obj) return '';
if (typeof obj === 'string') return obj;
if (obj.simpleText) return obj.simpleText;
if (obj.content) return obj.content;
if (Array.isArray(obj.runs)) return obj.runs.map(r => (r && r.text) || '').join('');
return '';
}
function _rewriteWatchDate(vpir, setDateStr) {
const rd = vpir.relativeDateText;
const raw = _relTextOf(rd);
if (!raw) return;
const approx = DateHelper.approxPublishDate(raw);
if (!approx) return;
const newText = DateHelper.recalcRelative(raw, setDateStr);
if (rd.simpleText !== undefined) rd.simpleText = newText;
else if (Array.isArray(rd.runs)) rd.runs = [{ text: newText }];
}
function _walkWatchDate(node, depth, setDateStr) {
if (!node || depth > 45 || typeof node !== 'object') return;
if (Array.isArray(node)) {
for (let i = 0; i < node.length; i++) _walkWatchDate(node[i], depth + 1, setDateStr);
return;
}
if (node.videoPrimaryInfoRenderer) {
try { _rewriteWatchDate(node.videoPrimaryInfoRenderer, setDateStr); } catch (_) {}
}
for (const k in node) _walkWatchDate(node[k], depth + 1, setDateStr);
}
function _processCommentsAndDates(json) {
if (!json || typeof json !== 'object') return;
// Browse responses carry no watch date and are the biggest payloads.
if (json.contents && json.contents.twoColumnBrowseResultsRenderer) return;
let setDateStr = null;
try { setDateStr = Store.getCurrentDate(); } catch (_) {}
if (!setDateStr) return;
if (isNaN(new Date(setDateStr).getTime())) return;
_walkWatchDate(json, 0, setDateStr);
}
// ---- Response patching -------------------------------------
// Patch Response.prototype.json + .text. Patches that fail to
// produce a usable response MUST NOT reject — that crashes V3's
// render. Always return either the modified or original body.
function waitForPool(timeoutMs) {
if (active && videoPool.length) return Promise.resolve(true);
return new Promise(resolve => {
const start = Date.now();
const tick = () => {
if (active && videoPool.length) return resolve(true);
if (Date.now() - start > timeoutMs) return resolve(false);
setTimeout(tick, 30);
};
tick();
});
}
// (moved to top — see _poolReadyCbs near pool declarations)
function rewriteJsonText(text) {
if (!active || !videoPool.length || !text || text.length < 20) return text;
try {
const json = JSON.parse(text);
_replaceCount = 0;
startResponseScope();
walkAndReplace(json, 0);
try { _processCommentsAndDates(json); } catch (_) {}
return JSON.stringify(json);
} catch (_) { return text; }
}
// Search responses must NOT be touched. The search hijack puts
// `before:YYYY-MM-DD` in the search URL — YouTube itself does the
// date filter and returns genuine, query-relevant videos from
// before the set date. If we walkAndReplace those, the user sees
// OUR pool videos (random replacements) instead of actual search
// results matching their query. Skip /search; keep /browse and
// /next (those are the home feed + watch-page sidebar — both
// need replacement to keep modern videos off the page).
function _isChannelPage() {
return /^\/(channel\/|@|c\/|user\/)/.test(location.pathname);
}
function _shouldReplace(url) {
if (!isInnerTubeUrl(url)) return false;
if (url.indexOf('/youtubei/v1/search') !== -1) return false;
if (url.indexOf('/youtubei/v1/player') !== -1) return false;
if (url.indexOf('/youtubei/v1/notification') !== -1) return false;
if (url.indexOf('/youtubei/v1/reel') !== -1) return false;
if (_isChannelPage() && url.indexOf('/youtubei/v1/browse') !== -1) return false;
return true;
}
function install() {
try {
const origRespJson = Response.prototype.json;
const origRespText = Response.prototype.text;
Response.prototype.json = async function () {
const url = this.url || '';
if (!_shouldReplace(url)) return origRespJson.call(this);
const ready = await waitForPool(8000);
if (!ready || !active || !videoPool.length) return origRespJson.call(this);
// Read body ONCE. If we throw here, V3's render dies — wrap
// the mutation in try/catch and always return the parsed body.
const json = await origRespJson.call(this);
try {
_replaceCount = 0;
startResponseScope();
walkAndReplace(json, 0);
try { _processCommentsAndDates(json); } catch (_) {}
// /next responses populate the watch-page sidebar; the
// initial paint can race ahead of our walk landing in
// the DOM, so kick the sidebar sweep a few times.
if (url.indexOf('/youtubei/v1/next') !== -1) {
setTimeout(() => { try { _sidebarSweep(); } catch (_) {} }, 100);
setTimeout(() => { try { _sidebarSweep(); } catch (_) {} }, 500);
setTimeout(() => { try { _sidebarSweep(); } catch (_) {} }, 1500);
}
} catch (_) {}
return json;
};
Response.prototype.text = async function () {
const url = this.url || '';
if (!_shouldReplace(url)) return origRespText.call(this);
const ready = await waitForPool(8000);
if (!ready || !active || !videoPool.length) return origRespText.call(this);
const text = await origRespText.call(this);
return rewriteJsonText(text);
};
} catch (e) { console.warn('[bygone] Response patch failed', e); }
// Belt-and-suspenders fetch patch: if anything reads the body via
// a Response we hand back (rather than .json()/.text() on the
// network Response), this still rewrites it.
const origFetch = window.fetch ? window.fetch.bind(window) : null;
if (origFetch) {
window.fetch = function (input, init) {
const url = typeof input === 'string' ? input : (input && input.url) || '';
const p = origFetch(input, init);
if (!_shouldReplace(url)) return p;
return p.then(response => {
if (!response || !response.ok) return response;
return waitForPool(8000).then(ready => {
if (!ready) return response;
return response.clone().text().then(text => {
const out = rewriteJsonText(text);
if (out === text) return response;
return new Response(out, {
status: response.status,
statusText: response.statusText,
headers: new Headers(response.headers),
});
}).catch(() => response);
});
});
};
}
}
// Store the original fetch so YouTubeAPI can use it without our patch
// re-entering (would create a feedback loop where our own InnerTube
// calls get their videos populated back into our pool).
let _origFetch = null;
try {
_origFetch = window.fetch ? window.fetch.bind(window) : null;
install();
} catch (e) { console.warn('[bygone] install failed', e); }
hydrateFromLocalStorage();
// ----------------------------------------------------------
// HIDE UN-REPLACED CARDS
// ----------------------------------------------------------
// Cards default to `visibility: hidden`. The sweep marks each card
// with `data-bygone-ok="1"` once it confirms the card shows a pool
// video, and `_rewriteCard` sets `data-bygone-swept="<id>"` on cards
// it freshly rewrites. CSS shows only those two attribute states.
//
// Effect: until our sweep verifies a card OR our interceptor's
// _rewriteCard touches it, you see empty space rather than a
// modern (un-replaced) YouTube video.
function _injectHideCss() {
try {
if (document.getElementById('bygone-hide-css')) return;
const s = document.createElement('style');
s.id = 'bygone-hide-css';
s.textContent = `
ytd-rich-item-renderer,
ytd-grid-video-renderer,
ytd-video-renderer,
ytd-compact-video-renderer,
yt-lockup-view-model,
.yt-lockup-view-model,
.lohp-large-shelf-container,
.lohp-medium-shelf {
visibility: hidden !important;
}
[data-bygone-ok="1"],
[data-bygone-swept] {
visibility: visible !important;
}
[data-bygone-ok="1"] .yt-lockup,
[data-bygone-swept] .yt-lockup,
[data-bygone-ok="1"] .context-data-item.yt-lockup,
[data-bygone-swept] .context-data-item.yt-lockup,
[data-bygone-ok="1"] .yt-lockup-content,
[data-bygone-swept] .yt-lockup-content,
[data-bygone-ok="1"] .yt-lockup-title,
[data-bygone-swept] .yt-lockup-title,
[data-bygone-ok="1"] .yt-lockup-title a,
[data-bygone-swept] .yt-lockup-title a,
[data-bygone-ok="1"] .lohp-media-object-content,
[data-bygone-swept] .lohp-media-object-content,
[data-bygone-ok="1"] .lohp-video-link,
[data-bygone-swept] .lohp-video-link {
visibility: visible !important;
}
/* Kill the load-time flash of YouTube's logged-in
"What to Watch" feed (multirow shelves + shelf-grid).
Gated with :has() on the genuine 2013 LOHP grid so it
only fires when the LOHP is present — the same condition
as the JS removal in _cleanupHomeSpaArtifacts. display:none
(not visibility) so no blank space is reserved while the
sweep removes them from the DOM. The :has() wrapper rules
collapse the whole feed row; the bare-shelf rules are the
fallback if the row wrapper class differs. */
html:has(.lohp-newspaper-shelf, .lohp-large-shelf-container) .feed-item-container:has(.multirow-shelf),
html:has(.lohp-newspaper-shelf, .lohp-large-shelf-container) .feed-item-container:has(.yt-shelf-grid),
html:has(.lohp-newspaper-shelf, .lohp-large-shelf-container) .multirow-shelf,
html:has(.lohp-newspaper-shelf, .lohp-large-shelf-container) .yt-shelf-grid {
display: none !important;
}
ytd-watch-flexy #primary,
ytd-watch-flexy #primary-inner,
ytd-watch-flexy #above-the-fold,
ytd-watch-flexy #owner,
ytd-watch-flexy #meta,
ytd-watch-flexy #info,
ytd-watch-flexy #info-contents,
ytd-watch-flexy #upload-info,
ytd-watch-flexy #menu,
ytd-watch-flexy #menu-container,
ytd-watch-flexy #top-level-buttons-computed,
ytd-watch-flexy ytd-watch-metadata,
ytd-watch-flexy ytd-video-owner-renderer,
#watch7-main-container,
#watch7-content,
#watch-header,
#watch-description,
.watch-main-col,
.watch-info,
.watch-actions,
.watch-action-buttons,
.watch-extras-section {
visibility: visible !important;
}
[data-bygone-swept] img.bygone-thumb,
[data-bygone-ok] img.bygone-thumb {
display: inline-block !important;
visibility: visible !important;
/* Size to the card's own slot, never a fixed 196x110.
A fixed width overflowed the 185px grid/shelfslider
slots (card pushed offscreen) and the fixed height
grew the row (empty space below). width:100% fits the
slot, max-width caps it in wide containers, and
aspect-ratio drives height so nothing overflows. */
width: 100% !important;
max-width: 196px !important;
height: auto !important;
aspect-ratio: 16 / 9 !important;
object-fit: cover;
vertical-align: top;
margin-right: 8px;
margin-bottom: 4px;
}
.html5-video-player [data-bygone-player-card-blocked="1"],
#movie_player [data-bygone-player-card-blocked="1"],
.ytp-autonav-endscreen-countdown-container,
.ytp-autonav-endscreen-countdown-overlay {
display: none !important;
}
[data-bygone-comment-hidden] {
display: none !important;
height: 0 !important;
max-height: 0 !important;
overflow: hidden !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
min-height: 0 !important;
visibility: hidden !important;
}
.bygone-meta {
display: block !important;
visibility: visible !important;
color: #aaa;
font-size: 11px;
margin-top: 2px;
}
.bygone-meta .yt-user-name {
color: #4e7ab5;
}
.bygone-meta .view-count,
.bygone-meta .content-item-time-created {
color: #999;
}
`;
(document.head || document.documentElement).appendChild(s);
} catch (_) {}
}
_injectHideCss();
// Re-inject if V3 strips our <style> element.
setInterval(() => { if (!document.getElementById('bygone-hide-css')) _injectHideCss(); }, 3000);
// ============================================================
// SWEEP — for cards V3 paints from cached renderer data
// (LOHP featured block, show-more continuations, sidebar fragments
// V3 reads from its own in-memory cache rather than the response).
// ============================================================
const _poolIdSet = () => _poolIdsSet;
const _HOME_ROOT_SEL = [
'.lohp-newspaper-shelf',
'#c3-content-items',
'#browse-items-primary',
'#feed',
'#feed-list',
'.feed-list',
'.channels-browse-content-grid',
'.expanded-shelf-content-list',
'.yt-shelf-grid',
'.yt-rich-grid',
'ytd-browse[page-subtype="home"] #contents',
'ytd-rich-grid-renderer #contents'
].join(',');
const _WATCH_CHROME_SEL = [
'ytd-watch-flexy',
'#watch7-container',
'#watch7-main-container',
'#watch7-content',
'#watch7-sidebar',
'#watch7-sidebar-contents',
'#watch7-sidebar-modules',
'#watch-header',
'#watch-description',
'#watch-discussion',
'.watch-main-col',
'.watch-sidebar',
'.watch-card',
'#above-the-fold',
'#owner',
'#meta'
].join(',');
const _STALE_HOME_CARD_SEL = [
'ytd-rich-item-renderer',
'ytd-video-renderer',
'ytd-compact-video-renderer',
'yt-lockup-view-model',
'.yt-lockup-view-model',
'.video-list-item',
'.feed-item-container .yt-lockup',
'.context-data-item.yt-lockup',
'.yt-shelf-grid-item',
'.yt-uix-shelfslider-item',
'.expanded-shelf-content-item',
'.channels-content-item'
].join(',');
const _HOME_CHANNEL_PROMO_SEL = [
'.channels-content-item',
'.yt-lockup-channel',
'.yt-lockup.yt-lockup-channel',
'.context-data-item.yt-lockup',
'yt-lockup-view-model',
'ytd-channel-renderer'
].join(',');
const _CHANNEL_LINK_SEL = [
'a[href^="/channel/"]',
'a[href^="/user/"]',
'a[href^="/c/"]',
'a[href^="/@"]'
].join(',');
function _isHomeLikePath() {
const p = location.pathname;
return p === '/' || p === '' || p === '/feed/trending';
}
function _homeRoots(root) {
const scope = root || document;
const out = [];
const add = el => { if (el && out.indexOf(el) === -1) out.push(el); };
if (scope.matches && scope.matches(_HOME_ROOT_SEL)) add(scope);
if (scope.querySelectorAll) scope.querySelectorAll(_HOME_ROOT_SEL).forEach(add);
return out.filter(el => !(el.closest && el.closest(_WATCH_CHROME_SEL)));
}
function _insideAnyRoot(el, roots) {
for (const root of roots || []) {
if (root === el || (root.contains && root.contains(el))) return true;
}
return false;
}
function _insideWatchChrome(el) {
return !!(el && el.closest && el.closest(_WATCH_CHROME_SEL));
}
function _cleanupHomeSpaArtifacts() {
if (!_isHomeLikePath()) return;
// LOGGED-IN "WHAT TO WATCH" FEED REMOVAL.
// For a signed-in user, YouTube/V3 render the modern logged-in home
// feed (`.multirow-shelf` recommendation shelves + `.yt-shelf-grid`
// + shelfslider lockups) ON TOP of the genuine 2013 LOHP newspaper
// grid. Those shelves are not part of the time-machine homepage:
// `.yt-shelf-grid` is a home root, so the sweep fills each lockup
// with a pool video and reveals it, and they multiply as YouTube
// lazy-loads more shelves (1 -> 30 -> ...). When the real LOHP grid
// is present, strip the competing feed shelves wholesale (header and
// all) BEFORE the sweep runs, so they are never swept/revealed.
// Guarded so a container holding any LOHP markup is never removed,
// and runs every sweep tick to catch lazy-loaded shelves.
const LOHP_SEL = '.lohp-newspaper-shelf, .lohp-large-shelf-container, ' +
'.lohp-medium-shelf, .lohp-media-object';
if (document.querySelector(LOHP_SEL)) {
const kill = new Set();
// Climb to the OUTERMOST feed-row wrapper so removal takes the
// whole row — leaving the empty `li.feed-item-container` (which
// keeps its margin) behind is what produced blank gaps between
// the LOHP shelves. Stop at the shared feed list and never climb
// into a wrapper that holds LOHP markup.
const WRAP_RE = /feed-item|shelf-wrapper|multirow-shelf|yt-shelf-grid|expander/i;
document.querySelectorAll('.multirow-shelf, .yt-shelf-grid').forEach(shelf => {
if (shelf.querySelector(LOHP_SEL)) return;
if (shelf.closest('#masthead, #guide, #guide-container, #wbt-panel')) return;
let target = shelf;
let p = shelf.parentElement;
while (p && p !== document.body) {
const cls = (p.className && p.className.toString) ? p.className.toString() : '';
if (!WRAP_RE.test(cls)) break;
if (p.querySelector(LOHP_SEL)) break;
target = p;
p = p.parentElement;
}
kill.add(target);
});
kill.forEach(el => {
try { el.remove(); } catch (_) {
try { el.style.setProperty('display', 'none', 'important'); } catch (__) {}
}
});
}
const roots = _homeRoots(document);
let channelPromoPruned = 0;
let channelPromoLeft = 0;
document.querySelectorAll(_WATCH_CHROME_SEL).forEach(el => {
if (_insideAnyRoot(el, roots)) return;
try { el.remove(); } catch (_) {
try { el.style.setProperty('display', 'none', 'important'); } catch (__) {}
}
});
if (roots.length) {
document.querySelectorAll(_STALE_HOME_CARD_SEL).forEach(el => {
if (_insideAnyRoot(el, roots)) return;
if (!el.querySelector || !el.querySelector('a[href*="/watch"]')) return;
if (el.closest && el.closest('#masthead, #guide, #guide-container, #wbt-panel')) return;
try { el.remove(); } catch (_) {
try { el.style.setProperty('display', 'none', 'important'); } catch (__) {}
}
});
document.querySelectorAll(_HOME_CHANNEL_PROMO_SEL).forEach(el => {
if (!_insideAnyRoot(el, roots)) return;
if (_insideWatchChrome(el)) return;
if (el.closest && el.closest('#masthead, #guide, #guide-container, #wbt-panel')) return;
if (el.closest && el.closest('[data-bygone-ok], [data-bygone-swept], [data-bygone-keep]')) return;
if (!el.querySelector || el.querySelector('a[href*="/watch"]')) return;
if (!el.querySelector(_CHANNEL_LINK_SEL)) return;
// Home-page channel recommendation/promo modules (for example
// brand intro cards) are not video cards, so they cannot be
// rewritten into pool videos. Remove them instead of revealing
// one native modern recommendation.
try { el.remove(); channelPromoPruned++; } catch (_) {
try { el.style.setProperty('display', 'none', 'important'); channelPromoPruned++; } catch (__) {}
}
});
document.querySelectorAll(_HOME_CHANNEL_PROMO_SEL).forEach(el => {
if (!_insideAnyRoot(el, roots)) return;
if (_insideWatchChrome(el)) return;
if (el.closest && el.closest('#masthead, #guide, #guide-container, #wbt-panel')) return;
if (el.closest && el.closest('[data-bygone-ok], [data-bygone-swept], [data-bygone-keep]')) return;
if (!el.querySelector || el.querySelector('a[href*="/watch"]')) return;
if (!el.querySelector(_CHANNEL_LINK_SEL)) return;
const cs = getComputedStyle(el);
if (cs.display !== 'none' && cs.visibility !== 'hidden') channelPromoLeft++;
});
}
_lastHomeChannelPromoPruned = channelPromoPruned;
_lastHomeChannelPromoLeft = channelPromoLeft;
}
let _homeSpaFixTimer = null;
function _burstHomeSpaFix() {
if (_homeSpaFixTimer) { clearInterval(_homeSpaFixTimer); _homeSpaFixTimer = null; }
let elapsed = 0;
const run = () => {
try { _cleanupHomeSpaArtifacts(); } catch (_) {}
try { _sweep(); } catch (_) {}
};
run();
_homeSpaFixTimer = setInterval(() => {
run();
elapsed += 150;
if (elapsed >= 4500) {
clearInterval(_homeSpaFixTimer);
_homeSpaFixTimer = null;
}
}, 150);
}
function _nextDomVideoFor(origId) {
// dedupInResponse: false — the sweep uses a LOCAL `seenOnPage`
// Set (built fresh from the live DOM each sweep) for within-
// pass dedup. avoidDisplayedOnFreshPick uses our displayed
// cache so the FIRST mapping for any origId picks something
// not already on screen. Future ticks for the same origId hit
// the stable path and never touch displayed state.
return mapVideo(origId, {
dedupInResponse: false,
avoidDisplayedOnFreshPick: true,
});
}
function _freshDomVideoAvoiding(avoidIds) {
return _findVideo(v => v && v.id && !avoidIds.has(v.id));
}
function _findCards(root) {
// Outer shelf containers AND inner cards are BOTH kept (no
// innermost-only filter). V3's featured-top shelf has the
// BIG promoted card's img/title/link directly inside
// `.lohp-large-shelf-container` with no per-card wrapper —
// dropping the outer would leave the big card unmatched and
// its assets get partially rewritten by stray sub-element
// matches (title from one video, thumb from another, click
// from a third). The sweep handles the nesting by sorting
// INNERMOST FIRST and scoping each card's rewrite to its
// OWN subtree (excluding descendants that are themselves
// matched cards) — so the big card's own assets get one
// video and the 3 sidekick cards each get their own.
const sels = [
'ytd-rich-item-renderer',
'ytd-video-renderer',
'ytd-grid-video-renderer',
'ytd-compact-video-renderer',
'yt-lockup-view-model',
'.lohp-large-shelf-container',
'.lohp-medium-shelf',
'.lohp-media-object',
'.video-list-item',
'.feed-item-container .yt-lockup',
'.yt-shelf-grid-item',
'.yt-uix-shelfslider-item',
'.expanded-shelf-content-item',
'.channels-content-item',
];
const set = new Set();
for (const s of sels) root.querySelectorAll(s).forEach(el => set.add(el));
// Card must have at least one /watch link to be a video card.
let arr = Array.from(set).filter(c => c.querySelector('a[href*="/watch"]'));
if (_isHomeLikePath()) {
const roots = _homeRoots(root);
if (!roots.length) return [];
arr = arr.filter(c => _insideAnyRoot(c, roots) && !_insideWatchChrome(c));
}
// THUMB-ONLY INNER MERGE. V3's big featured card has its
// thumbnail wrapped in its own `.lohp-media-object` (re-used
// per-card class) separate from the title/click area. That
// inner wrapper has NO body text — just the thumb img.
// Normal sidekick cards have title + channel + views + date
// text (20+ chars). Dropping ONLY thumb-only inners lets the
// outer's scoped rewrite reach the big card's thumb without
// disturbing normal shelves where every inner is a real
// self-contained card.
arr = arr.filter(c => {
const cText = (c.textContent || '').trim();
if (cText.length >= 20) return true;
for (const outer of arr) {
if (outer === c || !outer.contains(c)) continue;
return false;
}
return true;
});
// INNERMOST FIRST: descendant cards sort before their ancestors.
// The sweep marks each card it processes with `data-bygone-swept`,
// and uses scoped-link/scoped-rewrite helpers so outer cards
// only touch elements not inside any inner card.
arr.sort((a, b) => a.contains(b) ? 1 : b.contains(a) ? -1 : 0);
return arr;
}
// Inner cards (matched cards that this card contains). Used by the
// sweep to scope an outer card's operations to its OWN subtree.
function _innerCardsOf(card, allCards) {
if (!allCards) return [];
const out = [];
for (const c of allCards) if (c !== card && card.contains(c)) out.push(c);
return out;
}
// Owned: not inside any inner card.
function _ownedBy(el, innerCards) {
for (const inner of innerCards) if (inner.contains(el)) return false;
return true;
}
function _primaryWatchLink(card, innerCards) {
const inner = innerCards || [];
const links = Array.from(card.querySelectorAll('a[href*="/watch"]'))
.filter(a => _ownedBy(a, inner));
for (const a of links) if (a.querySelector('img')) return a;
return links[0] || null;
}
// Debug logger. AUTO-logs a BYGONE-DIAG line every 3s for ~90s after
// load (so the transient "blank for ~30s then fills in" is captured
// without any console call — avoids the Tampermonkey sandbox boundary).
// Also exposes __bygoneDiag() / __bygoneDiag('watch') on the page window
// for manual checks. Reports matched cards HIDDEN (blank holes) vs
// visible, how many hidden ones still carry a valid /watch id, and the
// live pool/usage — distinguishing pool/mapping exhaustion (HIDDEN high,
// POOL < cards) from a thumbnail-only problem (visNoImg high).
const _diagT0 = Date.now();
function _bygoneDiagRun() {
try {
const cards = _findCards(document);
const idOf = function (el) {
const inner = _innerCardsOf(el, cards);
const a = _primaryWatchLink(el, inner) || el.querySelector('a[href*="/watch?v="]');
const m = a && (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]{11})/);
return m ? m[1] : null;
};
let vis = 0, hid = 0, hidId = 0, noImg = 0;
const allIds = new Set(), visIds = new Set(), hiddenIds = [];
const visCounts = {};
const samp = [];
for (const c of cards) {
const id = idOf(c);
if (id) allIds.add(id);
if (getComputedStyle(c).visibility === 'hidden') {
hid++;
if (id) { hidId++; hiddenIds.push(id); }
if (samp.length < 6) samp.push((c.className || '').toString().trim().slice(0, 36));
} else {
vis++;
if (id) visIds.add(id);
if (id) visCounts[id] = (visCounts[id] || 0) + 1;
const hasImg = Array.from(c.querySelectorAll('img')).some(function (im) {
return (im.getAttribute('src') || '').length > 10 && im.getBoundingClientRect().width > 10;
});
if (!hasImg) noImg++;
}
}
let dupHidden = 0;
let visDupes = 0, visDupeGroups = 0;
for (const count of Object.values(visCounts)) {
if (count > 1) {
visDupeGroups++;
visDupes += count - 1;
}
}
const distinctHidden = new Set();
for (const id of hiddenIds) { distinctHidden.add(id); if (visIds.has(id)) dupHidden++; }
const msg = 'BYGONE-DIAG t=' + Math.round((Date.now() - _diagT0) / 1000) + 's' +
' cards=' + cards.length + ' vis=' + vis + ' HIDDEN=' + hid +
' hiddenWithId=' + hidId + ' visNoImg=' + noImg +
' visDupes=' + visDupes + ' visDupeGroups=' + visDupeGroups +
' chanPromoPruned=' + _lastHomeChannelPromoPruned +
' chanPromoLeft=' + _lastHomeChannelPromoLeft +
' POOL=' + videoPool.length + ' used=' + _usedReplacements.size +
' idMap=' + _idMap.size + ' active=' + active;
const msg2 = ' distinctAll=' + allIds.size + ' distinctVisible=' + visIds.size +
' distinctHidden=' + distinctHidden.size + ' dupHidden=' + dupHidden +
' (hidden cards whose id is ALSO on a visible card)';
console.log(msg);
console.log(msg2);
console.log(' sweepBranches: ' + JSON.stringify(_sweepStats));
if (samp.length) console.log(' hidden: ' + samp.join(' / '));
return msg;
} catch (e) { console.log('BYGONE-DIAG err ' + e.message); return 'err: ' + e.message; }
}
function _bygoneDiag(mode) {
if (mode === 'watch') {
let n = 0;
const id = setInterval(function () { _bygoneDiagRun(); if (++n >= 20) clearInterval(id); }, 3000);
return 'BYGONE-DIAG watching for 60s…';
}
return _bygoneDiagRun();
}
try { window.__bygoneDiag = _bygoneDiag; } catch (_) {}
try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow) unsafeWindow.__bygoneDiag = _bygoneDiag; } catch (_) {}
try {
let _dn = 0;
const _dt = setInterval(function () { _bygoneDiagRun(); if (++_dn >= 30) clearInterval(_dt); }, 3000);
} catch (_) {}
// ---- Flight recorder (debug) ---------------------------------
// Read-only timeline of how the page settles, so a specific bad refresh
// can be replayed from its log. Captures V3 render bursts (a logging-
// ONLY MutationObserver — never writes, so no feedback loop), per-second
// state (visible / reverted-by-V3 / overlapping-thumbnail artifacts /
// pool), and the last sweep's branch counts. Dump with __bygoneLog();
// auto-prints once at ~25s.
const _rec = [];
const _recT0b = Date.now();
function _recAt() { return String(Date.now() - _recT0b).padStart(5, '0') + 'ms'; }
function _recPush(ev) { if (_rec.length < 600) _rec.push(_recAt() + ' ' + ev); }
function _recReverts() {
let reverted = 0, held = 0;
const all = document.querySelectorAll('[data-bygone-swept]');
for (const c of all) {
const want = c.getAttribute('data-bygone-swept');
const a = c.querySelector('a[href*="/watch?v="]');
const m = a && (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]{11})/);
if (m && m[1] === want) held++; else reverted++;
}
return { reverted: reverted, held: held };
}
function _recOverlaps() {
let bad = 0, scanned = 0;
const all = document.querySelectorAll('[data-bygone-ok],[data-bygone-swept]');
for (const c of all) {
if (scanned++ > 200) break;
const imgs = Array.from(c.querySelectorAll('img')).filter(function (im) {
const r = im.getBoundingClientRect();
return r.width > 40 && r.height > 30 && getComputedStyle(im).visibility !== 'hidden';
});
let hit = false;
for (let i = 0; i < imgs.length && !hit; i++) {
for (let j = i + 1; j < imgs.length; j++) {
const a = imgs[i].getBoundingClientRect(), b = imgs[j].getBoundingClientRect();
const ox = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
const oy = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
if (ox * oy > 0.5 * Math.min(a.width * a.height, b.width * b.height)) { hit = true; break; }
}
}
if (hit) bad++;
}
return bad;
}
try {
let _recN = 0;
const _recTick = setInterval(function () {
try {
const rv = _recReverts();
const ov = _recOverlaps();
const s = _sweepStats || {};
_recPush('vis=' + document.querySelectorAll('[data-bygone-ok],[data-bygone-swept]').length +
' held=' + rv.held + ' revertedByV3=' + rv.reverted + ' overlapCards=' + ov +
' chanPromoLeft=' + _lastHomeChannelPromoLeft +
' pool=' + videoPool.length +
' sweep{t:' + (s.total || 0) + ',rw:' + (s.rewritten || 0) + ',dup:' + (s.dupSkip || 0) +
',reuse:' + (s.dupReuse || 0) + ',fix:' + (s.dupFixed || 0) + ',wrap:' + (s.wrapperReveal || 0) +
',nul:' + (s.mapNull || 0) + ',noL:' + (s.noOwnedLink || 0) + ',thr:' + (s.rewriteThrew || 0) + '}');
} catch (e) { _recPush('tick-err ' + e.message); }
if (++_recN >= 30) clearInterval(_recTick);
}, 1000);
} catch (_) {}
try {
let _mut = 0, _mutCards = 0, _mutFlush = null;
const _mo = new MutationObserver(function (muts) {
for (const mu of muts) {
_mut += mu.addedNodes.length;
mu.addedNodes.forEach(function (n) {
if (n.nodeType === 1 && n.matches && n.matches('.context-data-item,.yt-lockup,.lohp-media-object,.lohp-medium-shelf,.yt-shelf-grid-item,ytd-rich-item-renderer')) _mutCards++;
});
}
if (!_mutFlush) _mutFlush = setTimeout(function () {
_recPush('V3-render burst +' + _mut + ' nodes (~' + _mutCards + ' card-ish)');
_mut = 0; _mutCards = 0; _mutFlush = null;
}, 250);
});
_mo.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(function () { try { _mo.disconnect(); } catch (_) {} }, 30000);
} catch (_) {}
function _bygoneLogDump() {
const out = 'BYGONE-LOG (' + _rec.length + ' events)\n' + _rec.join('\n');
console.log(out);
return out;
}
try { window.__bygoneLog = _bygoneLogDump; } catch (_) {}
try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow) unsafeWindow.__bygoneLog = _bygoneLogDump; } catch (_) {}
try { setTimeout(function () { _bygoneLogDump(); }, 25000); } catch (_) {}
// Read the "X ago" relative-date text shown on a DOM card.
function _cardRelText(card) {
const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT, null);
let node;
while ((node = walker.nextNode())) {
const m = (node.nodeValue || '').match(_REL_RE);
if (m) return m[0];
}
return '';
}
// True when a DOM card shows a video uploaded at/before the set
// date — i.e. a naturally-old YouTube recommendation worth keeping.
function _cardIsNaturallyOld(card) {
return _relTextAtOrBeforeSet(_cardRelText(card));
}
function _cardVideoId(card, innerCards) {
const a = _primaryWatchLink(card, innerCards);
if (!a) return '';
const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
return m ? m[1] : '';
}
// Re-relativize a kept card's "X ago" date to the set date so the
// feed stays date-consistent. Called ONCE when the card is first
// marked `data-bygone-keep` — after that the displayed date reads
// as "modern", so the sweep must skip-guard kept cards (otherwise
// they'd be re-evaluated as new and erroneously replaced).
// The actual one-shot guard is `data-bygone-redated`; if a kept
// card had no date text yet, later sweeps may try again.
function _redateKeptCard(card, vid) {
let setDate;
try { setDate = Store.getCurrentDate(); } catch { return false; }
if (!setDate || !card || !card.getAttribute) return false;
if (card.getAttribute('data-bygone-redated') === '1') return true;
const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT, null);
let node;
while ((node = walker.nextNode())) {
const v = node.nodeValue || '';
const m = v.match(_REL_RE);
if (m) {
if (!_relTextAtOrBeforeSet(m[0])) {
card.setAttribute('data-bygone-redated', '1');
return true;
}
const nd = DateHelper.recalcForFeed(m[0], setDate, vid);
if (nd && nd !== m[0]) node.nodeValue = v.replace(m[0], nd);
card.setAttribute('data-bygone-redated', '1');
return true;
}
}
return false;
}
// Idempotent channel-name update. Hard guard against writing into
// title elements (the v189 mistake where titles became channel
// names). Writes only when value differs — calling on a correct
// card produces zero mutations, so it cannot cause a sweep loop.
function _setCardChannel(card, video, innerCards) {
if (!card || !video || !video.channel) return;
const want = video.channel;
const href = video.channelId ? '/channel/' + video.channelId : null;
const inner = innerCards || [];
const owns = (el) => _ownedBy(el, inner);
const ownedFirst = (sel) => {
for (const e of card.querySelectorAll(sel)) if (owns(e)) return e;
return null;
};
// 1. Real channel anchors. Skip those wrapping an <img> (avatar).
let hitAnchor = false;
card.querySelectorAll(
'a[href^="/channel/"], a[href^="/user/"], a[href^="/c/"], a[href^="/@"]'
).forEach(link => {
if (!owns(link)) return;
if (link.querySelector('img')) return;
if ((link.textContent || '').trim() !== want) link.textContent = want;
if (href && link.getAttribute('href') !== href) link.setAttribute('href', href);
hitAnchor = true;
});
if (hitAnchor) return;
// 2. V3's 2013 sidebar/card markup: `.attribution > .g-hovercard`.
// The video title is a SEPARATE `.title` element.
let el = ownedFirst('.attribution .g-hovercard')
|| ownedFirst('.attribution .yt-user-name')
|| ownedFirst('.attribution');
if (!el) {
el = ownedFirst(
'.chan-name, .yt-user-name, .video-user-name, ' +
'#channel-name, .ytd-channel-name'
);
}
if (!el) return;
// Hard guard: never write into a title element.
if (el.matches && el.matches('[class*="title"], #video-title, h3, h3 *')) return;
const cur = el.textContent || '';
const byMatch = cur.match(/^(\s*by\s+)/i);
const desired = (byMatch ? byMatch[1] : '') + want;
if (cur.trim() !== desired.trim()) el.textContent = desired;
}
function _rewriteCard(card, video, innerCards) {
const vid = video.id;
const watchUrl = '/watch?v=' + vid;
const thumbUrl = video.thumbnail || ('https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg');
const inner = innerCards || [];
const owns = (el) => _ownedBy(el, inner);
// querySelectorAll filtered to this card's OWN subtree.
const ownedQS = (sel) =>
Array.from(card.querySelectorAll(sel)).filter(owns);
// Rewrite all /watch hrefs in this card's own subtree.
ownedQS('a').forEach(a => {
const href = a.getAttribute('href') || '';
if (href.includes('/watch')) a.setAttribute('href', watchUrl);
});
// Primary thumbnail. V3 sidebar cards have multiple imgs (avatar,
// badges) — target the one INSIDE the primary watch link first.
const primaryLink = _primaryWatchLink(card, inner);
const targetImgs = new Set();
if (primaryLink) primaryLink.querySelectorAll('img').forEach(im => {
if (owns(im)) targetImgs.add(im);
});
ownedQS('img').forEach(im => {
const cls = (im.className || '') + ' ' + (im.parentElement && im.parentElement.className || '');
if (/thumb|preview|video/i.test(cls)) targetImgs.add(im);
});
if (!targetImgs.size) {
const fi = ownedQS('img')[0];
if (fi) targetImgs.add(fi);
}
if (!targetImgs.size) {
const thumbImg = document.createElement('img');
thumbImg.src = thumbUrl;
thumbImg.alt = video.title || '';
thumbImg.className = 'bygone-thumb';
const link = ownedQS('a[href*="/watch"]')[0];
if (link) link.insertBefore(thumbImg, link.firstChild);
else card.insertBefore(thumbImg, card.firstChild);
} else {
targetImgs.forEach(img => {
img.setAttribute('src', thumbUrl);
img.removeAttribute('srcset');
if (img.hasAttribute('data-src')) img.setAttribute('data-src', thumbUrl);
if (img.hasAttribute('data-thumb')) img.setAttribute('data-thumb', thumbUrl);
img.alt = video.title || '';
img.style.visibility = 'visible';
if (img.style.display === 'none') img.style.display = '';
});
}
ownedQS('source').forEach(s => {
if (s.hasAttribute('srcset')) s.setAttribute('srcset', thumbUrl);
if (s.hasAttribute('src')) s.setAttribute('src', thumbUrl);
});
ownedQS('[style*="ytimg"], [style*="background-image"]').forEach(el => {
try { el.style.backgroundImage = 'url(' + thumbUrl + ')'; } catch (_) {}
});
ownedQS('[data-thumb], [data-thumbnail], [data-thumb-url]').forEach(el => {
if (el.hasAttribute('data-thumb')) el.setAttribute('data-thumb', thumbUrl);
if (el.hasAttribute('data-thumbnail')) el.setAttribute('data-thumbnail', thumbUrl);
if (el.hasAttribute('data-thumb-url')) el.setAttribute('data-thumb-url', thumbUrl);
});
// Title: only use existing StarTube/YouTube title nodes. Do not
// manufacture title markup; that breaks V3's layouts.
const titleSels = [
'.yt-lockup-title a[href*="/watch"]',
'h3.yt-lockup-title a[href*="/watch"]',
'a.yt-uix-tile-link[href*="/watch"]',
'a.yt-uix-sessionlink[href*="/watch"]',
'.lohp-video-link[href*="/watch"]',
'#video-title-link',
'a#video-title',
'span#video-title',
'#video-title',
'a[href*="/watch"] .yt-ui-ellipsis-wrapper',
'a[href*="/watch"] .title',
'a.related-video span.title',
'.related-video .title',
'span.title',
'.title',
];
let titleEl = null;
for (const sel of titleSels) {
const candidates = ownedQS(sel).filter(e => {
if (!e || (e.querySelector && e.querySelector('img'))) return false;
if (e.closest && e.closest('.yt-lockup-meta, .lohp-video-metadata, .attribution')) return false;
const a = e.matches && e.matches('a') ? e : (e.closest && e.closest('a'));
return !a || ((a.getAttribute('href') || '').indexOf('/watch') !== -1);
});
titleEl = candidates.find(e => (e.textContent || '').trim()) || candidates[0] || null;
if (titleEl) break;
}
if (titleEl && video.title) {
titleEl.textContent = video.title;
if (titleEl.hasAttribute('title')) titleEl.setAttribute('title', video.title);
const titleAnchor = titleEl.matches && titleEl.matches('a') ? titleEl : (titleEl.closest && titleEl.closest('a'));
if (titleAnchor) {
titleAnchor.setAttribute('href', watchUrl);
titleAnchor.setAttribute('title', video.title);
}
}
_setCardChannel(card, video, inner);
// Views + date via text-node regex (works across V3/modern markup).
// Skip text nodes that live inside an inner matched card — those
// belong to that card, not this one.
const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => owns(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT,
});
let node, viewsNode = null, dateNode = null;
while ((node = walker.nextNode())) {
const v = node.nodeValue || '';
if (!viewsNode && /\b\d[\d,.]*\s*[KkMmBb]?\s*views?\b/.test(v)) viewsNode = node;
if (!dateNode && /\b\d+\s*(year|month|week|day|hour|minute|second)s?\s+ago\b/i.test(v)) dateNode = node;
}
if (viewsNode && (video.viewCountFormatted || video.viewCount)) {
viewsNode.nodeValue = video.viewCountFormatted || (video.viewCount + ' views');
}
if (dateNode) {
let dateStr = video.relativeDate || '';
try {
const setDate = Store.getCurrentDate();
if (setDate && dateStr) dateStr = DateHelper.recalcForFeed(dateStr, setDate, vid) || dateStr;
} catch (_) {}
if (dateStr) dateNode.nodeValue = dateStr;
}
const durEl = ownedQS('.video-time, .ytd-thumbnail-overlay-time-status-renderer, .badge-shape-wiz__text')[0];
if (durEl) durEl.textContent = video.duration || '';
card.setAttribute('data-bygone-swept', vid);
}
// Re-relativize the "X ago" date text node on a card that already
// shows a pool video. Used after exact dates arrive so the displayed
// date corrects itself in place (no rebuild / reshuffle). Idempotent:
// recalcForFeed is deterministic, so it only writes when the value
// actually changes.
function _refreshCardDate(card, video, inner) {
if (!card || !video || !video.id) return;
const setDate = Store.getCurrentDate();
if (!setDate) return;
const owns = (el) => _ownedBy(el, inner || []);
const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => owns(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT,
});
let node;
while ((node = walker.nextNode())) {
const v = node.nodeValue || '';
if (/\b\d+\s*(year|month|week|day|hour|minute|second)s?\s+ago\b/i.test(v)) {
const base = video.relativeDate || v;
let nd;
try { nd = DateHelper.recalcForFeed(base, setDate, video.id); } catch (_) { return; }
if (nd && nd !== v) node.nodeValue = nd;
return;
}
}
}
// Walk every on-screen card and re-relativize its date. Called when a
// batch of exact dates has just been cached.
function refreshAllDates() {
try {
if (!Store.getCurrentDate()) return;
const cards = _findCards(document);
for (const card of cards) {
const inner = _innerCardsOf(card, cards);
const vid = _cardVideoId(card, inner);
if (!vid) continue;
const video = videoPool.find(v => v.id === vid) || { id: vid };
_refreshCardDate(card, video, inner);
}
} catch (_) {}
}
function _imgHasAnySource(img) {
if (!img) return false;
if ((img.currentSrc || '').length > 10) return true;
const attrs = ['src', 'srcset', 'data-src', 'data-thumb', 'data-thumbnail', 'data-thumb-url'];
for (const attr of attrs) {
if ((img.getAttribute(attr) || '').length > 10) return true;
}
return false;
}
function _cleanupBygoneThumbs(card, inner) {
const owns = (el) => _ownedBy(el, inner || []);
const thumbs = Array.from(card.querySelectorAll('img.bygone-thumb')).filter(owns);
if (!thumbs.length) return false;
const nativeImgs = Array.from(card.querySelectorAll('img'))
.filter(img => owns(img) && !img.classList.contains('bygone-thumb'));
if (!nativeImgs.some(_imgHasAnySource)) return false;
for (const thumb of thumbs) {
try { thumb.remove(); } catch (_) {}
}
return true;
}
function _hasNativeThumbScaffold(card, inner) {
const owns = (el) => _ownedBy(el, inner || []);
const primary = _primaryWatchLink(card, inner || []);
const imgRoot = primary || card;
const imgs = Array.from(imgRoot.querySelectorAll('img'))
.filter(img => owns(img) && !img.classList.contains('bygone-thumb'));
if (imgs.length) return true;
const scaffolds = Array.from(card.querySelectorAll(
'picture, source, [data-thumb], [data-thumbnail], [data-thumb-url], ' +
'[style*="ytimg"], [style*="background-image"], .yt-thumb, .video-thumb, ' +
'.thumb, .thumbnail, ytd-thumbnail'
)).filter(owns);
return scaffolds.length > 0;
}
function _fillExistingThumbImg(card, videoId, inner) {
const owns = (el) => _ownedBy(el, inner || []);
const primary = _primaryWatchLink(card, inner || []);
const imgRoot = primary || card;
const imgs = Array.from(imgRoot.querySelectorAll('img'))
.filter(img => owns(img) && !img.classList.contains('bygone-thumb'));
if (!imgs.length) return false;
if (imgs.some(_imgHasAnySource)) return true;
const target = imgs.find(img => {
const cls = ((img.className || '') + ' ' +
((img.parentElement && img.parentElement.className) || '')).toString();
return /thumb|preview|video/i.test(cls);
}) || imgs[0];
const thumbUrl = 'https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg';
try {
target.setAttribute('src', thumbUrl);
target.removeAttribute('srcset');
if (target.hasAttribute('data-src')) target.setAttribute('data-src', thumbUrl);
if (target.hasAttribute('data-thumb')) target.setAttribute('data-thumb', thumbUrl);
if (target.hasAttribute('data-thumbnail')) target.setAttribute('data-thumbnail', thumbUrl);
if (target.hasAttribute('data-thumb-url')) target.setAttribute('data-thumb-url', thumbUrl);
target.style.visibility = 'visible';
if (target.style.display === 'none') target.style.display = '';
return true;
} catch (_) {
return false;
}
}
function _ensureThumbOnCard(card, videoId, inner) {
if (_cleanupBygoneThumbs(card, inner)) return;
const owns = (el) => _ownedBy(el, inner || []);
if (Array.from(card.querySelectorAll('img.bygone-thumb')).some(owns)) return;
if (_fillExistingThumbImg(card, videoId, inner)) return;
if (_hasNativeThumbScaffold(card, inner)) return;
const thumbUrl = 'https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg';
const thumbImg = document.createElement('img');
thumbImg.src = thumbUrl;
thumbImg.className = 'bygone-thumb';
const link = Array.from(card.querySelectorAll('a[href*="/watch"]')).filter(owns)[0];
if (link) link.insertBefore(thumbImg, link.firstChild);
else card.insertBefore(thumbImg, card.firstChild);
}
function _ensureMetadata(card, videoId, inner) {
if (card.getAttribute('data-bygone-meta')) return;
const owns = (el) => _ownedBy(el, inner || []);
const contentArea = Array.from(card.querySelectorAll(
'.lohp-media-object-content, .yt-lockup-content'
)).filter(owns)[0];
if (!contentArea) return;
const hasChannel = contentArea.querySelector('.yt-user-name, a[href*="/channel/"]');
let hasViews = false;
for (const s of contentArea.querySelectorAll('span, li')) {
if (/\d[\d,.]*\s*(views?|[KkMmBb]\s*views?)/i.test(s.textContent || '')) { hasViews = true; break; }
}
if (hasChannel && hasViews) { card.setAttribute('data-bygone-meta', '1'); return; }
const video = videoPool.find(v => v.id === videoId);
if (!video) return;
if (!hasChannel && video.channel) {
const d = document.createElement('div');
d.className = 'lohp-video-metadata bygone-meta';
const chanHref = video.channelId ? '/channel/' + video.channelId : '#';
d.innerHTML = '<span class="run run-text ">by </span>' +
'<a class="yt-user-name" href="' + chanHref + '">' +
video.channel.replace(/</g, '<') + '</a>';
contentArea.appendChild(d);
}
const viewsText = video.viewCountFormatted || ((video.viewCount || '0') + ' views');
if (!hasViews) {
const d = document.createElement('div');
d.className = 'lohp-video-metadata bygone-meta';
let dateStr = video.relativeDate || '';
try {
const setDate = Store.getCurrentDate();
if (setDate && dateStr) dateStr = DateHelper.recalcForFeed(dateStr, setDate, videoId) || dateStr;
} catch (_) {}
d.innerHTML = '<span><span class="view-count">' + viewsText.replace(/</g, '<') +
'</span>' + (dateStr ? ' <span class="content-item-time-created">' +
dateStr.replace(/</g, '<') + '</span>' : '') + '</span>';
contentArea.appendChild(d);
}
card.setAttribute('data-bygone-meta', '1');
}
// ---- Grid sweep --------------------------------------------
// RULE: only rewrite a card that currently shows a non-pool video.
// A card already showing one of our videos is revealed and never
// re-rolled. If V3 creates more slots than the pool can uniquely
// populate in this render wave, repeated era videos are acceptable;
// blank slots and rotation are not.
function _hasRevealedInnerCard(innerCards) {
for (const inner of innerCards || []) {
if (!inner || !inner.getAttribute) continue;
if (inner.getAttribute('data-bygone-ok') === '1') return true;
if (inner.getAttribute('data-bygone-swept')) return true;
if (inner.getAttribute('data-bygone-keep')) return true;
}
return false;
}
function _tryRewriteDuplicatePoolCard(card, currentVid, inner, seenOnPage, keptPoolThisSweep, st) {
if (!currentVid || !keptPoolThisSweep.has(currentVid)) return false;
const alt = _freshDomVideoAvoiding(seenOnPage);
if (!alt) {
if (st) st.dupSkip++;
return false;
}
try {
_rewriteCard(card, alt, inner);
seenOnPage.add(alt.id);
keptPoolThisSweep.add(alt.id);
if (st) {
st.dupReuse++;
st.dupFixed++;
st.rewritten++;
}
return true;
} catch (_) {
if (st) st.rewriteThrew++;
return false;
}
}
function _sweep() {
if (!active || !videoPool.length) return;
// Search-results page: real query results from YouTube (already
// date-bounded via `before:` in the URL). Sweeping would replace
// those genuine matches with pool videos. Leave them alone.
const _p = location.pathname;
if (_p === '/results' || _p === '/results/' || _isChannelPage()) return;
if (_isHomeLikePath()) _cleanupHomeSpaArtifacts();
startResponseScope(); // refresh _displayedIdsCache
const poolIds = _poolIdSet();
const cards = _findCards(document);
const st = {
total: cards.length, alreadySwept: 0, keep: 0, noOwnedLink: 0,
badHref: 0, isPool: 0, natural: 0, mapNull: 0, dupSkip: 0,
dupReuse: 0, dupFixed: 0, wrapperReveal: 0, rewritten: 0, rewriteThrew: 0
};
_sweepStats = st;
if (!cards.length) return;
let swept = 0;
const poolById = new Map();
for (const v of videoPool) if (v && v.id) poolById.set(v.id, v);
// First pass: every pool video already visible on the page.
// The next-pass fresh picks will avoid duplicating these.
const seenOnPage = new Set();
for (const card of cards) {
const inner = _innerCardsOf(card, cards);
const a = _primaryWatchLink(card, inner);
if (!a) continue;
const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
if (m && poolIds.has(m[1])) seenOnPage.add(m[1]);
}
const keptPoolThisSweep = new Set();
for (const card of cards) {
const inner = _innerCardsOf(card, cards);
if (card.getAttribute && card.getAttribute('data-bygone-swept')) {
const sweptVid = card.getAttribute('data-bygone-swept');
const visibleVid = _cardVideoId(card, inner) || sweptVid;
if (poolIds.has(visibleVid) && _tryRewriteDuplicatePoolCard(card, visibleVid, inner, seenOnPage, keptPoolThisSweep, st)) {
continue;
}
if (poolIds.has(visibleVid)) {
seenOnPage.add(visibleVid);
keptPoolThisSweep.add(visibleVid);
}
const sweptVideo = poolById.get(sweptVid) || poolById.get(visibleVid);
if (sweptVideo) {
try { _setCardChannel(card, sweptVideo, inner); } catch (_) {}
try { _ensureMetadata(card, sweptVideo.id, inner); } catch (_) {}
}
_cleanupBygoneThumbs(card, inner);
st.alreadySwept++;
continue;
}
// Kept card from an earlier sweep — its date was already
// redated, so re-evaluating it would read as "modern".
// Skip it; just keep it revealed.
if (card.getAttribute && card.getAttribute('data-bygone-keep')) {
const keepVid = _cardVideoId(card, inner);
if (keepVid) _redateKeptCard(card, keepVid);
card.setAttribute('data-bygone-ok', '1');
st.keep++;
continue;
}
const a = _primaryWatchLink(card, inner);
if (!a) {
st.noOwnedLink++;
if (_hasRevealedInnerCard(inner)) {
card.setAttribute('data-bygone-ok', '1');
st.wrapperReveal++;
}
continue;
}
const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
if (!m) { st.badHref++; continue; }
const currentVid = m[1];
// ANTI-ROTATION (v206 rule, do not change): NEVER touch a
// card already showing one of our videos — not even to
// dedup. Rewriting an already-replaced card means fighting
// V3's re-render, and that tug-of-war is the once-per-
// second rotation. Visible duplicates are accepted; the
// rotation is not.
if (poolIds.has(currentVid)) {
if (_tryRewriteDuplicatePoolCard(card, currentVid, inner, seenOnPage, keptPoolThisSweep, st)) continue;
seenOnPage.add(currentVid);
keptPoolThisSweep.add(currentVid);
card.setAttribute('data-bygone-ok', '1');
const currentVideo = poolById.get(currentVid);
if (currentVideo) {
try { _setCardChannel(card, currentVideo, inner); } catch (_) {}
}
_ensureThumbOnCard(card, currentVid, inner);
_ensureMetadata(card, currentVid, inner);
st.isPool++;
continue;
}
// Naturally-old recommendation — kept by the interceptor at
// the JSON level OR detected here from the DOM date text.
// Mark `keep` so the click hijack navigates to the REAL video.
if (_keptNaturalIds.has(currentVid) || _cardIsNaturallyOld(card)) {
_keptNaturalIds.add(currentVid);
_redateKeptCard(card, currentVid);
card.setAttribute('data-bygone-ok', '1');
card.setAttribute('data-bygone-keep', '1');
_ensureThumbOnCard(card, currentVid, inner);
_ensureMetadata(card, currentVid, inner);
st.natural++;
continue;
}
let next = _nextDomVideoFor(currentVid);
if (!next) { st.mapNull++; continue; }
if (seenOnPage.has(next.id)) {
const alt = _freshDomVideoAvoiding(seenOnPage);
if (alt) next = alt;
st.dupReuse++;
}
seenOnPage.add(next.id);
keptPoolThisSweep.add(next.id);
try { _rewriteCard(card, next, inner); swept++; st.rewritten++; } catch (_) { st.rewriteThrew++; }
}
}
// ---- Sidebar sweep -----------------------------------------
// CRITICAL: disconnect the MutationObserver during our own DOM
// writes. Otherwise our writes refire the observer → schedule
// another sweep → write again → tight feedback loop.
let _sidebarObs = null;
let _sidebarObsTarget = null;
function _sidebarSweep() {
let resumeTarget = null;
if (_sidebarObs && _sidebarObsTarget) {
try { _sidebarObs.disconnect(); resumeTarget = _sidebarObsTarget; } catch (_) {}
}
try { _sidebarSweepCore(); }
finally {
if (_sidebarObs && resumeTarget) {
try { _sidebarObs.observe(resumeTarget, { childList: true, subtree: true }); } catch (_) {}
}
}
}
function _sidebarSweepCore() {
if (!active || !videoPool.length) return;
if (!location.pathname.startsWith('/watch')) return;
startResponseScope();
const poolIds = _poolIdSet();
const poolById = new Map();
for (const v of videoPool) poolById.set(v.id, v);
const rewriteIfNeeded = (card) => {
if (!card || (card.getAttribute && card.getAttribute('data-bygone-swept'))) return;
// Kept card from an earlier sweep — already redated; skip.
if (card.getAttribute && card.getAttribute('data-bygone-keep')) {
const keepVid = _cardVideoId(card);
if (keepVid) _redateKeptCard(card, keepVid);
card.setAttribute('data-bygone-ok', '1');
return;
}
const a = _primaryWatchLink(card);
if (!a) return;
const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
if (!m) return;
const cur = m[1];
// ANTI-ROTATION (v206 rule): card already shows one of our
// videos → only fix a stale channel name (idempotent) and
// skip. Re-mapping "duplicates" here is what produces the
// every-tick rotation, because V3 immediately re-renders
// the card back to its cached state and we re-replace it.
if (poolIds.has(cur)) {
card.setAttribute('data-bygone-ok', '1');
const vobj = poolById.get(cur);
if (vobj) {
try { _setCardChannel(card, vobj); } catch (_) {}
try { _ensureThumbOnCard(card, cur); } catch (_) {}
try { _ensureMetadata(card, cur); } catch (_) {}
try { _refreshCardDate(card, vobj); } catch (_) {}
}
return;
}
if (_keptNaturalIds.has(cur) || _cardIsNaturallyOld(card)) {
_keptNaturalIds.add(cur);
_redateKeptCard(card, cur);
card.setAttribute('data-bygone-ok', '1');
card.setAttribute('data-bygone-keep', '1');
try { _ensureThumbOnCard(card, cur); } catch (_) {}
try { _ensureMetadata(card, cur); } catch (_) {}
return;
}
const next = _nextDomVideoFor(cur);
if (!next) return;
try { _rewriteCard(card, next); } catch (_) {}
};
// V3 exact path: every <li> in the sidebar <ol> is a card.
document.querySelectorAll(
'#watch7-sidebar-contents ol > li, #watch7-sidebar ol > li, ' +
'#watch7-sidebar-modules li.video-list-item, .watch-sidebar-body li'
).forEach(rewriteIfNeeded);
// Named containers only (the old [id*="sidebar"] catch-all scanned
// the whole document on every sweep — huge CPU sink).
const containers = [
document.getElementById('watch7-sidebar-contents'),
document.getElementById('watch7-sidebar'),
document.getElementById('watch7-sidebar-modules'),
document.getElementById('related'),
document.getElementById('secondary'),
document.getElementById('secondary-inner'),
].filter(Boolean);
const seenCards = new Set();
for (const c of containers) {
const classicMatches = c.querySelectorAll(
'.video-list-item, .related-list-item, ytd-compact-video-renderer, ' +
'yt-lockup-view-model, yt-collection-thumbnail-view-model, ' +
'.ytd-watch-next-secondary-results-renderer'
);
const cards = new Set(Array.from(classicMatches));
c.querySelectorAll('a[href*="/watch?v="]').forEach(a => {
let p = a;
for (let i = 0; i < 8 && p && p !== c; i++) {
if (p.querySelector && p.querySelector('img')) { cards.add(p); break; }
p = p.parentElement;
}
});
cards.forEach(card => {
if (seenCards.has(card)) return;
seenCards.add(card);
rewriteIfNeeded(card);
});
}
}
// ---- Sidebar infinite scroll -------------------------------
// V3's Up Next sidebar is a fixed list from the initial /next
// response — it doesn't natively load more. We extend it by
// cloning an existing <li> (inherits V3's exact markup + CSS)
// and rewriting it with a fresh pool video whenever the user
// scrolls near the bottom.
let _lastSidebarExtend = 0;
function _maybeExtendSidebar() {
if (!active || !videoPool.length) return;
if (!location.pathname.startsWith('/watch')) return;
// Find the sidebar <ol> (V3) or any container with sidebar list items.
const ol = document.querySelector(
'#watch7-sidebar-contents ol, #watch7-sidebar ol, ' +
'#watch7-sidebar-modules ol, .watch-sidebar-body ol'
);
if (!ol) return;
const items = ol.querySelectorAll('li');
if (!items.length) return;
// Are we near the bottom of the sidebar list?
const olBottom = ol.getBoundingClientRect().bottom;
if (olBottom - window.innerHeight > 800) return;
// Throttle: at most one extend every 500ms.
if (Date.now() - _lastSidebarExtend < 500) return;
_lastSidebarExtend = Date.now();
// Collect videoIds already shown so we don't duplicate.
const shown = new Set();
items.forEach(li => {
const a = _primaryWatchLink(li);
if (!a) return;
const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
if (m) shown.add(m[1]);
});
// Pick N unused pool videos.
const N = 10;
const picks = [];
for (const v of videoPool) {
if (shown.has(v.id)) continue;
picks.push(v);
if (picks.length >= N) break;
}
if (!picks.length) {
// Pool dry — trigger lazy fetch and try again next scroll.
_maybeFetchMore();
return;
}
// Clone the FIRST existing <li> as a template (inherits V3's
// exact markup + CSS) and rewrite each clone with a fresh video.
const template = items[0];
for (const v of picks) {
try {
const clone = template.cloneNode(true);
clone.removeAttribute('data-bygone-swept');
clone.removeAttribute('data-bygone-ok');
_rewriteCard(clone, v);
clone.setAttribute('data-bygone-ok', '1');
ol.appendChild(clone);
} catch (_) {}
}
}
// Hook scroll on multiple potential containers — V3's scroll
// container varies by layout. window scroll is the catch-all.
window.addEventListener('scroll', _maybeExtendSidebar, { passive: true });
document.addEventListener('scroll', _maybeExtendSidebar, { passive: true, capture: true });
// ---- Player recommendations / autoplay ---------------------
// The watch sidebar is normal DOM, but the YouTube player owns its
// own end-screen cards, "up next" overlay, and next-button/autoplay
// navigation. Those paths can fire synthetic clicks or internal
// player navigation, bypassing the normal card click hijack. Keep
// player rec UI pool-only, and block native autoplay entirely.
function _currentWatchVideoId() {
try {
const u = new URL(location.href);
const v = u.searchParams.get('v') || '';
return _VALID_VID.test(v) ? v : '';
} catch (_) {
const m = (location.href || '').match(/[?&]v=([A-Za-z0-9_-]{11})/);
return m ? m[1] : '';
}
}
function _poolVideoById(id) {
if (!id) return null;
for (const v of videoPool) if (v && v.id === id) return v;
return null;
}
function _extractVideoIdFromText(text) {
if (!text) return '';
const m = String(text).match(/(?:[?&]v=|\/(?:vi|embed|shorts)\/)([A-Za-z0-9_-]{11})/);
return m ? m[1] : '';
}
function _playerRecVideoId(el) {
if (!el) return '';
const attrs = ['data-video-id', 'data-vid', 'data-videoid', 'video-id', 'videoid'];
for (const a of attrs) {
try {
const v = el.getAttribute && el.getAttribute(a);
if (v && _VALID_VID.test(v)) return v;
} catch (_) {}
}
try {
const link = el.matches && el.matches('a[href]') ? el : el.querySelector('a[href]');
const id = link && _extractVideoIdFromText(link.getAttribute('href') || '');
if (id) return id;
} catch (_) {}
try {
const nodes = [el].concat(Array.from(el.querySelectorAll('[style], img, source')));
for (const n of nodes) {
const id = _extractVideoIdFromText(
(n.getAttribute && (
n.getAttribute('src') || n.getAttribute('srcset') ||
n.getAttribute('style') || n.getAttribute('data-thumb') ||
n.getAttribute('data-thumbnail') || ''
)) || ''
);
if (id) return id;
}
} catch (_) {}
return '';
}
function _pickPlayerPoolVideo(origId) {
const current = _currentWatchVideoId();
if (origId && _poolIdsSet.has(origId) && origId !== current) {
const alreadyPool = _poolVideoById(origId);
if (alreadyPool) return alreadyPool;
}
let mapped = null;
if (origId && !_poolIdsSet.has(origId)) {
try { mapped = _nextDomVideoFor(origId); } catch (_) {}
if (mapped && mapped.id && mapped.id !== current) return mapped;
}
const avoid = new Set();
if (current) avoid.add(current);
if (origId) avoid.add(origId);
try {
const fresh = _freshDomVideoAvoiding(avoid);
if (fresh) return fresh;
} catch (_) {}
for (const v of videoPool) {
if (v && v.id && !avoid.has(v.id)) return v;
}
return videoPool[0] || null;
}
function _setFirstText(root, selectors, text) {
if (!text) return false;
for (const sel of selectors) {
const els = root.querySelectorAll ? root.querySelectorAll(sel) : [];
for (const el of els) {
if (!el || el.querySelector && el.querySelector('img')) continue;
try {
el.textContent = text;
if (el.hasAttribute && el.hasAttribute('title')) el.setAttribute('title', text);
return true;
} catch (_) {}
}
}
return false;
}
function _rewritePlayerRecCard(card, video) {
if (!card || !video || !video.id) return false;
const vid = video.id;
const watchUrl = '/watch?v=' + vid;
const thumbUrl = video.thumbnail || ('https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg');
try {
if (card.matches && card.matches('a[href]')) card.setAttribute('href', watchUrl);
card.querySelectorAll('a[href]').forEach(a => {
const href = a.getAttribute('href') || '';
if (href.indexOf('/watch') !== -1 || href.indexOf('/shorts') !== -1 || href.indexOf('/embed') !== -1) {
a.setAttribute('href', watchUrl);
}
});
} catch (_) {}
try {
const nodes = [card].concat(Array.from(card.querySelectorAll('*')));
for (const n of nodes) {
['data-video-id', 'data-vid', 'data-videoid', 'video-id', 'videoid'].forEach(a => {
try { if (n.hasAttribute && n.hasAttribute(a)) n.setAttribute(a, vid); } catch (_) {}
});
}
} catch (_) {}
try {
card.querySelectorAll('img').forEach(img => {
img.setAttribute('src', thumbUrl);
img.removeAttribute('srcset');
if (img.hasAttribute('data-src')) img.setAttribute('data-src', thumbUrl);
if (img.hasAttribute('data-thumb')) img.setAttribute('data-thumb', thumbUrl);
if (img.hasAttribute('data-thumbnail')) img.setAttribute('data-thumbnail', thumbUrl);
img.alt = video.title || '';
});
card.querySelectorAll('source').forEach(s => {
if (s.hasAttribute('src')) s.setAttribute('src', thumbUrl);
if (s.hasAttribute('srcset')) s.setAttribute('srcset', thumbUrl);
});
[card].concat(Array.from(card.querySelectorAll('[style]'))).forEach(n => {
const style = n.getAttribute && (n.getAttribute('style') || '');
if (/ytimg|background-image/i.test(style)) n.style.backgroundImage = 'url("' + thumbUrl + '")';
});
} catch (_) {}
_setFirstText(card, [
'.ytp-ce-video-title',
'.ytp-videowall-still-info-title',
'.ytp-autonav-endscreen-upnext-title',
'.ytp-suggestion-title',
'.ytp-ce-expanding-overlay-title',
'[class*="title"]'
], video.title || '');
_setFirstText(card, [
'.ytp-ce-video-metadata',
'.ytp-videowall-still-info-author',
'.ytp-autonav-endscreen-upnext-author',
'.ytp-suggestion-author',
'[class*="metadata"]'
], video.channel || '');
try {
card.setAttribute('data-bygone-player-card-ok', '1');
card.setAttribute('data-bygone-player-video', vid);
card.removeAttribute('data-bygone-player-card-blocked');
} catch (_) {}
return true;
}
function _looksLikePlayerRecCard(el) {
if (!el || !el.closest || !el.closest('.html5-video-player, #movie_player')) return false;
const cls = (el.className || '').toString();
if (/ytp-(ce-video|videowall-still|autonav-endscreen-upnext|suggestion)/i.test(cls)) return true;
return !!_playerRecVideoId(el);
}
function _playerRecCards() {
const roots = document.querySelectorAll('.html5-video-player, #movie_player');
const out = new Set();
roots.forEach(root => {
root.querySelectorAll([
'.ytp-ce-video',
'.ytp-ce-element',
'.ytp-videowall-still',
'.ytp-autonav-endscreen-upnext-container',
'.ytp-suggestion-link',
'a.ytp-suggestion-link'
].join(',')).forEach(el => {
if (_looksLikePlayerRecCard(el)) out.add(el);
});
});
return Array.from(out);
}
let _lastAutoplayDisableAttempt = 0;
function _tryDisableAutoplayControl(el) {
if (!el) return;
const now = Date.now();
if (now - _lastAutoplayDisableAttempt < 8000) return;
_lastAutoplayDisableAttempt = now;
try { el.setAttribute('data-bygone-autoplay-disable-attempt', String(now)); } catch (_) {}
try { el.click(); } catch (_) {}
}
function _disableNativeAutoplay() {
if (!location.pathname.startsWith('/watch')) return;
try {
document.querySelectorAll('.ytp-autonav-toggle-button, .ytp-autonav-toggle-button-container button').forEach(btn => {
const state = (btn.getAttribute('aria-checked') || btn.getAttribute('aria-pressed') || '').toLowerCase();
const label = (btn.getAttribute('aria-label') || btn.getAttribute('title') || '').toLowerCase();
if (!(state === 'true' || /autoplay.*on|on.*autoplay/.test(label))) return;
_tryDisableAutoplayControl(btn);
});
document.querySelectorAll('.autoplay-bar input[type="checkbox"]:checked').forEach(_tryDisableAutoplayControl);
document.querySelectorAll('.ytp-autonav-endscreen-countdown-container, .ytp-autonav-endscreen-countdown-overlay')
.forEach(el => el.setAttribute('data-bygone-player-card-blocked', '1'));
} catch (_) {}
}
function _sweepPlayerRecommendations() {
if (!active || !videoPool.length) return;
if (!location.pathname.startsWith('/watch')) return;
_disableNativeAutoplay();
for (const card of _playerRecCards()) {
const orig = _playerRecVideoId(card);
const v = _pickPlayerPoolVideo(orig);
if (!v) {
try { card.setAttribute('data-bygone-player-card-blocked', '1'); } catch (_) {}
continue;
}
_rewritePlayerRecCard(card, v);
}
}
function _playerRecClickTarget(target) {
if (!target || !target.closest) return null;
return target.closest(
'.ytp-next-button, .ytp-ce-video, .ytp-videowall-still, ' +
'.ytp-autonav-endscreen-upnext-container, .ytp-suggestion-link'
);
}
function _navigateToPoolVideo(video) {
if (!video || !video.id) return false;
try {
Store.addClickEvent({
videoId: video.id, channelId: video.channelId || null,
channel: video.channel || '', title: video.title || '',
source: video.source || 'unknown', ts: Date.now(),
});
Store.markFeedClicked(video.id);
Store.recordSourceClick(video.source || 'unknown');
} catch (_) {}
location.href = location.origin + '/watch?v=' + video.id;
return true;
}
setInterval(_sweepPlayerRecommendations, 500);
// ---- Home page infinite scroll --------------------------------
// V3's LOHP continuation fires once then dies. We inject more
// pool videos into the grid when the user scrolls near bottom.
let _lastHomeExtend = 0;
let _homeExtendCount = 0;
let _homeExtendPausedUntil = 0;
const _MAX_HOME_EXTENDS = 20;
const _HOME_FEATURE_SEL = '.lohp-large-shelf-container, .lohp-medium-shelf';
const _HOME_EXTEND_CONTAINER_SEL = [
'#c3-content-items',
'#browse-items-primary',
'#feed',
'#feed-list',
'.feed-list',
'.channels-browse-content-grid',
'.expanded-shelf-content-list',
'.yt-shelf-grid',
'.yt-rich-grid',
'ytd-rich-grid-renderer #contents'
].join(',');
function _isHomeFeature(el) {
return !!(el && el.matches && el.matches(_HOME_FEATURE_SEL));
}
function _isInsideHomeFeature(el) {
return !!(el && el.closest && el.closest(_HOME_FEATURE_SEL));
}
function _containsHomeFeature(el) {
return !!(el && el.querySelector && el.querySelector(_HOME_FEATURE_SEL));
}
function _countDirectVideoChildren(container) {
if (!container || !container.children) return 0;
let n = 0;
for (const child of container.children) {
if (_isHomeFeature(child) || _isInsideHomeFeature(child) || _containsHomeFeature(child)) continue;
if (child.querySelector && child.querySelector('a[href*="/watch"]')) n++;
}
return n;
}
function _watchLinkCount(el) {
if (!el || !el.querySelectorAll) return 0;
const ids = new Set();
el.querySelectorAll('a[href*="/watch"]').forEach(a => {
const h = a.getAttribute('href') || '';
const m = h.match(/[?&]v=([A-Za-z0-9_-]+)/);
ids.add(m ? m[1] : h);
});
return ids.size;
}
function _watchAnchorCount(el) {
return el && el.querySelectorAll ? el.querySelectorAll('a[href*="/watch"]').length : 0;
}
function _isSingleVideoTemplate(el) {
if (!el || _isHomeFeature(el) || _isInsideHomeFeature(el) || _containsHomeFeature(el)) return false;
return _watchLinkCount(el) === 1 && _watchAnchorCount(el) <= 3;
}
function _templateFromHomeContainer(container, cards) {
if (!container || _isHomeFeature(container) || _isInsideHomeFeature(container)) return null;
const direct = [];
for (const child of Array.from(container.children || [])) {
if (_isSingleVideoTemplate(child)) direct.push(child);
}
if (direct.length) return direct[direct.length - 1];
for (let i = cards.length - 1; i >= 0; i--) {
let node = cards[i];
if (!_isSingleVideoTemplate(node)) continue;
while (node && node.parentElement && node.parentElement !== container) node = node.parentElement;
if (node && node.parentElement === container && _isSingleVideoTemplate(node)) {
return node;
}
}
return null;
}
function _findHomeExtendTarget(cards) {
const sidebarAncestorSel = [
'#watch7-sidebar-contents',
'#watch7-sidebar',
'#watch7-sidebar-modules',
'#related',
'#secondary',
'#secondary-inner'
].join(',');
const badAncestorSel = [
_HOME_FEATURE_SEL,
sidebarAncestorSel
].join(',');
const knownContainers = [
'#c3-content-items',
'#browse-items-primary',
'#feed',
'#feed-list',
'.feed-list',
'.channels-browse-content-grid',
'.expanded-shelf-content-list',
'.yt-shelf-grid',
'.yt-rich-grid',
'ytd-rich-grid-renderer #contents'
];
for (const sel of knownContainers) {
for (const container of document.querySelectorAll(sel)) {
if (!container || (container.closest && container.closest(sidebarAncestorSel))) continue;
const template = _templateFromHomeContainer(container, cards);
if (template) return { container, template };
}
}
for (let i = cards.length - 1; i >= 0; i--) {
const template = cards[i];
if (!template || !template.parentElement) continue;
if (_isHomeFeature(template) || _isInsideHomeFeature(template) || _containsHomeFeature(template)) continue;
if (template.closest && template.closest(badAncestorSel)) continue;
let container = template.parentElement;
for (let depth = 0; depth < 5 && container; depth++, container = container.parentElement) {
if (_isHomeFeature(container) || _isInsideHomeFeature(container) || _containsHomeFeature(container)) break;
if (container.closest && container.closest('#watch7-sidebar-contents,#watch7-sidebar,#watch7-sidebar-modules,#related,#secondary,#secondary-inner')) break;
const tag = (container.tagName || '').toLowerCase();
const cls = (container.className || '').toString();
const directVideoChildren = _countDirectVideoChildren(container);
const listLike = tag === 'ol' || tag === 'ul' || /items|grid|list|feed|shelf|browse|content/i.test(cls);
// The top LOHP feature area has a big card + side cards but
// is not a repeatable feed list. Requiring repeated direct
// card children keeps appended batches out of that gap.
if (listLike && directVideoChildren >= 2) {
return { container, template };
}
}
}
return null;
}
function _maybeExtendHome() {
if (!active || !videoPool.length) return;
const path = location.pathname;
if (path !== '/' && path !== '' && path !== '/feed/trending') return;
if (_homeExtendCount >= _MAX_HOME_EXTENDS) return;
if (Date.now() - _lastHomeExtend < 600) return;
const docBottom = document.documentElement.scrollHeight;
if (docBottom - window.scrollY - window.innerHeight > 1200) return;
_lastHomeExtend = Date.now();
const cards = _findCards(document);
if (!cards.length) return;
const shown = new Set();
for (const c of cards) {
const a = _primaryWatchLink(c);
if (!a) continue;
const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
if (m) shown.add(m[1]);
}
const N = 12;
const picks = [];
for (const v of videoPool) {
if (shown.has(v.id)) continue;
picks.push(v);
if (picks.length >= N) break;
}
if (!picks.length) { _maybeFetchMore(); return; }
const target = _findHomeExtendTarget(cards);
if (!target) return;
const { container, template } = target;
for (const v of picks) {
try {
const clone = template.cloneNode(true);
clone.removeAttribute('data-bygone-swept');
clone.removeAttribute('data-bygone-ok');
clone.removeAttribute('data-bygone-keep');
clone.removeAttribute('data-bygone-redated');
_rewriteCard(clone, v);
clone.setAttribute('data-bygone-ok', '1');
clone.setAttribute('data-bygone-home-extend', '1');
container.appendChild(clone);
shown.add(v.id);
} catch (_) {}
}
_homeExtendCount++;
_maybeFetchMore();
}
// Reset extend count on navigation (new page = fresh feed).
function _resetHomeExtend() { _homeExtendCount = 0; }
window.addEventListener('scroll', _maybeExtendHome, { passive: true });
document.addEventListener('scroll', _maybeExtendHome, { passive: true, capture: true });
window.addEventListener('yt-navigate-finish', _resetHomeExtend);
window.addEventListener('popstate', _resetHomeExtend);
// ---- Comment filter (DOM sweep) ----------------------------
// Comments lazy-load in batches and V3 re-renders them from its
// own caches, so a data-level filter can't reliably catch them.
// Instead we sweep the rendered comment DOM on a heartbeat:
// - Hide any comment thread dated AFTER the cutoff
// (set date + 2 years).
// - Re-relativize surviving comment timestamps to the set date.
// - Auto-drain the V3 "show/load more comments" continuation,
// sweeping each loaded batch before it has a chance to linger.
const _C_DATE_RE = /(?:Streamed\s+)?(\d+)\s+(year|month|week|day|hour|minute|second)s?\s+ago/i;
// V3's 2013 layout can render an ABSOLUTE date ("May 15, 2013")
// instead of a relative "X years ago" string. Detect both.
const _C_ABS_DATE_RE = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}/i;
let _cDiag = { found: 0, hidden: 0, kept: 0, notime: 0 };
function _collectCommentThreads() {
const threads = [];
const seen = new Set();
const add = (node) => {
if (!node || seen.has(node)) return;
seen.add(node);
threads.push(node);
};
document.querySelectorAll('ytd-comment-thread-renderer').forEach(add);
document.querySelectorAll('ytd-comment-view-model').forEach(t => {
if (!t.closest('ytd-comment-thread-renderer')) add(t);
});
document.querySelectorAll('.comment-thread-renderer, .comment-item').forEach(add);
// V3 2013 layout: comments are div.comment[data-id].
document.querySelectorAll('div.comment[data-id]').forEach(add);
return threads;
}
function _commentRemovalTarget(thread) {
if (!thread || !thread.parentElement) return thread;
const root = _commentRoot();
let best = thread;
for (let el = thread; el && el !== document.body && el !== root; el = el.parentElement) {
const cls = (el.className || '').toString();
const id = el.id || '';
const tag = (el.tagName || '').toLowerCase();
const sig = cls + ' ' + id;
const isSlot = tag === 'li' ||
/^(ytd-comment-thread-renderer|ytd-comment-view-model)$/i.test(tag) ||
/(^|\s)(post|comment|comment-renderer|comment-thread|comment-item|comment-entry|comment-container|comment-renderer-root)(\s|$)/i.test(cls);
if (isSlot) {
best = el;
}
if (el.parentElement === root) {
if (isSlot) best = el;
break;
}
if (el.parentElement && /comments?|discussion|responses?|threads?|items?|list/i.test((el.parentElement.className || '') + ' ' + (el.parentElement.id || ''))) {
best = el;
break;
}
if (/comments?|discussion|responses?|threads?/i.test(sig) && !/(^|\s)(post|comment|comment-renderer|comment-thread|comment-item|comment-entry|comment-container)(\s|$)/i.test(cls)) break;
}
return best || thread;
}
function _collapseEmptyCommentAncestors(start, root) {
let el = start;
let guard = 0;
while (el && el !== document.body && el !== root && guard++ < 8) {
const next = el.parentElement;
const sig = ((el.className || '') + ' ' + (el.id || '')).toString();
const tag = (el.tagName || '').toLowerCase();
const isSlot = /^(ytd-comment-thread-renderer|ytd-comment-view-model)$/i.test(tag) ||
/(^|\s)(post|comment|comment-renderer|comment-thread|comment-item|comment-entry|comment-container|comment-renderer-root)(\s|$)/i.test(sig);
const isListItem = tag === 'li';
if (!isSlot && !isListItem) break;
const hasRemainingComment = !!(el.querySelector && el.querySelector(
'div.comment[data-id], ytd-comment-thread-renderer, ytd-comment-view-model, ' +
'.comment-thread-renderer, .comment-item'
));
const visibleText = (el.textContent || '').replace(/\s+/g, '').trim();
if (!hasRemainingComment && visibleText.length < 8) {
try { el.remove(); } catch (_) {
try {
el.setAttribute('data-bygone-comment-hidden', '1');
el.style.setProperty('display', 'none', 'important');
el.style.setProperty('height', '0', 'important');
el.style.setProperty('min-height', '0', 'important');
el.style.setProperty('margin', '0', 'important');
el.style.setProperty('padding', '0', 'important');
} catch (__) {}
}
} else {
break;
}
el = next;
}
}
function _removeCommentSlot(thread) {
const target = _commentRemovalTarget(thread);
const root = _commentRoot();
const parentAfterRemove = target && target.parentElement;
const sibsToRemove = [];
let sib = target && target.nextElementSibling, guard = 0;
while (sib && guard++ < 8) {
if (sib.matches && sib.matches('.post, div.comment[data-id], ytd-comment-thread-renderer, .comment-thread-renderer, .comment-thread, .comment-item')) break;
const cls = (sib.className || '').toString();
const txt = (sib.textContent || '').toLowerCase();
if (/comment-repl|reply|replies|repl/i.test(cls) || /reply|replies|repl/i.test(txt)) sibsToRemove.push(sib);
sib = sib.nextElementSibling;
}
for (const s of sibsToRemove) {
try { s.remove(); } catch (_) {}
}
if (target) {
try {
target.remove();
_collapseEmptyCommentAncestors(parentAfterRemove, root);
return;
} catch (_) {}
try {
target.setAttribute('data-bygone-comment-hidden', '1');
target.style.setProperty('display', 'none', 'important');
target.style.setProperty('height', '0', 'important');
target.style.setProperty('min-height', '0', 'important');
target.style.setProperty('margin', '0', 'important');
target.style.setProperty('padding', '0', 'important');
_collapseEmptyCommentAncestors(parentAfterRemove, root);
} catch (_) {}
}
}
function _commentSweep() {
if (!active) return;
if (!location.pathname.startsWith('/watch')) return;
let setDateStr;
try { setDateStr = Store.getCurrentDate(); } catch { return; }
if (!setDateStr) return;
const setDate = new Date(setDateStr);
if (isNaN(setDate.getTime())) return;
const cutoff = new Date(setDate);
cutoff.setFullYear(cutoff.getFullYear() + 2);
// Collect comment threads — prefer the OUTER thread renderer
// so we don't double-process the inner view-model.
const threads = _collectCommentThreads();
for (const thread of threads) {
if (thread.getAttribute('data-bygone-cchecked')) continue;
_cDiag.found++;
// Find the timestamp element. Try explicit selectors first,
// then fall back to scanning for any element whose text is a
// date (relative "X ago" OR absolute "May 15, 2013").
let timeEl = thread.querySelector(
'#published-time-text a, #published-time-text yt-formatted-string, ' +
'#published-time-text yt-core-attributed-string, ' +
'.published-time-text a, .published-time-text yt-core-attributed-string, ' +
'.metadata a.detail_link:not(.detail_link_full), ' +
'.metadata .time, .comment .time, .time a, .time, ' +
'.comment-time, .comment-date, a.comment-author-time, time'
);
if (!timeEl) {
for (const el of thread.querySelectorAll(
'a, span, div, time, yt-core-attributed-string, yt-formatted-string'
)) {
const t = (el.textContent || '').trim();
if (!t || t.length > 40) continue; // skip prose
if (_C_DATE_RE.test(t) || _C_ABS_DATE_RE.test(t)) { timeEl = el; break; }
}
}
if (!timeEl) { _cDiag.notime++; continue; } // not rendered yet — retry next tick
const raw = (timeEl.textContent || '').trim();
// Resolve an approximate publish date from either format.
let approx = null;
const m = raw.match(_C_DATE_RE);
if (m) {
approx = DateHelper.approxPublishDate(m[0]);
} else {
const am = raw.match(_C_ABS_DATE_RE);
if (am) { const d = new Date(am[0]); if (!isNaN(d.getTime())) approx = d; }
}
if (!approx || isNaN(approx.getTime())) continue;
// Mark processed only once we actually have a date.
thread.setAttribute('data-bygone-cchecked', '1');
if (approx.getTime() > cutoff.getTime()) {
_cDiag.hidden++;
_removeCommentSlot(thread);
} else {
_cDiag.kept++;
// Survivor — re-relativize its timestamp to the set date.
const edited = /\(edited\)/i.test(raw) ? ' (edited)' : '';
const newText = DateHelper.relativeToDate(approx, setDate) + edited;
try { timeEl.textContent = newText; } catch (_) {}
}
}
// One-shot diagnostic per render burst: report what the sweep did.
if (_cDiag.found && (_cDiag.hidden || _cDiag.kept || _cDiag.notime) &&
!_commentSweep._lastReport) {
_commentSweep._lastReport = true;
console.log('[bygone] comments swept:',
'threads=' + _cDiag.found, 'hidden=' + _cDiag.hidden,
'kept=' + _cDiag.kept, 'no-timestamp=' + _cDiag.notime,
'cutoff=' + cutoff.toISOString().slice(0, 10));
if (_cDiag.notime && !_cDiag.hidden && !_cDiag.kept && threads[0]) {
console.log('[bygone] NO timestamps matched. Sample thread:',
threads[0].outerHTML.slice(0, 400));
}
setTimeout(() => { _commentSweep._lastReport = false; }, 4000);
}
}
const _COMMENT_DRAIN_MAX_CLICKS = 400;
const _COMMENT_DRAIN_MIN_CLICK_MS = 70;
const _COMMENT_DRAIN_WAIT_SAME_COUNT_MS = 180;
const _COMMENT_DRAIN_STALL_TICKS = 70;
let _commentDrain = {
key: '',
clicks: 0,
done: false,
capped: false,
stall: 0,
lastCount: 0,
lastClickAt: 0,
lastClickCount: -1,
lastLogAt: 0,
};
function _commentPageKey() {
const m = location.search.match(/[?&]v=([A-Za-z0-9_-]+)/);
return location.pathname + '|' + (m ? m[1] : location.search);
}
function _resetCommentDrain() {
_cDiag = { found: 0, hidden: 0, kept: 0, notime: 0 };
_commentSweep._lastReport = false;
_commentDrain = {
key: _commentPageKey(),
clicks: 0,
done: false,
capped: false,
stall: 0,
lastCount: 0,
lastClickAt: 0,
lastClickCount: -1,
lastLogAt: 0,
};
}
function _commentRoot() {
return document.querySelector(
'ytd-comments, #comments, #watch-discussion, #watch-discussion-section, ' +
'#watch7-discussion, .comment-section, .comments-section, .comment-list, .comments'
);
}
function _commentButtonText(el) {
if (!el) return '';
return [
el.getAttribute && el.getAttribute('aria-label'),
el.getAttribute && el.getAttribute('title'),
el.value,
el.textContent,
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
}
function _isUsableCommentButton(el) {
if (!el || !el.getAttribute) return false;
if (el.disabled || el.getAttribute('aria-disabled') === 'true') return false;
const text = _commentButtonText(el).toLowerCase();
const cls = (el.className || '').toString().toLowerCase();
if (!text && !/(load-more|continuation|paginator)/i.test(cls)) return false;
if (/reply|replies|repl|transcript|description|playlist|share|sort|newest|top comments/.test(text + ' ' + cls)) return false;
if (el.closest && el.closest(
'div.comment[data-id], ytd-comment-thread-renderer, ytd-comment-view-model, ' +
'.comment-thread-renderer, .comment-item'
)) return false;
if (!/(more comments|load more|show more|more|continuation|paginator|load-more)/.test(text + ' ' + cls)) return false;
try {
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.pointerEvents === 'none') return false;
} catch (_) {}
if (/loading|working|please wait/.test(text + ' ' + cls)) return false;
return true;
}
function _findCommentLoadButton() {
const root = _commentRoot();
if (!root) return null;
const selectors = [
'button',
'a',
'[role="button"]',
'input[type="button"]',
'input[type="submit"]',
'.yt-uix-load-more',
'.load-more-button',
'.load-more',
'.comment-section-renderer-paginator',
'.comments-pagination',
'.comment-pager',
'[class*="continuation"]',
'[class*="paginator"]',
].join(',');
const candidates = root.querySelectorAll(selectors);
for (const el of candidates) {
if (_isUsableCommentButton(el)) return el;
}
return null;
}
function _clickCommentLoadButton(btn) {
try {
btn.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window }));
btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }));
btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }));
btn.click();
return true;
} catch (_) {
try { btn.click(); return true; } catch (_) {}
}
return false;
}
function _commentDrainTick() {
if (!active || !location.pathname.startsWith('/watch')) return;
const key = _commentPageKey();
if (_commentDrain.key !== key) _resetCommentDrain();
if (_commentDrain.capped) return;
if (_commentDrain.done) {
const now = Date.now();
if (now - _commentDrain.lastLogAt < 3000) return;
_commentDrain.lastLogAt = now;
if (!_findCommentLoadButton()) return;
_commentDrain.done = false;
_commentDrain.stall = 0;
}
try { _commentSweep(); } catch (_) {}
const count = _collectCommentThreads().length;
const btn = _findCommentLoadButton();
if (!btn) {
if (count !== _commentDrain.lastCount) {
_commentDrain.lastCount = count;
_commentDrain.stall = 0;
} else if (_commentDrain.clicks) {
_commentDrain.stall++;
}
if (_commentDrain.clicks && _commentDrain.stall >= _COMMENT_DRAIN_STALL_TICKS) {
_commentDrain.done = true;
console.log('[bygone] comment drain done:',
'clicks=' + _commentDrain.clicks,
'threads=' + count,
'hidden=' + _cDiag.hidden,
'kept=' + _cDiag.kept,
'no-timestamp=' + _cDiag.notime);
}
return;
}
if (_commentDrain.clicks >= _COMMENT_DRAIN_MAX_CLICKS) {
_commentDrain.done = true;
_commentDrain.capped = true;
console.log('[bygone] comment drain stopped at cap:',
'clicks=' + _commentDrain.clicks,
'threads=' + count);
return;
}
const now = Date.now();
if (now - _commentDrain.lastClickAt < _COMMENT_DRAIN_MIN_CLICK_MS) return;
if (count === _commentDrain.lastClickCount &&
now - _commentDrain.lastClickAt < _COMMENT_DRAIN_WAIT_SAME_COUNT_MS) return;
if (_clickCommentLoadButton(btn)) {
_commentDrain.clicks++;
_commentDrain.lastClickAt = now;
_commentDrain.lastClickCount = count;
_commentDrain.lastCount = count;
_commentDrain.stall = 0;
[25, 80, 180].forEach(ms => setTimeout(() => { try { _commentSweep(); } catch (_) {} }, ms));
if (_commentDrain.clicks === 1 || now - _commentDrain.lastLogAt > 3000) {
_commentDrain.lastLogAt = now;
console.log('[bygone] comment drain:',
'clicks=' + _commentDrain.clicks,
'threads=' + count,
'button="' + _commentButtonText(btn).slice(0, 60) + '"');
}
}
}
// v382 comment subsystem replacement. These declarations intentionally
// shadow the legacy comment helpers above; the timers/observers below
// bind to these versions. V3 uses explicit "load more comments" batches,
// so the active model is: remove bad comment slots from the DOM, collapse
// their orphan reply loaders/spacers, then keep draining V3's paginator
// until the visible kept-comment list is replenished or exhausted.
const _COMMENT_V2_TARGET_VISIBLE = 35;
const _COMMENT_V2_MAX_CLICKS = 260;
const _COMMENT_V2_MIN_CLICK_MS = 260;
const _COMMENT_V2_WAIT_SAME_COUNT_MS = 900;
const _COMMENT_V2_STALL_TICKS = 36;
function _commentPageKey() {
const m = location.search.match(/[?&]v=([A-Za-z0-9_-]+)/);
return location.pathname + '|' + (m ? m[1] : location.search);
}
function _commentRoots() {
const selectors = [
'ytd-comments',
'#comments',
'#watch-comments',
'#watch-comments-section',
'#watch-discussion',
'#watch-discussion-section',
'#watch7-discussion',
'#watch7-discussion-contents',
'#comment-section-renderer',
'#comment-section-renderer-items',
'.comment-section',
'.comments-section',
'.comment-list',
'.comments-list',
'.comments'
];
const out = [];
const seen = new Set();
for (const sel of selectors) {
try {
document.querySelectorAll(sel).forEach(el => {
if (!seen.has(el)) { seen.add(el); out.push(el); }
});
} catch (_) {}
}
return out;
}
function _commentRoot() {
const roots = _commentRoots();
return roots[0] || null;
}
function _isCommentishArea(el) {
if (!el || !el.closest) return false;
return !!el.closest(
'ytd-comments, #comments, #watch-comments, #watch-comments-section, ' +
'#watch-discussion, #watch-discussion-section, #watch7-discussion, ' +
'#comment-section-renderer, .comment-section, .comments-section, ' +
'.comment-list, .comments-list, .comments'
);
}
function _commentThreadCount(el) {
if (!el || !el.querySelectorAll) return 0;
return el.querySelectorAll(
'div.comment[data-id], ytd-comment-thread-renderer, ytd-comment-view-model, ' +
'.comment-thread-renderer, .comment-item'
).length;
}
function _addCommentThread(out, seen, node) {
if (!node || seen.has(node) || !node.isConnected) return;
if (node.getAttribute && node.getAttribute('data-bygone-comment-hidden') === '1') return;
for (const old of Array.from(seen)) {
if (old.contains && old.contains(node)) return;
if (node.contains && node.contains(old)) {
seen.delete(old);
const idx = out.indexOf(old);
if (idx !== -1) out.splice(idx, 1);
}
}
seen.add(node);
out.push(node);
}
function _collectCommentThreads() {
const out = [];
const seen = new Set();
const roots = _commentRoots();
const scopes = roots.length ? roots : [document];
const selectors = [
'ytd-comment-thread-renderer',
'ytd-comment-view-model',
'.comment-thread-renderer',
'.comment-item',
'div.comment[data-id]'
].join(',');
for (const root of scopes) {
try {
root.querySelectorAll(selectors).forEach(node => {
if (node.matches && node.matches('ytd-comment-view-model') &&
node.closest('ytd-comment-thread-renderer')) return;
_addCommentThread(out, seen, node);
});
} catch (_) {}
}
return out;
}
function _commentTimeElement(thread) {
if (!thread || !thread.querySelectorAll) return null;
const direct = thread.querySelector(
'#published-time-text a, #published-time-text yt-formatted-string, ' +
'#published-time-text yt-core-attributed-string, ' +
'.published-time-text a, .published-time-text yt-core-attributed-string, ' +
'span.metadata span.detail a.detail_link:not(.detail_link_full), ' +
'.metadata a.detail_link:not(.detail_link_full), ' +
'a.detail_link:not(.detail_link_full), .metadata .time, ' +
'.comment .time, .time a, .time, .comment-time, .comment-date, ' +
'a.comment-author-time, time'
);
if (direct) return direct;
const scan = thread.querySelectorAll('a, span, div, time, yt-core-attributed-string, yt-formatted-string');
for (const el of scan) {
if (el.children && el.children.length > 2) continue;
const t = (el.textContent || '').trim();
if (!t || t.length > 80) continue;
if (_C_DATE_RE.test(t) || _C_ABS_DATE_RE.test(t)) return el;
}
return null;
}
function _commentApproxDate(raw) {
raw = (raw || '').trim();
const m = raw.match(_C_DATE_RE);
if (m) return DateHelper.approxPublishDate(m[0]);
const am = raw.match(_C_ABS_DATE_RE);
if (am) {
const d = new Date(am[0]);
if (!isNaN(d.getTime())) return d;
}
return null;
}
function _commentSlotFor(thread) {
if (!thread) return null;
if (thread.closest) {
const post = thread.closest('.post');
if (post && _commentThreadCount(post) <= 1) return post;
const ytd = thread.closest('ytd-comment-thread-renderer');
if (ytd) return ytd;
const item = thread.closest('.comment-thread-renderer, .comment-item');
if (item && _commentThreadCount(item) <= 1) return item;
const li = thread.closest('li');
if (li && _commentThreadCount(li) <= 1 && _isCommentishArea(li)) return li;
}
const root = _commentRoot();
let best = thread;
for (let el = thread; el && el !== document.body && el !== root; el = el.parentElement) {
const cls = (el.className || '').toString();
const tag = (el.tagName || '').toLowerCase();
const isSlot = tag === 'li' ||
/^(ytd-comment-thread-renderer|ytd-comment-view-model)$/i.test(tag) ||
/(^|\s)(post|comment-thread|comment-item|comment-entry|comment-container|comment-renderer)(\s|$)/i.test(cls);
if (isSlot && _commentThreadCount(el) <= 1) best = el;
if (el.parentElement === root) break;
}
return best;
}
function _hardCollapseNode(el) {
if (!el || !el.setAttribute) return;
try {
el.setAttribute('data-bygone-comment-hidden', '1');
el.setAttribute('aria-hidden', 'true');
el.style.setProperty('display', 'none', 'important');
el.style.setProperty('height', '0', 'important');
el.style.setProperty('min-height', '0', 'important');
el.style.setProperty('max-height', '0', 'important');
el.style.setProperty('overflow', 'hidden', 'important');
el.style.setProperty('margin', '0', 'important');
el.style.setProperty('padding', '0', 'important');
el.style.setProperty('border', '0', 'important');
el.style.setProperty('visibility', 'hidden', 'important');
} catch (_) {}
}
function _removeNode(el) {
if (!el) return false;
try { el.remove(); return true; } catch (_) {}
_hardCollapseNode(el);
return false;
}
function _isReplyLoaderNode(el) {
if (!el || !el.textContent) return false;
const cls = ((el.className || '') + ' ' + (el.id || '')).toString().toLowerCase();
const text = (el.textContent || '').replace(/\s+/g, ' ').trim().toLowerCase();
if (/more comments|load more comments|show more comments/.test(text)) return false;
if (/view all\s+\d*\s*repl|show all\s+\d*\s*repl|load\s+\d*\s*repl/.test(text)) return true;
return /comment-repl|reply|replies|\brepl\b/.test(cls) && /reply|replies|\brepl\b/.test(text);
}
function _removeAdjacentCommentJunk(slot) {
if (!slot) return;
let sib = slot.nextElementSibling;
let guard = 0;
while (sib && guard++ < 10) {
if (_commentThreadCount(sib) > 0 || (sib.matches && sib.matches('.post, ytd-comment-thread-renderer, .comment-thread-renderer, .comment-item'))) break;
const next = sib.nextElementSibling;
const text = (sib.textContent || '').replace(/\s+/g, ' ').trim();
const cls = ((sib.className || '') + ' ' + (sib.id || '')).toString();
if (_isReplyLoaderNode(sib) || (!text && /spacer|separator|reply|repl|loader/i.test(cls))) {
_removeNode(sib);
sib = next;
continue;
}
break;
}
}
function _collapseEmptyCommentAncestors(start, root) {
let el = start;
let guard = 0;
while (el && el !== document.body && el !== root && guard++ < 8) {
const next = el.parentElement;
const sig = ((el.className || '') + ' ' + (el.id || '')).toString();
const tag = (el.tagName || '').toLowerCase();
const slotish = tag === 'li' || /post|comment|thread|item|entry|container/i.test(sig);
if (!slotish) break;
const text = (el.textContent || '').replace(/\s+/g, '').trim();
if (_commentThreadCount(el) === 0 && text.length < 8) _removeNode(el);
else break;
el = next;
}
}
function _nukeCommentThread(thread) {
const slot = _commentSlotFor(thread);
if (!slot) return;
const root = _commentRoot();
const parent = slot.parentElement;
_removeAdjacentCommentJunk(slot);
_removeNode(slot);
_collapseEmptyCommentAncestors(parent, root);
}
function _pruneOldCommentShells() {
const roots = _commentRoots();
for (const root of roots) {
try {
root.querySelectorAll('[data-bygone-comment-hidden="1"]').forEach(_removeNode);
root.querySelectorAll('.post, .comment-thread-renderer, .comment-item, li').forEach(el => {
const text = (el.textContent || '').replace(/\s+/g, '').trim();
if (_commentThreadCount(el) === 0 && text.length < 8) _removeNode(el);
});
} catch (_) {}
}
}
function _visibleCommentCount() {
let n = 0;
for (const thread of _collectCommentThreads()) {
const slot = _commentSlotFor(thread) || thread;
if (!slot || !slot.isConnected) continue;
if (slot.getAttribute && slot.getAttribute('data-bygone-comment-hidden') === '1') continue;
try {
const cs = getComputedStyle(slot);
if (cs.display === 'none' || cs.visibility === 'hidden') continue;
} catch (_) {}
n++;
}
return n;
}
function _commentSweep() {
if (!active || !location.pathname.startsWith('/watch')) return;
let setDateStr;
try { setDateStr = Store.getCurrentDate(); } catch { return; }
const setDate = setDateStr ? new Date(setDateStr) : null;
if (!setDate || isNaN(setDate.getTime())) return;
const cutoff = new Date(setDate);
cutoff.setFullYear(cutoff.getFullYear() + 2);
const batch = { found: 0, removed: 0, kept: 0, notime: 0 };
for (const thread of _collectCommentThreads()) {
if (!thread || !thread.isConnected) continue;
if (thread.getAttribute && thread.getAttribute('data-bygone-comment-status') === 'kept') continue;
batch.found++;
const timeEl = _commentTimeElement(thread);
if (!timeEl) { batch.notime++; continue; }
const raw = (timeEl.textContent || '').trim();
const approx = _commentApproxDate(raw);
if (!approx || isNaN(approx.getTime())) { batch.notime++; continue; }
if (approx.getTime() > cutoff.getTime()) {
batch.removed++;
_cDiag.hidden++;
try { thread.setAttribute('data-bygone-comment-status', 'removed'); } catch (_) {}
_nukeCommentThread(thread);
continue;
}
batch.kept++;
_cDiag.kept++;
try {
thread.setAttribute('data-bygone-comment-status', 'kept');
const edited = /\(edited\)/i.test(raw) ? ' (edited)' : '';
timeEl.textContent = DateHelper.relativeToDate(approx, setDate) + edited;
} catch (_) {}
}
_cDiag.found += batch.found;
_cDiag.notime += batch.notime;
_pruneOldCommentShells();
if ((batch.removed || batch.kept || batch.notime) && !_commentSweep._lastReport) {
_commentSweep._lastReport = true;
console.log('[bygone] comments v382:',
'batch=' + batch.found,
'removed=' + batch.removed,
'kept=' + batch.kept,
'notime=' + batch.notime,
'visible=' + _visibleCommentCount(),
'cutoff=' + cutoff.toISOString().slice(0, 10));
setTimeout(() => { _commentSweep._lastReport = false; }, 3500);
}
}
function _resetCommentDrain() {
_cDiag = { found: 0, hidden: 0, kept: 0, notime: 0 };
_commentSweep._lastReport = false;
_commentDrain = {
key: _commentPageKey(),
clicks: 0,
done: false,
capped: false,
stall: 0,
lastCount: 0,
lastClickAt: 0,
lastClickCount: -1,
lastLogAt: 0,
};
}
function _commentButtonText(el) {
if (!el) return '';
return [
el.getAttribute && el.getAttribute('aria-label'),
el.getAttribute && el.getAttribute('title'),
el.value,
el.textContent,
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
}
function _commentClickable(el) {
if (!el) return null;
if (el.matches && el.matches('button, a, [role="button"], input[type="button"], input[type="submit"]')) return el;
return el.querySelector && el.querySelector('button, a, [role="button"], input[type="button"], input[type="submit"]');
}
function _isUsableCommentButton(el) {
const clickEl = _commentClickable(el);
if (!clickEl) return false;
if (clickEl.disabled || clickEl.getAttribute('aria-disabled') === 'true') return false;
if (clickEl.closest && clickEl.closest(
'div.comment[data-id], ytd-comment-thread-renderer, ytd-comment-view-model, ' +
'.comment-thread-renderer, .comment-item, .post'
)) return false;
const text = _commentButtonText(el).toLowerCase();
const sig = (text + ' ' + ((el.className || '') + ' ' + (el.id || '') + ' ' +
((clickEl.className || '') + ' ' + (clickEl.id || '')))).toLowerCase();
if (/reply|replies|\brepl\b|transcript|description|playlist|share|sort|newest|top comments/.test(sig)) return false;
const positive = /load more comments|show more comments|view more comments|more comments|load comments|show comments/.test(sig) ||
(_isCommentishArea(el) && /(load more|show more|view more|\bmore\b|continuation|paginator|load-more)/.test(sig));
if (!positive) return false;
try {
const cs = getComputedStyle(clickEl);
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.pointerEvents === 'none') return false;
} catch (_) {}
if (/loading|working|please wait/.test(sig)) return false;
return true;
}
function _findCommentLoadButton() {
const selectors = [
'button',
'a',
'[role="button"]',
'input[type="button"]',
'input[type="submit"]',
'.yt-uix-load-more',
'.yt-uix-button',
'.load-more-button',
'.load-more',
'.comment-section-renderer-paginator',
'.comments-pagination',
'.comment-pager',
'[class*="continuation"]',
'[class*="paginator"]',
'[class*="load-more"]'
].join(',');
const scopes = _commentRoots();
if (!scopes.length) scopes.push(document);
const seen = new Set();
for (const scope of scopes) {
let candidates = [];
try { candidates = Array.from(scope.querySelectorAll(selectors)); } catch (_) {}
for (const el of candidates) {
if (seen.has(el)) continue;
seen.add(el);
if (_isUsableCommentButton(el)) return _commentClickable(el);
}
}
return null;
}
function _clickCommentLoadButton(btn) {
if (!btn) return false;
try { btn.scrollIntoView({ block: 'center', inline: 'nearest' }); } catch (_) {}
try {
['pointerover', 'mouseover', 'pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'].forEach(type => {
btn.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
});
return true;
} catch (_) {
try { btn.click(); return true; } catch (__) {}
}
return false;
}
function _nearCommentBottom() {
const root = _commentRoot();
if (!root) return true;
try {
const r = root.getBoundingClientRect();
return r.bottom < window.innerHeight + 1100 ||
window.scrollY + window.innerHeight > document.documentElement.scrollHeight - 1400;
} catch (_) {
return true;
}
}
function _commentShouldLoadMore(visible) {
if (visible < _COMMENT_V2_TARGET_VISIBLE) return true;
return _nearCommentBottom();
}
function _commentDrainTick() {
if (!active || !location.pathname.startsWith('/watch')) return;
const key = _commentPageKey();
if (_commentDrain.key !== key) _resetCommentDrain();
if (_commentDrain.capped) return;
try { _commentSweep(); } catch (_) {}
const total = _collectCommentThreads().length;
const visible = _visibleCommentCount();
const btn = _findCommentLoadButton();
if (!btn) {
if (total !== _commentDrain.lastCount) {
_commentDrain.lastCount = total;
_commentDrain.stall = 0;
_commentDrain.done = false;
} else if (_commentDrain.clicks) {
_commentDrain.stall++;
}
if (_commentDrain.clicks && !_commentDrain.done && _commentDrain.stall >= _COMMENT_V2_STALL_TICKS) {
_commentDrain.done = true;
console.log('[bygone] comment drain exhausted:',
'clicks=' + _commentDrain.clicks,
'visible=' + visible,
'threads=' + total,
'removed=' + _cDiag.hidden,
'kept=' + _cDiag.kept);
}
return;
}
_commentDrain.done = false;
if (!_commentShouldLoadMore(visible)) return;
if (_commentDrain.clicks >= _COMMENT_V2_MAX_CLICKS) {
_commentDrain.done = true;
_commentDrain.capped = true;
console.log('[bygone] comment drain capped:',
'clicks=' + _commentDrain.clicks,
'visible=' + visible,
'threads=' + total);
return;
}
const now = Date.now();
if (now - _commentDrain.lastClickAt < _COMMENT_V2_MIN_CLICK_MS) return;
if (total === _commentDrain.lastClickCount &&
now - _commentDrain.lastClickAt < _COMMENT_V2_WAIT_SAME_COUNT_MS) return;
if (_clickCommentLoadButton(btn)) {
_commentDrain.clicks++;
_commentDrain.lastClickAt = now;
_commentDrain.lastClickCount = total;
_commentDrain.lastCount = total;
_commentDrain.stall = 0;
[60, 180, 420, 900].forEach(ms => setTimeout(() => {
try { _commentSweep(); } catch (_) {}
}, ms));
if (_commentDrain.clicks === 1 || now - _commentDrain.lastLogAt > 3000) {
_commentDrain.lastLogAt = now;
console.log('[bygone] comment drain loading:',
'clicks=' + _commentDrain.clicks,
'visible=' + visible,
'threads=' + total,
'button="' + _commentButtonText(btn).slice(0, 80) + '"');
}
}
}
let _commentObs = null;
let _commentObsRoot = null;
let _commentSweepQueued = false;
function _queueCommentSweep() {
if (_commentSweepQueued) return;
_commentSweepQueued = true;
setTimeout(() => {
_commentSweepQueued = false;
try { _commentSweep(); _commentDrainTick(); } catch (_) {}
}, 25);
}
function _installCommentObserver() {
if (!location.pathname.startsWith('/watch')) return;
const root = _commentRoot();
if (!root) { setTimeout(_installCommentObserver, 500); return; }
if (_commentObs && _commentObsRoot === root) return;
if (_commentObs) { try { _commentObs.disconnect(); } catch (_) {} }
_commentObsRoot = root;
_commentObs = new MutationObserver(_queueCommentSweep);
try { _commentObs.observe(root, { childList: true, subtree: true }); } catch (_) {}
_queueCommentSweep();
}
// ---- Heartbeats + nav burst --------------------------------
// Steady heartbeat. A persistent MutationObserver on the home grid
// was tried (v310) but it fought V3's own re-render of the feed —
// every fix we made triggered a V3 re-render which the observer
// caught and re-fixed, an unbounded loop. Sweeping is therefore
// POLL-based only: a steady heartbeat plus a time-BOUNDED burst
// after load / nav (see _burstGridSweep). Bounded = cannot loop.
setInterval(_sweep, 800);
setInterval(_sidebarSweep, 1000);
// Comment sweep — frequent so newly lazy-loaded / V3-re-rendered
// comment batches get filtered before the user reads them.
setInterval(_commentSweep, 300);
setInterval(_commentDrainTick, 80);
if (document.readyState !== 'loading') _installCommentObserver();
else document.addEventListener('DOMContentLoaded', _installCommentObserver);
window.addEventListener('yt-navigate-finish', (e) => {
if (e.detail && e.detail._bygonePoke) return;
_resetCommentDrain();
if (location.pathname.startsWith('/watch')) {
setTimeout(_installCommentObserver, 200);
} else if (_commentObs) {
try { _commentObs.disconnect(); } catch (_) {}
_commentObs = null;
_commentObsRoot = null;
}
[300, 800, 1600, 3000].forEach(ms => setTimeout(() => {
try { _commentSweep(); _commentDrainTick(); } catch (_) {}
}, ms));
});
// Heartbeat extender as a safety net when scroll events miss.
setInterval(() => { try { _maybeExtendSidebar(); } catch (_) {} }, 1500);
// Time-bounded grid burst: sweep every 120 ms for the first ~4 s
// after load / nav, then stop. This catches V3's load-time feed
// re-paints fast (so the "3-4 cycles" settle quickly and barely
// register) WITHOUT a persistent observer that could loop forever.
// Self-terminating + single-flight: it always ends.
let _gridBurstTimer = null;
function _burstGridSweep() {
if (_gridBurstTimer) { clearInterval(_gridBurstTimer); _gridBurstTimer = null; }
let elapsed = 0;
try { _sweep(); } catch (_) {}
_gridBurstTimer = setInterval(() => {
try { _sweep(); } catch (_) {}
elapsed += 120;
if (elapsed >= 4000) { clearInterval(_gridBurstTimer); _gridBurstTimer = null; }
}, 120);
}
if (document.readyState !== 'loading') _burstGridSweep();
else document.addEventListener('DOMContentLoaded', _burstGridSweep);
_onPoolReady(() => { _burstGridSweep(); _sidebarSweep(); _sweepPlayerRecommendations(); });
// SPA navigation re-renders without a reload — restart the bounded
// burst so replacements land fast after the new page paints.
let _lastV3Poke = 0;
function _navSweepBurst() {
if (_isHomeLikePath()) _burstHomeSpaFix();
else _burstGridSweep();
_pokeRetries = 0;
setTimeout(_maybePokeV3, 1200);
setTimeout(_maybePokeV3, 3000);
}
// Watch → home: V3 sometimes fails to render the top featured block;
// the card SLOTS are missing so our sweep can't do anything (nothing
// to sweep). Poke V3 by re-dispatching its own nav events. NOT a
// page reload, the URL doesn't change. Guarded so it can't loop:
// only on home, once per 8 s, retries up to 3 times.
let _pokeRetries = 0;
function _maybePokeV3() {
try {
if (!_checkV3()) return;
const path = location.pathname;
if (path !== '/' && path !== '') return;
if (!active || !videoPool.length) return;
if (Date.now() - _lastV3Poke < 8000) return;
const hasFeatured = !!document.querySelector('.lohp-large-shelf-container, .lohp-medium-shelf');
const cardCount = _findCards(document).length;
if (hasFeatured && cardCount >= 5) { _pokeRetries = 0; return; }
_lastV3Poke = Date.now();
try { window.dispatchEvent(new CustomEvent('yt-navigate-start', { detail: { pageType: 'home', url: location.href, _bygonePoke: true } })); } catch (_) {}
try { window.dispatchEvent(new CustomEvent('yt-navigate-finish', { detail: { pageType: 'home', url: location.href, response: {}, _bygonePoke: true } })); } catch (_) {}
try { window.dispatchEvent(new PopStateEvent('popstate', { state: history.state })); } catch (_) {}
[400, 900, 1600, 2600].forEach(ms => setTimeout(() => { try { _sweep(); } catch (_) {} }, ms));
_pokeRetries++;
if (_pokeRetries < 3) {
setTimeout(_maybePokeV3, 3000);
} else {
_pokeRetries = 0;
}
} catch (_) {}
}
window.addEventListener('yt-navigate-finish', (e) => {
if (e.detail && e.detail._bygonePoke) return;
startResponseScope();
_navSweepBurst();
});
window.addEventListener('popstate', (e) => {
if (e.detail && e.detail._bygonePoke) return;
startResponseScope();
_resetCommentDrain();
if (location.pathname.startsWith('/watch')) {
setTimeout(_installCommentObserver, 200);
} else if (_commentObs) {
try { _commentObs.disconnect(); } catch (_) {}
_commentObs = null;
_commentObsRoot = null;
}
_navSweepBurst();
});
// Watch page poke — V3's SPA nav often leaves the watch page
// metadata (channel, likes, description) empty. Detect this and
// re-dispatch nav events to kick V3 into rendering. Guarded:
// only on /watch, max 4 retries spaced 2s apart.
let _watchPokeRetries = 0;
let _lastWatchPoke = 0;
function _visibleEnough(el) {
if (!el) return false;
try {
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
const r = el.getBoundingClientRect();
if (r.width <= 1 || r.height <= 1) return false;
} catch (_) {}
return true;
}
function _watchMetaState() {
const ownerSelectors = [
'#owner ytd-channel-name',
'#owner #channel-name',
'#upload-info ytd-channel-name',
'#upload-info #channel-name',
'#watch7-content .yt-user-name',
'#watch-header .yt-user-name',
'.watch-main-col .yt-user-name',
'.watch-user-name',
'.yt-user-info',
'a[href^="/channel/UC"]'
];
const actionSelectors = [
'#top-level-buttons-computed',
'#menu-container',
'#menu ytd-toggle-button-renderer',
'ytd-menu-renderer',
'.watch-actions',
'.watch-action-buttons',
'#watch8-sentiment-actions',
'.like-button-renderer',
'.yt-uix-button-content'
];
const firstVisible = (selectors) => {
for (const sel of selectors) {
const els = document.querySelectorAll(sel);
for (const el of els) {
if (!_visibleEnough(el)) continue;
const text = (el.textContent || '').trim();
if (text || el.querySelector('button, a, img, yt-icon')) return el;
}
}
return null;
};
const owner = firstVisible(ownerSelectors);
const actions = firstVisible(actionSelectors);
return {
owner: !!owner,
actions: !!actions,
ownerSel: owner ? (owner.tagName.toLowerCase() + (owner.id ? '#' + owner.id : '') + (owner.className ? '.' + String(owner.className).split(/\s+/)[0] : '')) : '',
actionsSel: actions ? (actions.tagName.toLowerCase() + (actions.id ? '#' + actions.id : '') + (actions.className ? '.' + String(actions.className).split(/\s+/)[0] : '')) : ''
};
}
try { window.__bygoneWatchDiag = _watchMetaState; } catch (_) {}
try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow) unsafeWindow.__bygoneWatchDiag = _watchMetaState; } catch (_) {}
function _maybePokeWatch() {
try {
if (!_checkV3()) return;
if (!location.pathname.startsWith('/watch')) return;
if (!active || !videoPool.length) return;
if (Date.now() - _lastWatchPoke < 4000) return;
const metaState = _watchMetaState();
const hasMeta = metaState.owner && metaState.actions;
if (hasMeta) { _watchPokeRetries = 0; return; }
_lastWatchPoke = Date.now();
try { console.log('[bygone] watch metadata missing; poking V3', metaState); } catch (_) {}
try { window.dispatchEvent(new CustomEvent('yt-navigate-start', { detail: { pageType: 'watch', url: location.href, _bygonePoke: true } })); } catch (_) {}
try { window.dispatchEvent(new CustomEvent('yt-navigate-finish', { detail: { pageType: 'watch', url: location.href, response: {}, _bygonePoke: true } })); } catch (_) {}
try { window.dispatchEvent(new PopStateEvent('popstate', { state: history.state })); } catch (_) {}
_watchPokeRetries++;
if (_watchPokeRetries < 4) {
setTimeout(_maybePokeWatch, 2000);
} else {
_watchPokeRetries = 0;
}
} catch (_) {}
}
window.addEventListener('yt-navigate-finish', (e) => {
if (e.detail && e.detail._bygonePoke) return;
if (location.pathname.startsWith('/watch')) {
_watchPokeRetries = 0;
setTimeout(_maybePokeWatch, 1500);
setTimeout(_maybePokeWatch, 3500);
}
});
window.addEventListener('popstate', (e) => {
if (e.detail && e.detail._bygonePoke) return;
if (location.pathname.startsWith('/watch')) {
_watchPokeRetries = 0;
setTimeout(_maybePokeWatch, 1500);
}
});
// Sidebar MutationObserver — coalesced via rAF so V3's hundred-per-
// second sidebar mutations collapse to one sweep per animation frame.
let _sidebarSweepScheduled = false;
function _scheduleSidebarSweep() {
if (_sidebarSweepScheduled) return;
_sidebarSweepScheduled = true;
requestAnimationFrame(() => {
_sidebarSweepScheduled = false;
try { _sidebarSweep(); } catch (_) {}
});
}
function _installSidebarObserver() {
const target = document.getElementById('watch7-sidebar-contents')
|| document.getElementById('watch7-sidebar')
|| document.querySelector('#secondary');
if (!target) { setTimeout(_installSidebarObserver, 1000); return; }
if (_sidebarObs) { try { _sidebarObs.disconnect(); } catch (_) {} }
_sidebarObsTarget = target;
_sidebarObs = new MutationObserver(_scheduleSidebarSweep);
_sidebarObs.observe(target, { childList: true, subtree: true });
}
if (document.readyState !== 'loading') _installSidebarObserver();
else document.addEventListener('DOMContentLoaded', _installSidebarObserver);
window.addEventListener('yt-navigate-finish', () => setTimeout(_installSidebarObserver, 500));
window.addEventListener('popstate', () => setTimeout(_installSidebarObserver, 500));
// Aggressive post-nav polling for the watch sidebar (V3 may re-render
// it multiple times in the first few seconds). Single-flight.
let _burstTimer = null;
function _burstSidebarSweep() {
if (_burstTimer) { clearInterval(_burstTimer); _burstTimer = null; }
let elapsed = 0;
_burstTimer = setInterval(() => {
try { _sidebarSweep(); } catch (_) {}
try { _sweepPlayerRecommendations(); } catch (_) {}
elapsed += 200;
if (elapsed >= 10000) { clearInterval(_burstTimer); _burstTimer = null; }
}, 200);
}
window.addEventListener('yt-navigate-finish', _burstSidebarSweep);
window.addEventListener('popstate', _burstSidebarSweep);
if (location.pathname.startsWith('/watch')) {
if (document.readyState !== 'loading') _burstSidebarSweep();
else document.addEventListener('DOMContentLoaded', _burstSidebarSweep);
}
// ---- Click hijack ------------------------------------------
// V3 binds click handlers in closures over the ORIGINAL videoId at
// render time. Even if we rewrite href + the renderer's videoId
// afterwards, V3's bubble-phase handler navigates to the original.
// We capture BEFORE V3 sees the click and force navigation.
function _findClickTarget(target) {
let el = target, link = null, sweptCard = null, keepCard = null;
for (let i = 0; i < 14 && el && el !== document.body; i++) {
if (!link && el.tagName === 'A') {
const h = el.getAttribute('href') || '';
if (h.indexOf('/watch') !== -1 && h.indexOf('v=') !== -1) link = el;
}
if (!sweptCard && el.hasAttribute && el.hasAttribute('data-bygone-swept')) sweptCard = el;
if (!keepCard && el.hasAttribute && el.hasAttribute('data-bygone-keep')) keepCard = el;
el = el.parentElement;
}
return { link, sweptCard, keepCard };
}
function _resolveTarget(link, sweptCard, keepCard) {
if (sweptCard) {
const id = sweptCard.getAttribute('data-bygone-swept');
if (id) return id;
}
if (!link) return null;
const href = link.getAttribute('href') || '';
const m = href.match(/[?&]v=([A-Za-z0-9_-]+)/);
if (!m) return null;
const origId = m[1];
if (_poolIdsSet.has(origId)) return origId;
// Kept naturally-old recommendation — navigate to the REAL
// video, don't map it to a pool replacement.
if (keepCard) return origId;
const v = mapVideo(origId);
return v ? v.id : null;
}
// True when the user is on the search-results page. Click hijack
// must NOT rewrite link targets there — the results are genuine
// YouTube search hits already date-bounded via `before:` in the
// URL, so clicks should go to those real videos, not be remapped
// into our pool.
const _onResultsPage = () => {
const p = location.pathname;
return p === '/results' || p === '/results/';
};
function _isHomeNavClick(target) {
if (!target || !target.closest) return false;
const a = target.closest('a');
const href = a && (a.getAttribute('href') || '');
let homeHref = false;
if (href) {
try {
const u = new URL(href, location.origin);
homeHref = u.origin === location.origin && u.pathname === '/';
} catch (_) {
homeHref = href === '/' || href.indexOf('/?') === 0;
}
}
const logoHit = target.closest(
'#logo, #logo-container, #masthead-logo-link, #logo-icon, ' +
'.v3-logo, .yt-masthead-logo, .appbar-logo, ytd-topbar-logo-renderer, ' +
'a[title="YouTube"], a[aria-label="YouTube"], a[aria-label="Home"]'
);
return homeHref || !!logoHit;
}
function _scheduleHomeSpaFixFromClick() {
[80, 220, 500, 1000, 1800].forEach(ms => setTimeout(() => {
if (_isHomeLikePath()) _burstHomeSpaFix();
}, ms));
}
document.addEventListener('click', function (e) {
if (e.defaultPrevented) return;
const playerRecTarget = _playerRecClickTarget(e.target);
if (playerRecTarget && active && videoPool.length) {
e.preventDefault();
e.stopImmediatePropagation();
// Synthetic player clicks are autoplay/internal navigation.
// Blocking them removes native autoplay instead of letting a
// modern YouTube recommendation leak through.
if (!e.isTrusted) return;
const orig = _playerRecVideoId(playerRecTarget);
const video = _pickPlayerPoolVideo(orig);
if (video) _navigateToPoolVideo(video);
return;
}
// Only act on REAL user clicks. YouTube/V3 dispatch SYNTHETIC clicks
// for autoplay advance, end-screen cards and SPA prefetch; turning
// those into a full `location.href` load can cause a reload loop on
// the watch page. Untrusted clicks fall through to YouTube's own
// handling — and since the sweep already rewrites link hrefs to the
// pool video, navigation still lands on the right video. No feature
// is lost; only the synthetic-click → forced-reload path is cut.
if (!e.isTrusted) return;
if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
if (_isHomeNavClick(e.target)) {
_scheduleHomeSpaFixFromClick();
return;
}
if (!active || !videoPool.length) return;
// On search results: don't remap video IDs, but DO force a
// full page load so V3's broken SPA nav doesn't eat the watch
// page metadata (channel, likes, description).
if (_onResultsPage()) {
const { link } = _findClickTarget(e.target);
if (!link) return;
const href = link.getAttribute('href') || '';
if (href.indexOf('/watch') === -1) return;
e.preventDefault();
e.stopImmediatePropagation();
location.href = location.origin + href;
return;
}
const { link, sweptCard, keepCard } = _findClickTarget(e.target);
if (!sweptCard && !link) return;
const targetId = _resolveTarget(link, sweptCard, keepCard);
if (!targetId) return;
e.preventDefault();
e.stopImmediatePropagation();
try {
const pv = videoPool.find(v => v.id === targetId);
if (pv) {
Store.addClickEvent({
videoId: targetId, channelId: pv.channelId || null,
channel: pv.channel || '', title: pv.title || '',
source: pv.source || 'unknown', ts: Date.now(),
});
Store.markFeedClicked(targetId);
Store.recordSourceClick(pv.source || 'unknown');
}
} catch (_) {}
location.href = location.origin + '/watch?v=' + targetId;
}, true);
// Middle-click / ctrl-click: rewrite href so the browser-native
// new-tab open lands on the correct video.
document.addEventListener('auxclick', function (e) {
if (!active || !videoPool.length) return;
if (_onResultsPage()) return;
if (e.button !== 1) return;
const { link, sweptCard, keepCard } = _findClickTarget(e.target);
const targetId = _resolveTarget(link, sweptCard, keepCard);
if (!targetId || !link) return;
const href = link.getAttribute('href') || '';
link.setAttribute('href', href.replace(/([?&]v=)[A-Za-z0-9_-]+/, '$1' + targetId));
}, true);
return {
setVideos,
appendVideos,
setLazyFetcher,
isActive: () => active,
poolSize: () => videoPool.length,
usedCount: () => _usedReplacements.size,
origFetch: _origFetch,
sweep: _sweep,
mapVideo,
getPoolVideo: (id) => videoPool.find(v => v.id === id) || null,
getPoolIds: () => new Set(_poolIdsSet),
getPool: () => videoPool,
isKeptNatural: (id) => _keptNaturalIds.has(id),
rewriteCard: _rewriteCard,
findCards: _findCards,
refreshAllDates,
isChannelPage: _isChannelPage,
};
})();
// ============================================================
// CONFIG + VERSION
// ============================================================
const VERSION = 386;
const CONFIG = {
// maxSearchPages: how many continuation pages to walk per search query.
// The date filter drops a large share of each ~20-result page, so
// paging is what lets one era query yield well beyond a single page.
api: { maxResults: 25, cooldownMs: 250, maxSearchPages: 5 },
feed: {
dateWindowDays: 7,
// No forward grace on the video feed — only past-or-on-set-date
// uploads. (Comments have a separate 2-year cutoff applied in
// _commentCutoff; that one is intentional and stays.)
futureGraceDays: 0,
maxHomepageVideos: 300,
weights: {
subscriptions: 0.28,
searchTerms: 0.13,
categories: 0.18,
topics: 0.09,
similar: 0.14,
trending: 0.18,
},
},
cache: {
subscriptions: 14400000, // 4h
searchTerms: 7200000, // 2h
categories: 7200000,
topics: 7200000,
similar: 3600000,
trending: 1800000,
},
defaultGlobalNegatives: ['for kids', 'nursery rhymes', 'cocomelon', 'baby shark'],
installUrls: {
v3: 'https://vorapis.pages.dev/#/home/download',
starTube: 'https://greasyfork.org/scripts/485622-startube',
},
categories: {
1: 'Film & Animation', 2: 'Autos & Vehicles', 10: 'Music',
15: 'Pets & Animals', 17: 'Sports', 19: 'Travel & Events',
20: 'Gaming', 22: 'People & Blogs', 23: 'Comedy',
24: 'Entertainment', 25: 'News & Politics', 26: 'How-to & Style',
27: 'Education', 28: 'Science & Technology',
},
discoveryQueries: [
'', 'music video', 'trailer', 'funny', 'review', 'highlights',
'how to', 'compilation', 'reaction', 'vlog', 'tutorial', 'news',
'challenge', 'unboxing', 'animation', 'top 10', 'best of', 'cover',
'remix', 'documentary', 'interview', 'gameplay', 'montage',
],
};
// ============================================================
// STORE — GM_* persistence + profiles + rolling clock
// ============================================================
class Store {
static _migrated = false;
static _migrateFromWbt() {
if (this._migrated) return;
this._migrated = true;
try {
if (GM_getValue('bygone_migrated', false)) return;
const allKeys = GM_listValues();
let found = 0;
for (const k of allKeys) {
if (!k.startsWith('wbt_')) continue;
const newKey = 'bygone_' + k.slice(4);
if (GM_getValue(newKey, undefined) !== undefined) continue;
const val = GM_getValue(k, undefined);
if (val !== undefined) { GM_setValue(newKey, val); found++; }
}
GM_setValue('bygone_migrated', true);
if (found) console.log('[bygone] migrated', found, 'keys from wbt_ → bygone_');
} catch (e) { console.warn('[bygone] migration error', e); }
}
static _get(k, d) {
this._migrateFromWbt();
try { const r = GM_getValue(k, undefined); if (r === undefined) return d; return typeof r === 'string' ? JSON.parse(r) : r; }
catch { return d; }
}
static _set(k, v) { GM_setValue(k, JSON.stringify(v)); }
static _del(k) { GM_deleteValue(k); }
// Selected date
static getDate() { return this._get('bygone_date', null); }
static setDate(d) { this._set('bygone_date', d); }
// Sources
static getSubscriptions() { return this._get('bygone_subscriptions', []); }
static setSubscriptions(s) { this._set('bygone_subscriptions', s); }
static getSearchTerms() { return this._get('bygone_search_terms', []); }
static setSearchTerms(t) { this._set('bygone_search_terms', t); }
static getCategories() { return this._get('bygone_categories', [20, 10, 24]); }
static setCategories(c) { this._set('bygone_categories', c); }
static getTopics() { return this._get('bygone_topics', []); }
static setTopics(t) { this._set('bygone_topics', t); }
static getBlockedChannels(){ return this._get('bygone_blocked_channels', []); }
static setBlockedChannels(b){ this._set('bygone_blocked_channels', b); }
// Exact publish dates (ISO YYYY-MM-DD) keyed by video id, fetched from
// /next so relative dates can be computed precisely against the set
// date instead of guessed from year-granular strings. Persistent +
// in-memory cached because recalcForFeed reads it per card per sweep.
static _exactCache = null;
static getExactDates() { return this._get('bygone_exact_dates', {}); }
static getExactDate(id) {
if (!id) return null;
if (!this._exactCache) this._exactCache = this.getExactDates();
return this._exactCache[id] || null;
}
static addExactDates(map) {
if (!map) return;
if (!this._exactCache) this._exactCache = this.getExactDates();
Object.assign(this._exactCache, map);
this._set('bygone_exact_dates', this._exactCache);
}
// State
static isActive() { return this._get('bygone_active', true); }
static setActive(v) { this._set('bygone_active', v); }
static hasSeenDependencyPrompt(){ return this._get('bygone_dependency_prompt_seen', false); }
static markDependencyPromptSeen(){ this._set('bygone_dependency_prompt_seen', true); }
static isDiscoveryEnabled(){ return this._get('bygone_discovery', true); }
static setDiscoveryEnabled(v){ this._set('bygone_discovery', v); }
static isSimilarEnabled() { return this._get('bygone_similar_enabled', true); }
static setSimilarEnabled(v){ this._set('bygone_similar_enabled', v); }
static isLearningEnabled() { return this._get('bygone_learning', true); }
static setLearningEnabled(v){ this._set('bygone_learning', v); }
// Auto-sync bygone subscriptions to YouTube account
static isAutoSyncSubs() { return this._get('bygone_auto_sync_subs', true); }
static setAutoSyncSubs(v) { this._set('bygone_auto_sync_subs', v); }
// Track which channel IDs we've already synced to YouTube so we
// don't re-call subscribe on every panel render / page load.
static getSyncedSubIds() { return this._get('bygone_synced_sub_ids', []); }
static setSyncedSubIds(ids){ this._set('bygone_synced_sub_ids', ids); }
static markSubSynced(id) {
if (!id) return;
const ids = this.getSyncedSubIds();
if (!ids.includes(id)) { ids.push(id); this.setSyncedSubIds(ids); }
}
// Global negatives
static getGlobalNegatives() { return this._get('bygone_global_negatives', CONFIG.defaultGlobalNegatives.slice()); }
static setGlobalNegatives(v) { this._set('bygone_global_negatives', v); }
// Hidden videos
static getHiddenIds() { return this._get('bygone_hidden_ids', []); }
static setHiddenIds(ids) { this._set('bygone_hidden_ids', ids); }
static hideVideoId(id) {
const ids = this.getHiddenIds();
if (!ids.includes(id)) {
ids.push(id);
if (ids.length > 2000) ids.splice(0, ids.length - 2000);
this.setHiddenIds(ids);
}
}
// Profiles
static getProfiles() { return this._get('bygone_profiles', {}); }
static setProfiles(p) { this._set('bygone_profiles', p); }
static saveProfile(name) {
const profiles = this.getProfiles();
profiles[name] = {
date: this.getDate(),
subscriptions: this.getSubscriptions(),
searchTerms: this.getSearchTerms(),
categories: this.getCategories(),
topics: this.getTopics(),
blockedChannels: this.getBlockedChannels(),
customLogo: this.getCustomLogo(),
discovery: this.isDiscoveryEnabled(),
similar: this.isSimilarEnabled(),
learning: this.isLearningEnabled(),
globalNegatives: this.getGlobalNegatives(),
savedAt: Date.now(),
};
this.setProfiles(profiles);
}
// Create a fresh, empty profile (clean-install defaults) rather than
// snapshotting the current state. Keeps the current era date so the
// blank profile is immediately usable; everything else starts empty.
static createBlankProfile(name) {
const profiles = this.getProfiles();
profiles[name] = {
date: this.getDate() || '',
subscriptions: [],
searchTerms: [],
categories: [20, 10, 24],
topics: [],
blockedChannels: [],
customLogo: '',
discovery: true,
similar: true,
learning: true,
globalNegatives: CONFIG.defaultGlobalNegatives.slice(),
savedAt: Date.now(),
};
this.setProfiles(profiles);
}
static loadProfile(name) {
const p = this.getProfiles()[name];
if (!p) return false;
if (p.date) this.setDate(p.date);
if (p.subscriptions) this.setSubscriptions(p.subscriptions);
if (p.searchTerms) this.setSearchTerms(p.searchTerms);
if (p.categories) this.setCategories(p.categories);
if (p.topics) this.setTopics(p.topics);
if (p.blockedChannels) this.setBlockedChannels(p.blockedChannels);
if (p.discovery !== undefined) this.setDiscoveryEnabled(p.discovery);
if (p.similar !== undefined) this.setSimilarEnabled(p.similar);
if (p.learning !== undefined) this.setLearningEnabled(p.learning);
if (p.globalNegatives) this.setGlobalNegatives(p.globalNegatives);
if (p.customLogo) this.setCustomLogo(p.customLogo);
else this.clearCustomLogo();
this.stopClock();
return true;
}
static deleteProfile(name) {
const profiles = this.getProfiles();
delete profiles[name];
this.setProfiles(profiles);
}
static exportProfile(name) {
const p = this.getProfiles()[name];
return p ? JSON.stringify({ name, ...p }, null, 2) : null;
}
static importProfile(json) {
const data = JSON.parse(json);
const name = data.name;
if (!name) throw new Error('Profile has no name');
delete data.name;
const profiles = this.getProfiles();
profiles[name] = data;
this.setProfiles(profiles);
return name;
}
static exportAll() {
return JSON.stringify({
_bygone_export: true,
_version: VERSION,
_exportedAt: Date.now(),
date: this.getDate(),
active: this.isActive(),
subscriptions: this.getSubscriptions(),
searchTerms: this.getSearchTerms(),
categories: this.getCategories(),
topics: this.getTopics(),
blockedChannels: this.getBlockedChannels(),
globalNegatives: this.getGlobalNegatives(),
hiddenIds: this.getHiddenIds(),
customLogo: this.getCustomLogo(),
discovery: this.isDiscoveryEnabled(),
similar: this.isSimilarEnabled(),
learning: this.isLearningEnabled(),
autoSyncSubs: this.isAutoSyncSubs(),
syncedSubIds: this.getSyncedSubIds(),
profiles: this.getProfiles(),
clockActive: this.isClockActive(),
clockRealStart: this.getClockRealStart(),
clockSimStart: this.getClockSimStart(),
timeOffset: this.getTimeOffset(),
watchHistory: this.getWatchHistory(),
cachedInterests: this._get('bygone_cached_interests', null),
loadCount: this.getLoadCount(),
dislikes: this.getDislikes(),
impressions: this.getImpressions(),
seenIds: this.getSeenIds(),
clickEvents: this.getClickEvents(),
feedImpressions: this.getFeedImpressions(),
searchHistory: this.getSearchHistory(),
sourceStats: this._get('bygone_source_stats', {}),
sourceOrder: this.getSourceOrder(),
}, null, 2);
}
static importAll(json) {
const d = JSON.parse(json);
if (!d._bygone_export) throw new Error('Not a bygone-yt full export');
if (d.date !== undefined) this.setDate(d.date);
if (d.active !== undefined) this.setActive(d.active);
if (d.subscriptions) this.setSubscriptions(d.subscriptions);
if (d.searchTerms) this.setSearchTerms(d.searchTerms);
if (d.categories) this.setCategories(d.categories);
if (d.topics) this.setTopics(d.topics);
if (d.blockedChannels) this.setBlockedChannels(d.blockedChannels);
if (d.globalNegatives) this.setGlobalNegatives(d.globalNegatives);
if (d.hiddenIds) this.setHiddenIds(d.hiddenIds);
if (d.customLogo !== undefined) d.customLogo ? this.setCustomLogo(d.customLogo) : this.clearCustomLogo();
if (d.discovery !== undefined) this.setDiscoveryEnabled(d.discovery);
if (d.similar !== undefined) this.setSimilarEnabled(d.similar);
if (d.learning !== undefined) this.setLearningEnabled(d.learning);
if (d.autoSyncSubs !== undefined) this.setAutoSyncSubs(d.autoSyncSubs);
if (d.syncedSubIds) this.setSyncedSubIds(d.syncedSubIds);
if (d.profiles) this.setProfiles(d.profiles);
if (d.clockActive !== undefined) this.setClockActive(d.clockActive);
if (d.clockRealStart !== undefined) this.setClockRealStart(d.clockRealStart);
if (d.clockSimStart !== undefined) this.setClockSimStart(d.clockSimStart);
if (d.timeOffset !== undefined) this.setTimeOffset(d.timeOffset);
if (d.watchHistory) this.setWatchHistory(d.watchHistory);
if (d.cachedInterests) this._set('bygone_cached_interests', d.cachedInterests);
if (d.loadCount !== undefined) this._set('bygone_load_count', d.loadCount);
if (d.dislikes) this.setDislikes(d.dislikes);
if (d.impressions) this.setImpressions(d.impressions);
if (d.seenIds) this.setSeenIds(d.seenIds);
if (d.clickEvents) this.setClickEvents(d.clickEvents);
if (d.feedImpressions) this.setFeedImpressions(d.feedImpressions);
if (d.searchHistory) this.setSearchHistory(d.searchHistory);
if (d.sourceStats) this.setSourceStats(d.sourceStats);
if (d.sourceOrder) this._set('bygone_source_order', d.sourceOrder);
}
// Custom logo
static getCustomLogo() { return this._get('bygone_custom_logo', null); }
static setCustomLogo(d) { this._set('bygone_custom_logo', d); }
static clearCustomLogo() { this._del('bygone_custom_logo'); }
// APK page look controls. These use page localStorage so the APK's
// built-in WebExtension and this userscript share one setting source.
static _pageLocalStorage() {
try {
if (typeof unsafeWindow !== 'undefined' && unsafeWindow.localStorage) return unsafeWindow.localStorage;
} catch {}
try { return localStorage; } catch { return null; }
}
static _lsGet(k, d) {
const ls = this._pageLocalStorage();
if (!ls) return d;
try { const v = ls.getItem(k); return v === null ? d : v; } catch { return d; }
}
static _lsSet(k, v) {
const ls = this._pageLocalStorage();
if (!ls) return;
try { ls.setItem(k, String(v)); } catch {}
}
static getKioskDarkMode() { return this._lsGet('bygone_kiosk_dark', '0') !== '0'; }
static setKioskDarkMode(v){ this._lsSet('bygone_kiosk_dark', v ? '1' : '0'); }
static getKioskZoom() {
const n = Number(this._lsGet('bygone_kiosk_zoom', '1.38'));
if (!Number.isFinite(n)) return 1.38;
return Math.max(1, Math.min(1.8, n));
}
static setKioskZoom(v) {
const n = Number(v);
this._lsSet('bygone_kiosk_zoom', Math.max(1, Math.min(1.8, Number.isFinite(n) ? n : 1.38)));
}
// Rolling clock — sim time = simStart + (Date.now() - realStart), i.e.
// it advances exactly 1:1 with real elapsed wall-time from a single
// saved anchor (arm at 2014-05-02, reopen a week later → 2014-05-09).
// Date.now() is RAW (no offset) so the world-time sync can't skew the
// progression. Default ON and still toggleable; the anchor persists
// across reloads and is NEVER silently re-set, so the rate can't drift.
static isClockActive() { return this._get('bygone_clock_active', true); }
static setClockActive(v) { this._set('bygone_clock_active', v); }
static getClockRealStart() { return this._get('bygone_clock_real_start', 0); }
static setClockRealStart(t){ this._set('bygone_clock_real_start', t); }
static getClockSimStart() { return this._get('bygone_clock_sim_start', 0); }
static setClockSimStart(t) { this._set('bygone_clock_sim_start', t); }
static getTimeOffset() { return this._get('bygone_time_offset', 0); }
static setTimeOffset(v) { this._set('bygone_time_offset', v); }
// Parse YYYY-MM-DD as LOCAL midnight (not UTC — `new Date('2010-01-15')`
// is UTC midnight, the wrong day for non-UTC users).
static _parseLocalDate(s) {
if (!s) return new Date();
const m = String(s).match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
return m ? new Date(+m[1], +m[2] - 1, +m[3]) : new Date(s);
}
static _formatLocalDate(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dy = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${dy}`;
}
// Arm the rolling clock if it's active but has no anchor yet — fresh
// install, default-on, or after a stop cleared the anchors. Anchors to
// the current base date at the real wall-clock moment, so from here it
// tracks real elapsed time 1:1 and never silently re-anchors.
static _armClockIfNeeded() {
if (!this.isClockActive()) return;
if (this.getClockRealStart() && this.getClockSimStart()) return;
const base = this.getDate();
if (!base) return;
this.setClockSimStart(this._parseLocalDate(base).getTime());
this.setClockRealStart(Date.now());
}
static getCurrentDate() {
if (this.isClockActive()) {
this._armClockIfNeeded();
const rs = this.getClockRealStart();
const ss = this.getClockSimStart();
if (rs && ss) return this._formatLocalDate(new Date(ss + (Date.now() - rs)));
}
return this.getDate();
}
static getCurrentDateTime() {
if (this.isClockActive()) {
this._armClockIfNeeded();
const rs = this.getClockRealStart();
const ss = this.getClockSimStart();
if (rs && ss) return new Date(ss + (Date.now() - rs));
}
const d = this.getDate();
return d ? this._parseLocalDate(d) : new Date();
}
// Arm/re-arm at an explicit date: sim time = that date as of right now,
// then it rolls forward 1:1 with real elapsed wall-time.
static startClock(dateStr) {
const base = dateStr || this.getDate();
if (base) this.setDate(base);
this.setClockActive(true);
this.setClockSimStart(this._parseLocalDate(base).getTime());
this.setClockRealStart(Date.now());
}
// Freeze at the current advanced date and clear the anchors, so a later
// re-arm starts cleanly from the frozen date with no stale offset.
static stopClock() {
const cur = this.getCurrentDate();
this.setClockActive(false);
this.setDate(cur);
this.setClockRealStart(0);
this.setClockSimStart(0);
}
// Watch-history learning
static getWatchHistory() { return this._get('bygone_watch_history', []); }
static setWatchHistory(h) { this._set('bygone_watch_history', h); }
static addWatchEvent(ev) {
const h = this.getWatchHistory();
if (h.some(e => e.videoId === ev.videoId && (ev.ts - e.ts) < 300000)) return;
h.push(ev);
const cutoff = Date.now() - (60 * 86400000);
const pruned = h.filter(e => e.ts > cutoff);
if (pruned.length > 200) pruned.splice(0, pruned.length - 200);
this.setWatchHistory(pruned);
this._del('bygone_cached_interests');
}
static getCachedInterests() {
const cached = this._get('bygone_cached_interests', null);
if (cached) return cached;
const i = InterestModel.compute();
this._set('bygone_cached_interests', i);
return i;
}
static clearLearningData() {
this._del('bygone_watch_history');
this._del('bygone_cached_interests');
this._del('bygone_load_count');
}
static getLoadCount() { return this._get('bygone_load_count', 0); }
static incrementLoadCount(){ const c = this.getLoadCount() + 1; this._set('bygone_load_count', c); return c; }
// Dislike signal — blocks a channel and pushes its keywords down.
static getDislikes() { return this._get('bygone_dislikes', { channels: {}, keywords: {} }); }
static setDislikes(d) { this._set('bygone_dislikes', d); }
static recordDislike({ channelId, title }) {
const d = this.getDislikes();
if (channelId) d.channels[channelId] = (d.channels[channelId] || 0) + 2;
if (title) {
const stop = new Set(['the','a','an','in','on','at','to','for','of','and','or','is','it','my','we','i','you','this','that','with','from','by']);
const words = title.replace(/[^\w\s]/g, '').split(/\s+/)
.filter(w => w.length > 2 && !stop.has(w.toLowerCase()));
for (const w of words.slice(0, 5)) {
const k = w.toLowerCase();
d.keywords[k] = (d.keywords[k] || 0) + 1;
}
}
this.setDislikes(d);
this._del('bygone_cached_interests');
}
static getImpressions() { return this._get('bygone_impressions', {}); }
static setImpressions(i) { this._set('bygone_impressions', i); }
static recordImpressions(videoIds) {
const store = this.getImpressions();
const fi = this.getFeedImpressions();
const clicks = this.getClickEvents();
const now = Date.now();
const PARK_MS_COLD = 2 * 86400000;
const PARK_MS_CLICKED = 5 * 86400000;
const THRESHOLD_COLD = 5;
const THRESHOLD_CLICKED = 15;
const clickCounts = {};
for (const ev of clicks) {
clickCounts[ev.videoId] = (clickCounts[ev.videoId] || 0) + 1;
}
for (const id of videoIds) {
if (!store[id]) store[id] = { count: 0, hiddenUntil: 0, clicks: 0 };
const row = store[id];
if (row.hiddenUntil && row.hiddenUntil <= now) { row.count = 0; row.hiddenUntil = 0; }
row.count++;
const userClicks = clickCounts[id] || 0;
const fiClicked = fi[id] && fi[id].clicked;
row.clicks = userClicks;
if (userClicks >= 3 || fiClicked) {
if (row.count >= THRESHOLD_CLICKED) {
row.hiddenUntil = now + PARK_MS_CLICKED;
row.count = 0;
}
} else {
if (row.count >= THRESHOLD_COLD) {
row.hiddenUntil = now + PARK_MS_COLD;
row.count = 0;
}
}
}
const keys = Object.keys(store);
if (keys.length > 5000) {
const dormant = keys.filter(k => !store[k].hiddenUntil && store[k].count === 0);
for (const k of dormant.slice(0, keys.length - 4000)) delete store[k];
}
this.setImpressions(store);
}
static isImpressionHidden(id) {
const row = this.getImpressions()[id];
if (!row || !row.hiddenUntil) return false;
return row.hiddenUntil > Date.now();
}
// Seen videos (push to back of feed across refreshes)
static getSeenIds() { return this._get('bygone_seen_ids', []); }
static setSeenIds(ids) { this._set('bygone_seen_ids', ids); }
static addSeenIds(newIds) {
const ids = this.getSeenIds();
for (const id of newIds) if (!ids.includes(id)) ids.push(id);
if (ids.length > 300) ids.splice(0, ids.length - 300);
this.setSeenIds(ids);
}
// Click events (source-attributed engagement signal)
static getClickEvents() { return this._get('bygone_click_events', []); }
static setClickEvents(e) { this._set('bygone_click_events', e); }
static addClickEvent(ev) {
const events = this.getClickEvents();
if (events.some(e => e.videoId === ev.videoId && (ev.ts - e.ts) < 300000)) return;
events.push(ev);
const cutoff = Date.now() - (90 * 86400000);
const pruned = events.filter(e => e.ts > cutoff);
if (pruned.length > 500) pruned.splice(0, pruned.length - 500);
this.setClickEvents(pruned);
}
// Feed impressions (tracks shown-but-not-clicked for negative signals)
static getFeedImpressions() { return this._get('bygone_feed_impressions', {}); }
static setFeedImpressions(fi) { this._set('bygone_feed_impressions', fi); }
static recordFeedImpressions(videos) {
const store = this.getFeedImpressions();
const now = Date.now();
for (const v of videos) {
if (!v || !v.id) continue;
if (!store[v.id]) {
store[v.id] = {
impressions: 0, clicked: false,
channelId: v.channelId || null, channel: v.channel || '',
title: v.title || '', source: v.source || '',
firstSeen: now, lastSeen: now,
};
}
store[v.id].impressions++;
store[v.id].lastSeen = now;
}
const keys = Object.keys(store);
if (keys.length > 3000) {
const sorted = keys.filter(k => !store[k].clicked)
.sort((a, b) => store[a].lastSeen - store[b].lastSeen);
for (const k of sorted.slice(0, keys.length - 2500)) delete store[k];
}
this.setFeedImpressions(store);
}
static markFeedClicked(videoId) {
const store = this.getFeedImpressions();
if (store[videoId]) { store[videoId].clicked = true; this.setFeedImpressions(store); }
}
// Search history (auto-learned search queries)
static getSearchHistory() { return this._get('bygone_search_history', []); }
static setSearchHistory(h) { this._set('bygone_search_history', h); }
static addSearchQuery(query) {
if (!query || query.length < 3) return;
const clean = query.replace(/\s*before:\d{4}-\d{2}-\d{2}/g, '').trim();
if (!clean) return;
const h = this.getSearchHistory();
if (h.some(e => e.query.toLowerCase() === clean.toLowerCase() && (Date.now() - e.ts) < 3600000)) return;
h.push({ query: clean, ts: Date.now() });
const cutoff = Date.now() - (180 * 86400000);
const pruned = h.filter(e => e.ts > cutoff);
if (pruned.length > 200) pruned.splice(0, pruned.length - 200);
this.setSearchHistory(pruned);
}
// Source CTR stats (per-source impression/click counts)
static getSourceStats() {
const stats = this._get('bygone_source_stats', {});
const now = Date.now();
const DECAY_INTERVAL = 7 * 86400000;
let needsWrite = false;
for (const key of Object.keys(stats)) {
const s = stats[key];
if (s.lastUpdated && (now - s.lastUpdated) > DECAY_INTERVAL) {
s.impressions = Math.floor(s.impressions * 0.7);
s.clicks = Math.floor(s.clicks * 0.7);
s.lastUpdated = now;
needsWrite = true;
if (s.impressions < 5) { delete stats[key]; continue; }
}
}
if (needsWrite) this._set('bygone_source_stats', stats);
return stats;
}
static setSourceStats(s) { this._set('bygone_source_stats', s); }
static recordSourceImpression(source) {
if (!source) return;
const stats = this.getSourceStats();
if (!stats[source]) stats[source] = { impressions: 0, clicks: 0, lastUpdated: Date.now() };
stats[source].impressions++;
stats[source].lastUpdated = Date.now();
this.setSourceStats(stats);
}
static recordSourceClick(source) {
if (!source) return;
const stats = this.getSourceStats();
if (!stats[source]) stats[source] = { impressions: 0, clicks: 0, lastUpdated: Date.now() };
stats[source].clicks++;
stats[source].lastUpdated = Date.now();
this.setSourceStats(stats);
}
// Cache helpers
static getCacheEntry(key, ttlMs) {
const e = this._get(`bygone_cache_${key}`, null);
if (!e) return null;
if (Date.now() - e.ts > ttlMs) { this._del(`bygone_cache_${key}`); return null; }
return e.data;
}
static setCacheEntry(key, data) { this._set(`bygone_cache_${key}`, { ts: Date.now(), data }); }
// Source-source order (for drag-reorder support in UI)
static getSourceOrder() {
return this._get('bygone_source_order', ['subscriptions', 'searchTerms', 'topics', 'categories']);
}
static setSourceOrder(o) { this._set('bygone_source_order', o); }
}
// ============================================================
// DATE HELPER — relative-text ↔ Date
// ============================================================
class DateHelper {
static _msMap = {
year: 365.25 * 86400000,
month: 30.44 * 86400000,
week: 7 * 86400000,
day: 86400000,
hour: 3600000,
minute: 60000,
second: 1000,
};
static approxPublishDate(relativeText) {
if (!relativeText) return null;
const clean = relativeText.replace(/^Streamed\s+/i, '');
const m = clean.match(/(\d+)\s*(year|month|week|day|hour|minute|second)/i);
if (!m) return null;
return new Date(Date.now() - parseInt(m[1], 10) * (this._msMap[m[2].toLowerCase()] || 0));
}
static relativeToDate(publishDate, referenceDate, videoId) {
const diffMs = new Date(referenceDate).getTime() - new Date(publishDate).getTime();
if (diffMs < 0) {
const h = videoId ? this._hash(videoId) : this._hash(String(publishDate));
const d = (h % 13) + 1;
return d === 1 ? '1 day ago' : `${d} days ago`;
}
const days = Math.floor(diffMs / 86400000);
const years = Math.floor(days / 365.25);
const months = Math.floor(days / 30.44);
const weeks = Math.floor(days / 7);
const hours = Math.floor(diffMs / 3600000);
if (years >= 1) return years === 1 ? '1 year ago' : `${years} years ago`;
if (months >= 1) return months === 1 ? '1 month ago' : `${months} months ago`;
if (weeks >= 1) return weeks === 1 ? '1 week ago' : `${weeks} weeks ago`;
if (days >= 1) return days === 1 ? '1 day ago' : `${days} days ago`;
if (hours >= 1) return hours === 1 ? '1 hour ago' : `${hours} hours ago`;
return '1 day ago';
}
// Parse YouTube's exact watch-page date text ("Mar 18, 2008",
// "Premiered Mar 18, 2008", "Streamed live on Apr 5, 2007") to ISO.
static parseExactDateText(text) {
if (!text) return null;
const t = text.replace(/^(Premiered|Streamed live on|Started streaming on|Uploaded on|Published on)\s+/i, '').trim();
const d = new Date(t);
if (isNaN(d.getTime())) return null;
return d.toISOString().slice(0, 10);
}
static recalcRelative(innertubeText, setDateStr, videoId) {
if (!setDateStr) return innertubeText || '';
const prefix = /^Streamed\s+/i.test(innertubeText || '') ? 'Streamed ' : '';
const exact = videoId ? Store.getExactDate(videoId) : null;
if (exact) return prefix + this.relativeToDate(new Date(exact), setDateStr, videoId);
if (!innertubeText) return '';
const pub = this.approxPublishDate(innertubeText);
if (!pub) return innertubeText;
return prefix + this.relativeToDate(pub, setDateStr, videoId);
}
static _hash(str) {
let h = 0;
for (let i = 0; i < str.length; i++) { h = ((h << 5) - h) + str.charCodeAt(i); h |= 0; }
return Math.abs(h);
}
// Real recalc when meaningful (months/years), hash-spread otherwise.
// Variety prevents the feed from looking like "every video is 1 day ago".
static recalcForFeed(innertubeText, setDateStr, videoId) {
if (!setDateStr) return innertubeText || '';
const prefix = /^Streamed\s+/i.test(innertubeText || '') ? 'Streamed ' : '';
// Exact date known → compute the precise relative-to-set-date.
const exact = videoId ? Store.getExactDate(videoId) : null;
if (exact) return prefix + this.relativeToDate(new Date(exact), setDateStr, videoId);
if (!innertubeText) return '';
const pub = this.approxPublishDate(innertubeText);
if (!pub) return innertubeText;
const real = this.relativeToDate(pub, setDateStr, videoId);
if (real.includes('year') || real.includes('month')) return prefix + real;
// The relative string is year-granular, so a video within ~a year
// of the set date can't be relativized precisely and otherwise
// collapses to a fake "1 day ago" (negative diff from rounding).
// The pool is date-bounded to roughly the ~6 months before the set
// date, so spread these across a realistic recency window
// (days → weeks → months) keyed off the id for stable variety —
// instead of making every near-set-date video look brand new.
const h = this._hash(videoId || innertubeText);
const days = (h % 168) + 1; // 1..168 days (~5.5 months)
if (days <= 1) return prefix + '1 day ago';
if (days <= 6) return prefix + `${days} days ago`;
if (days === 7) return prefix + '1 week ago';
if (days < 30) return prefix + `${Math.floor(days / 7)} weeks ago`;
const months = Math.floor(days / 30.44);
return prefix + (months <= 1 ? '1 month ago' : `${months} months ago`);
}
}
// ============================================================
// INTEREST MODEL — channel/keyword scoring from watch history
// ============================================================
class InterestModel {
static _YT_STOP = new Set([
'official','video','full','new','part','episode','ep',
'hd','4k','live','stream','clip','trailer','season',
'ft','feat','vs','vol','remix','edit','reupload',
'deleted','original','extended','version','subtitles',
]);
static _STOP = new Set([
'the','a','an','in','on','at','to','for','of','and','or','is','it',
'my','we','i','you','this','that','with','from','by','be','as','are',
'was','were','been','has','have','had','do','does','did','but','not',
'so','if','no','yes',
]);
static compute() {
const watches = Store.getWatchHistory();
const dislikes = Store.getDislikes();
const now = Date.now();
const channels = {};
const keywords = {};
for (const w of watches) {
const ageDays = (now - w.ts) / 86400000;
const decay = Math.pow(0.5, ageDays / 7);
if (w.channelId) {
if (!channels[w.channelId]) channels[w.channelId] = { name: w.channel, score: 0 };
channels[w.channelId].score += decay;
}
if (w.title) {
const kws = w.title.replace(/[^\w\s]/g, '').split(/\s+/)
.filter(x => x.length > 2 && !this._STOP.has(x.toLowerCase()) && !this._YT_STOP.has(x.toLowerCase()));
for (const kw of kws.slice(0, 5)) {
const lower = kw.toLowerCase();
if (!keywords[lower]) keywords[lower] = { score: 0 };
keywords[lower].score += decay;
}
}
}
for (const [id, p] of Object.entries(dislikes.channels || {})) if (channels[id]) channels[id].score -= p;
for (const [kw, p] of Object.entries(dislikes.keywords || {})) if (keywords[kw]) keywords[kw].score -= p;
try {
const neg = InterestModel.computeNegativeSignals();
for (const cc of neg.coldChannels) {
if (channels[cc.channelId]) channels[cc.channelId].score -= Math.min(cc.skipScore * 0.5, 3);
}
for (const kw of neg.autoNegKeywords) {
if (keywords[kw]) keywords[kw].score -= 1;
}
} catch (_) {}
return { channels, keywords };
}
static getLearnedChannels(i) {
return Object.entries(i.channels)
.filter(([_, c]) => c.score >= 2)
.sort((a, b) => b[1].score - a[1].score)
.slice(0, 25)
.map(([id, c]) => ({ channelId: id, name: c.name, score: c.score }));
}
static getLearnedKeywords(i) {
return Object.entries(i.keywords)
.filter(([_, k]) => k.score >= 3)
.sort((a, b) => b[1].score - a[1].score)
.slice(0, 15)
.map(([kw, k]) => ({ keyword: kw, score: k.score }));
}
static computeNegativeSignals() {
const fi = Store.getFeedImpressions();
const channelSkips = {};
const keywordSkips = {};
for (const [_, data] of Object.entries(fi)) {
if (data.impressions < 4) continue;
const isSkip = !data.clicked;
if (data.channelId) {
if (!channelSkips[data.channelId]) channelSkips[data.channelId] = { name: data.channel, shown: 0, clicked: 0 };
channelSkips[data.channelId].shown++;
if (!isSkip) channelSkips[data.channelId].clicked++;
}
if (isSkip && data.title) {
const words = data.title.replace(/[^\w\s]/g, '').split(/\s+/)
.filter(w => w.length > 2 && !this._STOP.has(w.toLowerCase()) && !this._YT_STOP.has(w.toLowerCase()));
for (const w of words.slice(0, 5)) {
const k = w.toLowerCase();
if (!keywordSkips[k]) keywordSkips[k] = { shown: 0 };
keywordSkips[k].shown++;
}
}
}
const coldChannels = Object.entries(channelSkips)
.filter(([_, c]) => c.shown >= 3 && c.clicked === 0)
.map(([id, c]) => ({ channelId: id, name: c.name, skipScore: c.shown }));
const autoNegKeywords = Object.entries(keywordSkips)
.filter(([_, k]) => k.shown >= 3)
.sort((a, b) => b[1].shown - a[1].shown)
.slice(0, 20)
.map(([kw]) => kw);
return { coldChannels, autoNegKeywords };
}
static inferLanguageHints() {
const watches = Store.getWatchHistory();
const clicks = Store.getClickEvents();
const allTitles = [...watches, ...clicks].map(e => e.title || '').filter(Boolean);
let latin = 0, total = 0;
for (const title of allTitles) {
for (const ch of title) {
const cp = ch.codePointAt(0);
total++;
if (cp >= 0x0041 && cp <= 0x024F) latin++;
}
}
const englishMarkers = /\b(the|and|for|with|this|that|from|have|will|your|about)\b/i;
let englishScore = 0;
for (const t of allTitles) if (englishMarkers.test(t)) englishScore++;
const likelyEnglish = allTitles.length > 5 && (englishScore / allTitles.length) > 0.5;
const latinDominant = total > 100 && (latin / total) > 0.8;
return { latinDominant, likelyEnglish };
}
}
// ============================================================
// YOUTUBE API — InnerTube (no API keys, uses page's auth cookies
// via the auth-trick: SAPISIDHASH header on GM_xmlhttpRequest).
// V3-only: always use GM_xmlhttpRequest. The page-fetch path is
// dropped because V3's Response patch would otherwise re-enter
// our interceptor on our own outbound calls.
// ============================================================
class YouTubeAPI {
constructor() {
this._lastRequest = 0;
this._configCache = null;
this._configCacheTs = 0;
}
_getCookie(name) {
try {
const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const m = win.document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
return m ? decodeURIComponent(m[1]) : null;
} catch { return null; }
}
async _getSapisidHash(origin) {
const sapisid = this._getCookie('SAPISID') || this._getCookie('__Secure-3PAPISID');
if (!sapisid) return null;
const ts = Math.floor(Date.now() / 1000);
try {
const data = new TextEncoder().encode(`${ts} ${sapisid} ${origin}`);
const buf = await crypto.subtle.digest('SHA-1', data);
const hex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
return `SAPISIDHASH ${ts}_${hex}`;
} catch { return null; }
}
_getConfig() {
if (this._configCache && Date.now() - this._configCacheTs < 30000) return this._configCache;
let cfg = null;
try {
const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
cfg = win.ytcfg && win.ytcfg.data_;
} catch {}
let fullContext = null;
if (cfg && cfg.INNERTUBE_CONTEXT) {
try { fullContext = JSON.parse(JSON.stringify(cfg.INNERTUBE_CONTEXT)); } catch {}
}
this._configCache = {
apiKey: (cfg && cfg.INNERTUBE_API_KEY) || 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
clientVersion: (cfg && cfg.INNERTUBE_CLIENT_VERSION) || '2.20260301.00.00',
fullContext,
};
this._configCacheTs = Date.now();
return this._configCache;
}
_buildContext(cfg) {
return cfg.fullContext || { client: { clientName: 'WEB', clientVersion: cfg.clientVersion, hl: 'en', gl: 'US' } };
}
_postViaGM(url, body, headers) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST', url, headers, data: JSON.stringify(body), timeout: 15000,
onload(res) {
if (res.status >= 200 && res.status < 300) {
try { resolve(JSON.parse(res.responseText)); }
catch { reject(new Error('Invalid JSON')); }
} else {
const err = new Error(`InnerTube HTTP ${res.status}`);
err.status = res.status;
reject(err);
}
},
onerror() { reject(new Error('Network error')); },
ontimeout() { reject(new Error('Timed out')); },
});
});
}
async _rateLimit() {
const wait = CONFIG.api.cooldownMs - (Date.now() - this._lastRequest);
if (wait > 0) await new Promise(r => setTimeout(r, wait));
this._lastRequest = Date.now();
}
async _post(endpoint, body) {
await this._rateLimit();
const cfg = this._getConfig();
const url = `https://www.youtube.com/youtubei/v1/${endpoint}?key=${cfg.apiKey}&prettyPrint=false`;
const fullBody = { context: this._buildContext(cfg), ...body };
const headers = {
'Content-Type': 'application/json',
'X-YouTube-Client-Name': '1',
'X-YouTube-Client-Version': cfg.clientVersion,
'X-Origin': 'https://www.youtube.com',
'Origin': 'https://www.youtube.com',
'Referer': 'https://www.youtube.com/',
};
const auth = await this._getSapisidHash('https://www.youtube.com');
if (auth) { headers['Authorization'] = auth; headers['X-Goog-AuthUser'] = '0'; }
try { return await this._postViaGM(url, fullBody, headers); }
catch (err) {
if (err.status === 403 || (err.status >= 500 && err.status < 600)) {
this._configCache = null;
await new Promise(r => setTimeout(r, 1000));
const cfg2 = this._getConfig();
headers['X-YouTube-Client-Version'] = cfg2.clientVersion;
const auth2 = await this._getSapisidHash('https://www.youtube.com');
if (auth2) headers['Authorization'] = auth2;
fullBody.context = this._buildContext(cfg2);
return await this._postViaGM(`https://www.youtube.com/youtubei/v1/${endpoint}?key=${cfg2.apiKey}&prettyPrint=false`, fullBody, headers);
}
throw err;
}
}
// ---- Parsers -----------------------------------------------
_parseViewCount(t) {
if (!t) return 0;
const m = t.replace(/,/g, '').toLowerCase().match(/([\d.]+)\s*([kmb])?/);
if (!m) return 0;
const n = parseFloat(m[1]);
return m[2] === 'b' ? n * 1e9 : m[2] === 'm' ? n * 1e6 : m[2] === 'k' ? n * 1e3 : n;
}
_parseSearchResults(data) {
const out = [];
const pushVideo = (v) => {
if (!v || !v.videoId || !/^[A-Za-z0-9_-]{11}$/.test(v.videoId)) return;
const viewText = v.viewCountText?.simpleText || v.viewCountText?.runs?.[0]?.text || '';
out.push({
id: v.videoId,
title: v.title?.runs?.[0]?.text || '',
channel: v.ownerText?.runs?.[0]?.text || v.longBylineText?.runs?.[0]?.text || '',
channelId: v.ownerText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId || '',
thumbnail: v.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
viewCount: this._parseViewCount(viewText),
viewCountFormatted: viewText || '0 views',
relativeDate: v.publishedTimeText?.simpleText || '',
duration: v.lengthText?.simpleText
|| v.lengthText?.accessibility?.accessibilityData?.label
|| (v.thumbnailOverlays || []).map(o => o?.thumbnailOverlayTimeStatusRenderer?.text?.simpleText).filter(Boolean)[0]
|| '',
});
};
const scanItems = (items) => {
for (const item of items || []) pushVideo(item.videoRenderer);
};
try {
// Initial search response.
const sections = data?.contents?.twoColumnSearchResultsRenderer
?.primaryContents?.sectionListRenderer?.contents || [];
for (const section of sections) scanItems(section?.itemSectionRenderer?.contents);
// Continuation response (page 2+).
for (const c of data?.onResponseReceivedCommands || []) {
for (const cont of c?.appendContinuationItemsAction?.continuationItems || []) {
scanItems(cont?.itemSectionRenderer?.contents);
}
}
} catch (e) { console.warn('[bygone] parse error', e.message); }
return out;
}
_parsePlaylistResults(data) {
const out = [];
try {
const tabs = data?.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
let items = [];
for (const tab of tabs) {
const contents = tab?.tabRenderer?.content?.sectionListRenderer?.contents
|| tab?.tabRenderer?.content?.richGridRenderer?.contents || [];
for (const section of contents) {
const sectionItems = section?.itemSectionRenderer?.contents?.[0]
?.playlistVideoListRenderer?.contents || [];
items.push(...sectionItems);
}
}
for (const item of items) {
const v = item.playlistVideoRenderer;
if (!v || !v.videoId || !/^[A-Za-z0-9_-]{11}$/.test(v.videoId)) continue;
const viewText = v.videoInfo?.runs?.[0]?.text || '';
out.push({
id: v.videoId,
title: v.title?.runs?.[0]?.text || v.title?.simpleText || '',
channel: v.shortBylineText?.runs?.[0]?.text || '',
channelId: v.shortBylineText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId || '',
thumbnail: v.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
viewCount: this._parseViewCount(viewText),
viewCountFormatted: viewText || '0 views',
relativeDate: v.videoInfo?.runs?.[2]?.text || '',
duration: v.lengthText?.simpleText || '',
});
}
} catch (e) { console.warn('[bygone] playlist parse error', e.message); }
return out;
}
_parseRelatedResults(data) {
const out = [];
try {
const items = data?.contents?.twoColumnWatchNextResults?.secondaryResults?.secondaryResults?.results || [];
for (const item of items) {
const v = item?.compactVideoRenderer;
if (!v || !v.videoId) continue;
const viewText = v.viewCountText?.simpleText || v.viewCountText?.runs?.[0]?.text || v.shortViewCountText?.simpleText || '';
out.push({
id: v.videoId,
title: v.title?.simpleText || v.title?.runs?.[0]?.text || '',
channel: v.longBylineText?.runs?.[0]?.text || v.shortBylineText?.runs?.[0]?.text || '',
channelId: v.longBylineText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId
|| v.shortBylineText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId || '',
thumbnail: v.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
viewCount: this._parseViewCount(viewText),
viewCountFormatted: viewText || '0 views',
relativeDate: v.publishedTimeText?.simpleText || '',
duration: v.lengthText?.simpleText || '',
});
}
} catch (e) { console.warn('[bygone] related parse error', e.message); }
return out;
}
_parseChannelResults(data) {
try {
const sections = data?.contents?.twoColumnSearchResultsRenderer
?.primaryContents?.sectionListRenderer?.contents || [];
for (const section of sections) {
for (const item of section?.itemSectionRenderer?.contents || []) {
if (item.channelRenderer) {
return {
id: item.channelRenderer.channelId,
name: item.channelRenderer.title?.simpleText
|| item.channelRenderer.title?.runs?.[0]?.text || '',
};
}
}
}
} catch {}
return null;
}
// ---- Search query builder ----------------------------------
_buildDateQuery(query, after, before, negatives) {
let q = query || '';
if (after) q += ` after:${(after instanceof Date ? after : new Date(after)).toISOString().split('T')[0]}`;
if (before) q += ` before:${(before instanceof Date ? before : new Date(before)).toISOString().split('T')[0]}`;
if (Array.isArray(negatives)) {
for (const n of negatives) {
const t = String(n || '').trim();
if (!t) continue;
q += /\s/.test(t) ? ` -"${t}"` : ` -${t}`;
}
}
return q.trim();
}
// ---- Public methods ----------------------------------------
_allNegatives(extra) {
const neg = [...(Array.isArray(extra) ? extra : []), ...Store.getGlobalNegatives()];
try {
const lang = InterestModel.inferLanguageHints();
if (lang.likelyEnglish) neg.push('ITA', 'dublado', 'doblado', 'español', 'en español', 'hindi', 'tamil', 'telugu', 'bhojpuri', 'bollywood', 'русский', 'arabic', 'legendado', 'sottotitoli');
} catch (_) {}
return neg;
}
async searchVideos(query, { publishedAfter, publishedBefore, maxResults, order = 'relevance', categoryId, negatives, strictMatch, maxPages } = {}) {
const allNeg = this._allNegatives(negatives);
let q = this._buildDateQuery(query, publishedAfter, publishedBefore, allNeg);
if (categoryId && CONFIG.categories[categoryId]) q = `${CONFIG.categories[categoryId]} ${q}`.trim();
const params = order === 'viewCount' ? 'CAMSAhAB' : 'EgIQAQ==';
const want = maxResults || CONFIG.api.maxResults;
const pageCap = Math.max(1, maxPages || CONFIG.api.maxSearchPages || 1);
const strictRe = strictMatch
? new RegExp(`\\b${strictMatch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i')
: null;
// CLIENT-SIDE DATE BOUNDING. YouTube applies the `before:`/`after:`
// search operators loosely on broad/popular queries and returns
// modern videos anyway, so each page is filtered here. Undated
// results are kept (rare; matches the channel path) — modern
// pollution carries a recent relative date and is dropped.
const aMin = publishedAfter ? new Date(publishedAfter) : null;
const aMax = publishedBefore ? new Date(publishedBefore) : null;
const inWindow = (v) => {
if (!aMin && !aMax) return true;
const d = DateHelper.approxPublishDate(v.relativeDate);
if (!d) return true;
if (aMin && d < aMin) return false;
if (aMax && d > aMax) return false;
return true;
};
// PAGINATE via continuation tokens. The first page of a search is
// only ~20 results; the response ends with a continuation token
// that fetches the next ~20 (still inside the date window), and so
// on. The date filter drops a lot per page, so we keep paging until
// we have `want` valid results or run out of pages/token. This is
// what lets a single era query surface far more than one page.
const collected = [];
const seen = new Set();
let token = null;
for (let page = 0; page < pageCap; page++) {
let data;
try { data = await this._post('search', token ? { continuation: token } : { query: q, params }); }
catch (_) { break; }
const results = this._parseSearchResults(data);
for (const v of results) {
if (!v || !v.id || seen.has(v.id)) continue;
if (strictRe && !strictRe.test(v.title || '')) continue;
if (!inWindow(v)) continue;
seen.add(v.id);
collected.push(v);
}
if (collected.length >= want) break;
token = this._extractSearchContinuation(data);
if (!token) break;
}
return collected.slice(0, want);
}
// Pull the search continuation token from either an initial search
// response (continuationItemRenderer inside the sectionList) or a
// continuation response (appendContinuationItemsAction).
_extractSearchContinuation(data) {
const fromItems = (items) => {
for (const it of items || []) {
const t = it?.continuationItemRenderer?.continuationEndpoint
?.continuationCommand?.token;
if (t) return t;
}
return null;
};
try {
const sects = data?.contents?.twoColumnSearchResultsRenderer
?.primaryContents?.sectionListRenderer?.contents || [];
const t1 = fromItems(sects);
if (t1) return t1;
for (const s of sects) {
const t = fromItems(s?.itemSectionRenderer?.contents);
if (t) return t;
}
for (const c of data?.onResponseReceivedCommands || []) {
const t = fromItems(c?.appendContinuationItemsAction?.continuationItems);
if (t) return t;
}
} catch (_) {}
return null;
}
async getChannelVideos(channelName, { publishedAfter, publishedBefore, maxResults, channelId } = {}) {
if (channelId && channelId.startsWith('UC')) {
try {
const data = await this._post('browse', { browseId: `VL${'UU' + channelId.slice(2)}` });
const results = this._parsePlaylistResults(data);
const filtered = results.filter(v => {
const a = DateHelper.approxPublishDate(v.relativeDate);
if (!a) return true;
if (publishedAfter && a < new Date(publishedAfter)) return false;
if (publishedBefore && a > new Date(publishedBefore)) return false;
return true;
});
if (filtered.length) return filtered.slice(0, maxResults || CONFIG.api.maxResults);
} catch (e) { /* fall through to search */ }
}
const q = this._buildDateQuery(channelName, publishedAfter, publishedBefore, this._allNegatives());
const data = await this._post('search', { query: q, params: 'EgIQAQ==' });
let results = this._parseSearchResults(data);
if (channelId) {
results = results.filter(v => v.channelId === channelId);
} else {
const lc = channelName.toLowerCase();
results = results.filter(v => (v.channel || '').toLowerCase() === lc);
}
return results.slice(0, maxResults || CONFIG.api.maxResults);
}
async getPopularByCategory(categoryId, opts) {
return this.searchVideos('', { ...opts, categoryId, order: 'relevance' });
}
async getRelatedVideos(videoId) {
try { return this._parseRelatedResults(await this._post('next', { videoId })); }
catch { return []; }
}
// Exact publish date (ISO) for a video from its watch page's primary
// info date text. /next is already used for related videos and is less
// bot-gated than /player.
async fetchExactDate(videoId) {
if (!videoId) return null;
try {
const data = await this._post('next', { videoId });
const conts = data?.contents?.twoColumnWatchNextResults
?.results?.results?.contents || [];
for (const c of conts) {
const dt = c?.videoPrimaryInfoRenderer?.dateText;
const text = dt?.simpleText || (dt?.runs || []).map(r => r.text).join('');
const iso = DateHelper.parseExactDateText(text);
if (iso) return iso;
}
} catch (_) {}
return null;
}
async resolveChannel(input) {
const q = input.startsWith('@') ? input : `"${input}"`;
const data = await this._post('search', { query: q, params: 'EgIQAg==' });
return this._parseChannelResults(data);
}
// Subscribe (or unsubscribe) the logged-in user to one or more
// channels via InnerTube. Uses the same auth-trick as everything
// else, so the user must already be logged in to YouTube in this
// browser session.
async subscribeToChannel(channelId) {
if (!channelId) return false;
try {
await this._post('subscription/subscribe', { channelIds: [channelId] });
return true;
} catch (e) {
console.warn('[bygone] subscribe failed for', channelId, '—', e.message);
return false;
}
}
async unsubscribeFromChannel(channelId) {
if (!channelId) return false;
try {
await this._post('subscription/unsubscribe', { channelIds: [channelId] });
return true;
} catch (e) {
console.warn('[bygone] unsubscribe failed for', channelId, '—', e.message);
return false;
}
}
}
// ============================================================
// FEED ENGINE — merge 5 sources (subs / search / categories /
// topics / similar) + trending; dedup; weight; date-window
// ending at the set date (no forward grace).
// ============================================================
class FeedEngine {
constructor(api) { this.api = api; }
static _tag(videos, source, detail) {
for (const v of videos) if (v) { v.source = source; v.sourceDetail = detail; }
return videos;
}
_dateWindow(selectedDate) {
const d = new Date(selectedDate);
const days = CONFIG.feed.dateWindowDays;
const after = new Date(d); after.setDate(after.getDate() - days);
const before = new Date(d); before.setDate(before.getDate() + days);
return { after, before, center: d };
}
_interleave(batches) {
const out = [];
const longest = Math.max(0, ...batches.map(b => b.length));
for (let i = 0; i < longest; i++) for (const b of batches) if (i < b.length) out.push(b[i]);
return out;
}
_dedupe(videos) {
const seen = new Set();
const blockedNames = new Set(Store.getBlockedChannels().map(b => b.name.toLowerCase()));
const blockedIds = new Set(Store.getBlockedChannels().map(b => b.id).filter(Boolean));
const hidden = new Set(Store.getHiddenIds());
let coldIds = new Set();
try {
const neg = InterestModel.computeNegativeSignals();
coldIds = new Set(neg.coldChannels.filter(c => c.skipScore >= 5).map(c => c.channelId));
} catch (_) {}
return videos.filter(v => {
if (!v || seen.has(v.id)) return false;
if (v.channel && blockedNames.has(v.channel.toLowerCase())) return false;
if (v.channelId && blockedIds.has(v.channelId)) return false;
if (v.channelId && coldIds.has(v.channelId)) return false;
if (hidden.has(v.id)) return false;
seen.add(v.id);
return true;
});
}
// Soft bias toward videos near the chosen date. Flatter falloff
// (^0.3) so older material still surfaces.
_weightedShuffle(videos, centerDate) {
const center = new Date(centerDate).getTime();
const weighted = videos.map(v => {
let pub = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
if (!pub || isNaN(pub)) {
const d = DateHelper.approxPublishDate(v.relativeDate);
pub = d ? d.getTime() : center;
}
const daysDiff = Math.max(1, Math.abs(center - pub) / 86400000);
return { v, sort: Math.random() / Math.pow(daysDiff, 0.3), source: v.source || '' };
});
weighted.sort((a, b) => b.sort - a.sort);
const result = [];
const deferred = [];
let lastSrc = '', consecutive = 0;
for (const w of weighted) {
if (w.source === lastSrc) { consecutive++; if (consecutive > 2) { deferred.push(w); continue; } }
else { lastSrc = w.source; consecutive = 1; }
result.push(w);
}
let insertIdx = 1;
for (const d of deferred) { result.splice(Math.min(insertIdx, result.length), 0, d); insertIdx += 3; }
return result.map(w => w.v);
}
// ---- Source collection ------------------------------------
_collectSubscriptions() {
const subs = Store.getSubscriptions();
const explicitIds = new Set(subs.map(s => s.id).filter(Boolean));
const explicitNames = new Set(subs.map(s => s.name.toLowerCase()));
const result = [...subs];
if (Store.isLearningEnabled()) {
const interests = Store.getCachedInterests();
if (interests) for (const lc of InterestModel.getLearnedChannels(interests)) {
if (!explicitIds.has(lc.channelId)) {
result.push({ id: lc.channelId, name: lc.name, weight: Math.min(3, Math.round(lc.score)), _learned: true });
explicitIds.add(lc.channelId);
explicitNames.add(lc.name.toLowerCase());
}
}
}
return result;
}
_normalizeTerm(raw) {
if (typeof raw === 'string') return { term: raw, weight: 3 };
return {
term: raw.term || '',
weight: raw.weight || 3,
negatives: Array.isArray(raw.negatives) ? raw.negatives : [],
strict: !!raw.strict,
categoryBias: raw.categoryBias || null,
};
}
_normalizeTopic(raw) {
if (typeof raw === 'string') return { name: raw, weight: 3 };
return {
name: raw.name || '',
weight: raw.weight || 3,
negatives: Array.isArray(raw.negatives) ? raw.negatives : [],
strict: !!raw.strict,
categoryBias: raw.categoryBias || null,
};
}
_collectSearchTerms() {
const terms = Store.getSearchTerms().map(t => this._normalizeTerm(t));
if (Store.isLearningEnabled()) {
const interests = Store.getCachedInterests();
if (interests) {
const existing = new Set(terms.map(t => t.term.toLowerCase()));
for (const lk of InterestModel.getLearnedKeywords(interests)) {
if (!existing.has(lk.keyword)) {
terms.push({ term: lk.keyword, weight: 2, _learned: true, negatives: [], strict: false, categoryBias: null });
}
}
}
}
return terms;
}
// Unified source fetcher: caches optional. Each fetcher returns an array.
async _fetchSubs(dw, count, useCache) {
const subs = this._collectSubscriptions();
if (!subs.length) return [];
const key = `subs_${dw.center.toDateString()}`;
if (useCache) {
const c = Store.getCacheEntry(key, CONFIG.cache.subscriptions);
if (c) return c;
}
const totalW = subs.reduce((s, x) => s + (x.weight || 3), 0);
const batches = await Promise.allSettled(subs.map(async sub => {
if (!sub.id && !sub._learned) {
try {
const ch = await this.api.resolveChannel(sub.name);
if (ch && ch.id) {
sub.id = ch.id;
sub.name = ch.name || sub.name;
const stored = Store.getSubscriptions();
const match = stored.find(s => s.name.toLowerCase() === sub.name.toLowerCase() || (s.id && s.id === ch.id));
if (match && !match.id) { match.id = ch.id; match.name = ch.name || match.name; Store.setSubscriptions(stored); }
}
} catch (_) {}
}
const w = sub.weight || 3;
const per = Math.max(3, Math.ceil(count * w / totalW));
const videos = await this.api.getChannelVideos(sub.name, {
publishedAfter: dw.after, publishedBefore: dw.before,
maxResults: per, channelId: sub.id,
});
const detail = sub._learned ? `Learned: ${sub.name}` : `Subscription: ${sub.name}`;
return FeedEngine._tag(videos, 'subscriptions', detail);
}));
const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value));
if (useCache && out.length) Store.setCacheEntry(key, out);
return out;
}
async _fetchSearch(dw, count, useCache) {
const terms = this._collectSearchTerms();
if (!terms.length) return [];
const key = `search_${dw.center.toDateString()}`;
if (useCache) {
const c = Store.getCacheEntry(key, CONFIG.cache.searchTerms);
if (c) return c;
}
const totalW = terms.reduce((s, x) => s + (x.weight || 3), 0);
const batches = await Promise.allSettled(terms.map(async t => {
const w = t.weight || 3;
const per = Math.max(3, Math.ceil(count * w / totalW));
const videos = await this.api.searchVideos(t.term, {
publishedAfter: dw.after, publishedBefore: dw.before, maxResults: per,
negatives: t.negatives, strictMatch: t.strict ? t.term : null, categoryId: t.categoryBias,
});
return FeedEngine._tag(videos, 'searchTerms', `Search: "${t.term}"`);
}));
const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value));
if (useCache && out.length) Store.setCacheEntry(key, out);
return out;
}
async _fetchCategories(dw, count, useCache) {
const cats = Store.getCategories();
if (!cats.length) return [];
const key = `cats_${cats.join('_')}_${dw.center.toDateString()}`;
if (useCache) {
const c = Store.getCacheEntry(key, CONFIG.cache.categories);
if (c) return c;
}
const per = Math.max(5, Math.ceil(count / cats.length));
const batches = await Promise.allSettled(cats.map(async id => {
const videos = await this.api.getPopularByCategory(id, {
publishedAfter: dw.after, publishedBefore: dw.before, maxResults: per,
});
return FeedEngine._tag(videos, 'categories', `Category: ${CONFIG.categories[id] || id}`);
}));
const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value));
if (useCache && out.length) Store.setCacheEntry(key, out);
return out;
}
async _fetchTopics(dw, count, useCache) {
const topics = Store.getTopics().map(t => this._normalizeTopic(t));
if (!topics.length) return [];
const key = `topics_${dw.center.toDateString()}`;
if (useCache) {
const c = Store.getCacheEntry(key, CONFIG.cache.topics);
if (c) return c;
}
const totalW = topics.reduce((s, x) => s + (x.weight || 3), 0);
const batches = await Promise.allSettled(topics.map(async topic => {
const w = topic.weight || 3;
const per = Math.max(3, Math.ceil(count * w / totalW));
const videos = await this.api.searchVideos(topic.name, {
publishedAfter: dw.after, publishedBefore: dw.before, maxResults: per,
negatives: topic.negatives, strictMatch: topic.strict ? topic.name : null, categoryId: topic.categoryBias,
});
return FeedEngine._tag(videos, 'topics', `Topic: "${topic.name}"`);
}));
const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value));
if (useCache && out.length) Store.setCacheEntry(key, out);
return out;
}
// Trending: random discovery queries sorted by view count.
_buildDiscoveryQueries() {
const queries = new Set();
const interests = Store.getCachedInterests();
if (interests) {
const keywords = Object.entries(interests.keywords)
.filter(([_, k]) => k.score >= 1)
.sort((a, b) => b[1].score - a[1].score)
.slice(0, 15)
.map(([kw]) => kw);
for (let i = 0; i < keywords.length; i++) {
queries.add(keywords[i]);
if (i + 1 < keywords.length) queries.add(keywords[i] + ' ' + keywords[i + 1]);
}
}
const searchTerms = Store.getSearchTerms();
for (const raw of searchTerms) {
const term = typeof raw === 'string' ? raw : raw.term;
if (term) { queries.add(term); queries.add(term + ' review'); }
}
const subs = Store.getSubscriptions();
for (const sub of subs.slice(0, 5)) { if (sub.name) queries.add(sub.name); }
const searchHistory = Store.getSearchHistory();
for (const entry of searchHistory.slice(-10)) { if (entry.query) queries.add(entry.query); }
if (queries.size < 4) {
for (const f of ['review', 'tutorial', 'documentary', 'explained', 'analysis']) queries.add(f);
}
return Array.from(queries);
}
async _fetchTrending(dw, count, useCache) {
if (!Store.isDiscoveryEnabled()) return [];
const key = `trending_${dw.center.toDateString()}`;
if (useCache) {
const c = Store.getCacheEntry(key, CONFIG.cache.trending);
if (c) return c;
}
const pool = this._buildDiscoveryQueries();
const picked = [];
for (let i = 0; i < 6 && pool.length; i++) {
picked.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]);
}
if (!picked.length) picked.push('');
let autoNeg = [];
try { autoNeg = InterestModel.computeNegativeSignals().autoNegKeywords.slice(0, 10); } catch (_) {}
const per = Math.max(5, Math.ceil(count / picked.length));
const batches = await Promise.allSettled(picked.map(async q => {
const videos = await this.api.searchVideos(q, {
negatives: autoNeg,
publishedAfter: dw.after, publishedBefore: dw.before, maxResults: per, order: 'relevance',
});
return FeedEngine._tag(videos, 'trending', q ? `Discover: "${q}"` : 'Discover');
}));
const out = batches.filter(r => r.status === 'fulfilled').flatMap(r => r.value);
if (useCache && out.length) Store.setCacheEntry(key, out);
return out;
}
// CF "Similar": harvest /next sidebar for recent watch seeds.
async _fetchSimilar(dw, count, useCache) {
if (!Store.isSimilarEnabled()) return [];
const key = `similar_${dw.center.toDateString()}`;
if (useCache) {
const c = Store.getCacheEntry(key, CONFIG.cache.similar);
if (c) return c;
}
const seeds = await this._pickSimilarSeeds();
if (!seeds.length) return [];
const batches = await Promise.allSettled(seeds.map(async seed => {
const related = await this.api.getRelatedVideos(seed.videoId);
const filtered = related.filter(v => {
const a = DateHelper.approxPublishDate(v.relativeDate);
if (!a) return true;
return a >= dw.after && a <= dw.before;
});
return FeedEngine._tag(filtered, 'similar', `Similar to: ${seed.label}`);
}));
const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value)).slice(0, count);
if (useCache && out.length) Store.setCacheEntry(key, out);
return out;
}
async _pickSimilarSeeds() {
const recent = Store.getWatchHistory().slice().reverse().filter(w => w.videoId).slice(0, 8);
const seeds = [];
const usedChannels = new Set();
for (const w of recent) {
if (w.channelId && usedChannels.has(w.channelId)) continue;
seeds.push({ videoId: w.videoId, label: w.title || w.channel || 'recent watch' });
if (w.channelId) usedChannels.add(w.channelId);
if (seeds.length >= 3) break;
}
if (seeds.length >= 3) return seeds;
const interests = Store.getCachedInterests();
if (interests) for (const lc of InterestModel.getLearnedChannels(interests)) {
if (seeds.length >= 3) break;
if (usedChannels.has(lc.channelId)) continue;
try {
const v = await this.api.getChannelVideos(lc.name, { maxResults: 1, channelId: lc.channelId });
if (v.length) { seeds.push({ videoId: v[0].id, label: lc.name }); usedChannels.add(lc.channelId); }
} catch {}
}
return seeds;
}
// RELATED FAN-OUT. Harvest the related-videos sidebar (/next) of the
// era videos we already pooled. Neighbours of an era video are mostly
// era videos, so this deepens the pool from billions of candidates
// without inventing search terms. The /next endpoint takes no date
// operator, so results are STRICTLY date-filtered and undated ones are
// dropped (related lists are full of present-day recommendations).
async _fetchRelatedExpansion(seedVideos, dw, count) {
const seeds = [];
const usedCh = new Set();
for (const v of seedVideos) {
if (!v || !v.id) continue;
if (v.channelId && usedCh.has(v.channelId)) continue; // spread seeds across channels
seeds.push(v);
if (v.channelId) usedCh.add(v.channelId);
if (seeds.length >= 8) break;
}
if (!seeds.length) return [];
const batches = await Promise.allSettled(seeds.map(async s => {
const related = await this.api.getRelatedVideos(s.id);
const filtered = related.filter(r => {
const a = DateHelper.approxPublishDate(r.relativeDate);
if (!a) return false; // drop undated (likely modern)
return a >= dw.after && a <= dw.before; // strict era window
});
return FeedEngine._tag(filtered, 'related', `Related to: ${s.title || s.id}`);
}));
const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value));
return out.slice(0, count);
}
// Boost subs+search when learning has signal; drain from trending+similar.
_effectiveWeights() {
if (!Store.isLearningEnabled()) return CONFIG.feed.weights;
const i = Store.getCachedInterests();
if (!i) return CONFIG.feed.weights;
const lc = InterestModel.getLearnedChannels(i).length;
const lk = InterestModel.getLearnedKeywords(i).length;
const w = { ...CONFIG.feed.weights };
const subBoost = Math.min(0.10, lc * 0.02);
const termBoost = Math.min(0.05, lk * 0.01);
w.subscriptions += subBoost;
w.searchTerms += termBoost;
const drain = (subBoost + termBoost) / 2;
w.trending = Math.max(0.05, w.trending - drain);
w.similar = Math.max(0.05, (w.similar || 0) - drain);
const stats = Store.getSourceStats();
const sourceKeys = Object.keys(w);
const ctrs = {};
let hasEnoughData = false;
for (const key of sourceKeys) {
const s = stats[key];
if (s && s.impressions >= 20) { ctrs[key] = s.clicks / s.impressions; hasEnoughData = true; }
else ctrs[key] = null;
}
if (hasEnoughData) {
const validCtrs = Object.values(ctrs).filter(v => v !== null);
const avgCtr = validCtrs.reduce((s, v) => s + v, 0) / validCtrs.length;
for (const key of sourceKeys) {
if (ctrs[key] === null) continue;
const adj = Math.max(-0.08, Math.min(0.08, (ctrs[key] - avgCtr) * 2));
w[key] = Math.max(0.03, w[key] + adj);
}
const total = Object.values(w).reduce((s, v) => s + v, 0);
for (const key of sourceKeys) w[key] /= total;
}
return w;
}
_mixSources(sources, weights) {
const w = weights || CONFIG.feed.weights;
const total = CONFIG.feed.maxHomepageVideos;
const take = (arr, n) => [...arr].sort(() => Math.random() - 0.5).slice(0, n);
const counts = {
subscriptions: Math.round(total * (w.subscriptions || 0)),
searchTerms: Math.round(total * (w.searchTerms || 0)),
categories: Math.round(total * (w.categories || 0)),
topics: Math.round(total * (w.topics || 0)),
similar: Math.round(total * (w.similar || 0)),
trending: Math.round(total * (w.trending || 0)),
};
const mixed = [];
for (const [k, n] of Object.entries(counts)) mixed.push(...take(sources[k] || [], n));
if (mixed.length < total) {
const ids = new Set(mixed.map(v => v.id));
const extras = Object.values(sources).flat().filter(v => v && !ids.has(v.id));
mixed.push(...take(extras, total - mixed.length));
}
return mixed;
}
// Internal: race the build against a 30s timeout so loading can't hang.
async _buildWithTimeout(promise) {
return Promise.race([promise, new Promise((_, rej) => setTimeout(() => rej(new Error('Feed build timed out')), 30000))]);
}
// useCache:false — InnerTube fetches are free, so pull a fresh
// feed from the live API on every page load instead of serving
// a stale cached batch. The random shuffle in _mixSources/
// _weightedShuffle plus the impression park (recordImpressions)
// mean every refresh surfaces a different set of pool videos.
async buildHomeFeed(selectedDate) { return this._buildWithTimeout(this._build(selectedDate, false)); }
async buildHomeFeedMore(selectedDate, page, excludeIds) {
const d = new Date(selectedDate);
d.setDate(d.getDate() - CONFIG.feed.dateWindowDays * 2 * (page - 1));
const out = await this._build(d.toISOString().split('T')[0], false);
const excl = excludeIds instanceof Set ? excludeIds : new Set(excludeIds || []);
return out.filter(v => !excl.has(v.id));
}
_enforceDiversity(videos) {
const MAX_PER_CHANNEL = 3;
const MAX_PER_SOURCE = Math.ceil(videos.length * 0.35);
const channelCounts = {};
const sourceCounts = {};
const kept = [];
const deferred = [];
for (const v of videos) {
const chKey = v.channelId || v.channel || 'unknown';
const srcKey = v.source || 'unknown';
const chCount = channelCounts[chKey] || 0;
const srcCount = sourceCounts[srcKey] || 0;
if (chCount >= MAX_PER_CHANNEL || srcCount >= MAX_PER_SOURCE) {
deferred.push(v);
continue;
}
channelCounts[chKey] = chCount + 1;
sourceCounts[srcKey] = srcCount + 1;
kept.push(v);
}
if (kept.length < 100) for (const v of deferred) { kept.push(v); if (kept.length >= videos.length) break; }
return kept;
}
async _build(selectedDate, useCache) {
const total = CONFIG.feed.maxHomepageVideos;
const anchor = new Date(selectedDate);
// No future-grace on videos. The small +7d edge on each window
// is just smoothing for YouTube's approximate relative-date
// strings ("1 day ago" can fall a couple days either side of
// the actual upload), not a deliberate grace period.
const smoothMs = 7 * 86400000;
const subsWindow = this._dateWindow(selectedDate);
const queryWindow = { after: new Date(anchor - 90 * 86400000), before: new Date(anchor.getTime() + smoothMs), center: anchor };
const catWindow = { after: new Date(anchor - 180 * 86400000), before: new Date(anchor.getTime() + smoothMs), center: anchor };
const trendingWindow = { after: new Date(anchor - 365 * 86400000), before: new Date(anchor.getTime() + smoothMs), center: anchor };
const loadNum = Store.incrementLoadCount();
const explore = loadNum % 10 === 0;
const weights = explore ? CONFIG.feed.weights : this._effectiveWeights();
const results = await Promise.allSettled([
this._fetchSubs (subsWindow, Math.round(total * weights.subscriptions * 2), useCache),
this._fetchSearch (queryWindow, Math.round(total * weights.searchTerms * 2), useCache),
this._fetchCategories(catWindow, Math.round(total * weights.categories * 2), useCache),
this._fetchTopics (queryWindow, Math.round(total * weights.topics * 2), useCache),
this._fetchSimilar (subsWindow, Math.round(total * weights.similar * 2), useCache),
this._fetchTrending (trendingWindow, Math.round(total * weights.trending * 2), useCache),
]);
const val = (i) => results[i].status === 'fulfilled' ? results[i].value : [];
const err = (i) => results[i].status === 'rejected' ? (results[i].reason && results[i].reason.message) || 'rejected' : '';
// Per-source diagnostic: tells us at a glance which fetchers
// are returning videos and which are silently empty/failing.
console.log('[bygone] sources:',
'subs=' + val(0).length + (err(0) ? '(err:'+err(0)+')' : ''),
'search=' + val(1).length + (err(1) ? '(err:'+err(1)+')' : ''),
'cats=' + val(2).length + (err(2) ? '(err:'+err(2)+')' : ''),
'topics=' + val(3).length + (err(3) ? '(err:'+err(3)+')' : ''),
'similar=' + val(4).length + (err(4) ? '(err:'+err(4)+')' : ''),
'trending=' + val(5).length + (err(5) ? '(err:'+err(5)+')' : ''));
const mixed = this._mixSources({
subscriptions: val(0), searchTerms: val(1), categories: val(2),
topics: val(3), similar: val(4), trending: val(5),
}, weights);
let deduped = this._dedupe(mixed);
// RELATED FAN-OUT. If the keyword/category/trending pool came back
// below target (deep/sparse eras, or a near-sourceless profile),
// expand it by harvesting the related sidebar of the era videos we
// already have. Seeds come from the pool itself, so it works on any
// date and needs no watch history (unlike _fetchSimilar).
if (deduped.length < total) {
try {
const related = await this._fetchRelatedExpansion(deduped, trendingWindow, total - deduped.length);
if (related.length) {
deduped = this._dedupe([...deduped, ...related]);
console.log('[bygone] related fan-out added', related.length, '→ pool', deduped.length);
}
} catch (_) {}
}
const diverse = this._enforceDiversity(deduped);
let visible = diverse.filter(v => !Store.isImpressionHidden(v.id));
console.log('[bygone] feed build: mixed=' + mixed.length + ' deduped=' + deduped.length + ' visible=' + visible.length);
// Safety valve: if the impression-park filter has whittled
// the visible pool below a usable threshold, fall back to
// the unfiltered pool. Better to show seen videos than to
// show the same 3-4 cards everywhere.
if (visible.length < 30 && deduped.length > visible.length) visible = deduped;
// Push recently seen videos to the back so refreshes feel fresh.
const seen = new Set(Store.getSeenIds());
const unseen = visible.filter(v => !seen.has(v.id));
const seenVids = visible.filter(v => seen.has(v.id));
const ordered = [
...this._weightedShuffle(unseen, anchor),
...this._weightedShuffle(seenVids, anchor),
];
// Always record impressions (not gated on useCache) so the
// park logic keeps running — that's what stops the same
// videos reappearing across refreshes. Slice is small (20)
// so that only videos actually likely to be SHOWN this load
// get counted; recording the whole 150-video pool would mean
// a couple of reloads parks everything the API returns.
const top20 = ordered.slice(0, 20);
Store.recordImpressions(top20.map(v => v.id));
try {
Store.recordFeedImpressions(top20);
for (const v of top20) Store.recordSourceImpression(v.source || 'unknown');
} catch (_) {}
return ordered;
}
}
// ============================================================
// UI — floating panel + FAB. Panel-only CSS (no YouTube-layout
// overrides; V3 already provides the 2013 look).
// ============================================================
// CSS uses !important throughout because V3 / YouTube CSS targets
// a lot of selectors aggressively (e.g. `body button { display: none }`
// in some V3 layouts) and would otherwise hide our FAB.
const CSS = `
#wbt-fab {
position: fixed !important;
bottom: calc(24px + env(safe-area-inset-bottom, 0px)) !important;
right: calc(24px + env(safe-area-inset-right, 0px)) !important;
z-index: 2147483646 !important;
width: 64px !important; height: 64px !important;
border-radius: 50% !important;
background: #c00 !important; color: #fff !important;
border: 1px solid #800 !important;
font: bold 25px sans-serif !important;
line-height: 64px !important;
cursor: pointer !important;
box-shadow: 0 2px 8px rgba(0,0,0,.4) !important;
display: block !important; visibility: visible !important; opacity: 1 !important;
padding: 0 !important; margin: 0 !important;
transform: none !important; zoom: 1 !important;
}
#wbt-fab:hover { background: #e00 !important; }
#wbt-panel {
position: fixed !important;
bottom: calc(96px + env(safe-area-inset-bottom, 0px)) !important;
right: 12px !important;
z-index: 2147483647 !important;
max-width: calc(100vw - 24px) !important;
width: min(430px, calc(100vw - 24px)) !important;
max-height: calc(100vh - 128px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)) !important;
overflow-y: auto !important;
background: #f5f5f5 !important; color: #222 !important;
border: 1px solid #888 !important; border-radius: 4px !important;
box-shadow: 0 2px 12px rgba(0,0,0,.4) !important;
font: 14px sans-serif !important;
visibility: visible !important; opacity: 1 !important;
transform: none !important; zoom: 1 !important;
}
#wbt-panel.wbt-hidden { display: none !important; }
.wbt-h { background: linear-gradient(#e8e8e8, #d4d4d4); padding: 8px 12px;
font-weight: bold; border-bottom: 1px solid #aaa; cursor: move; }
.wbt-close { float: right; cursor: pointer; color: #666; font-weight: normal; }
.wbt-close:hover { color: #c00; }
.wbt-body { padding: 10px 12px; }
.wbt-sec { margin-bottom: 14px; }
.wbt-sec h4 { margin: 0 0 6px; font-size: 12px; color: #333;
border-bottom: 1px dotted #aaa; padding-bottom: 3px; }
.wbt-row { display: flex; gap: 6px; align-items: center; margin: 4px 0; }
.wbt-row input[type="text"], .wbt-row input[type="date"], .wbt-row input[type="number"],
.wbt-row select { flex: 1; padding: 3px 5px; border: 1px solid #aaa;
border-radius: 2px; font: 12px sans-serif; min-width: 0; }
.wbt-btn { padding: 3px 9px; border: 1px solid #888; background: #ddd;
border-radius: 2px; cursor: pointer; font: 12px sans-serif; white-space: nowrap; }
.wbt-btn:hover { background: #e8e8e8; }
.wbt-btn-primary { background: #c00; color: #fff; border-color: #800; }
.wbt-btn-primary:hover { background: #e00; }
.wbt-btn-x { padding: 0 6px; font-weight: bold; color: #c00; }
.wbt-list { background: #fff; border: 1px solid #aaa; border-radius: 2px;
min-height: 28px; max-height: 110px; overflow-y: auto; }
.wbt-item { padding: 3px 6px; border-bottom: 1px dotted #ddd;
display: flex; align-items: center; gap: 6px; cursor: grab; }
.wbt-item:last-child { border-bottom: 0; }
.wbt-item.dragging { opacity: .4; }
.wbt-item-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wbt-item-weight { width: 32px; }
.wbt-toggle { display: flex; align-items: center; gap: 6px; margin: 3px 0; cursor: pointer; }
.wbt-tabs { display: flex; gap: 0; border-bottom: 1px solid #aaa; }
.wbt-tab { padding: 5px 10px; cursor: pointer; border: 1px solid transparent;
border-bottom: 0; font-size: 11px; }
.wbt-tab.active { background: #f5f5f5; border-color: #aaa; }
.wbt-tabbody { display: none; }
.wbt-tabbody.active { display: block; }
.wbt-mute { color: #666; font-size: 11px; }
.wbt-pill { display: inline-block; background: #ddd; padding: 1px 5px; border-radius: 8px;
font-size: 10px; margin-right: 3px; }
.wbt-stats td { padding: 1px 6px 1px 0; }
#wbt-dep-modal {
position: fixed !important;
inset: 0 !important;
z-index: 2147483647 !important;
background: rgba(0,0,0,.55) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
visibility: visible !important;
opacity: 1 !important;
font: 12px sans-serif !important;
color: #222 !important;
}
#wbt-dep-modal-box {
width: min(420px, calc(100vw - 28px)) !important;
background: #f5f5f5 !important;
border: 1px solid #777 !important;
box-shadow: 0 4px 22px rgba(0,0,0,.45) !important;
padding: 12px !important;
border-radius: 3px !important;
}
.wbt-dep-actions {
display: flex !important;
flex-wrap: wrap !important;
gap: 6px !important;
margin-top: 10px !important;
}
`;
class UI {
constructor(api, feedEngine) {
this.api = api;
this.feedEngine = feedEngine;
this._dragSrc = null;
this._activeTab = _checkV3() ? 'feed' : 'setup';
}
init() {
// Try GM_addStyle (polish). ALSO inject a manual <style> element
// (so the styles survive even if GM_addStyle is blocked). The
// layout-critical styles are duplicated inline on each container
// below so the panel works even if the <style> is stripped by V3.
try { GM_addStyle(CSS); } catch {}
try {
if (!document.getElementById('wbt-style')) {
const s = document.createElement('style');
s.id = 'wbt-style';
s.textContent = CSS;
(document.head || document.documentElement).appendChild(s);
}
} catch {}
if (!document.body) {
document.addEventListener('DOMContentLoaded', () => this.init(), { once: true });
return;
}
this._mountFab();
this._mountPanel();
}
// Apply a style object to an element using setProperty + 'important'
// so no other stylesheet can override us.
_style(el, props) {
for (const k in props) {
try { el.style.setProperty(k, props[k], 'important'); } catch {}
}
}
_mountFab() {
if (document.getElementById('wbt-fab')) return;
const fab = this._el('button', 'wbt-fab', '⏲');
fab.id = 'wbt-fab';
fab.title = 'bygone-yt — open panel';
// Inline critical styles so the FAB shows even if CSS was stripped.
this._style(fab, {
position: 'fixed',
bottom: 'calc(24px + env(safe-area-inset-bottom, 0px))',
right: 'calc(24px + env(safe-area-inset-right, 0px))',
'z-index': '2147483646',
width: '64px', height: '64px',
'border-radius': '50%',
background: '#c00', color: '#fff',
border: '1px solid #800',
font: 'bold 25px sans-serif',
'line-height': '64px',
cursor: 'pointer',
'box-shadow': '0 2px 8px rgba(0,0,0,.4)',
display: 'block', visibility: 'visible', opacity: '1',
transform: 'none', zoom: '1',
padding: '0', margin: '0',
});
fab.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
const panel = document.getElementById('wbt-panel');
if (panel) {
const wasHidden = panel.classList.contains('wbt-hidden');
panel.classList.toggle('wbt-hidden');
// Belt-and-suspenders: toggle inline display too.
panel.style.setProperty('display', wasHidden ? 'block' : 'none', 'important');
}
};
(document.body || document.documentElement).appendChild(fab);
}
_mountPanel() {
const old = document.getElementById('wbt-panel');
if (old) old.remove();
const p = this._el('div');
p.id = 'wbt-panel';
p.classList.add('wbt-hidden');
// Inline critical layout styles. CSS classes provide polish on
// top; if CSS is stripped, the panel still renders correctly.
this._style(p, {
position: 'fixed',
bottom: 'calc(96px + env(safe-area-inset-bottom, 0px))',
right: '12px',
'z-index': '2147483647',
width: 'min(430px, calc(100vw - 24px))',
'max-height': 'calc(100vh - 128px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))',
'overflow-y': 'auto',
background: '#f5f5f5', color: '#222',
border: '1px solid #888', 'border-radius': '4px',
'box-shadow': '0 2px 12px rgba(0,0,0,.4)',
font: '14px sans-serif',
display: 'none', // start hidden
'pointer-events': 'auto', // defeat any global pe:none
transform: 'none', zoom: '1',
padding: '0', margin: '0',
});
this._renderPanel(p);
(document.body || document.documentElement).appendChild(p);
}
_renderPanel(p) {
p.innerHTML = '';
const header = this._el('div', 'wbt-h', 'bygone-yt v' + VERSION);
this._style(header, {
background: 'linear-gradient(#e8e8e8, #d4d4d4)',
padding: '8px 12px', 'font-weight': 'bold',
'border-bottom': '1px solid #aaa', cursor: 'move',
color: '#222', font: 'bold 12px sans-serif',
});
const close = this._el('span', 'wbt-close', '✕');
this._style(close, { float: 'right', cursor: 'pointer', color: '#666', 'font-weight': 'normal' });
close.onclick = () => {
p.classList.add('wbt-hidden');
p.style.setProperty('display', 'none', 'important');
};
header.appendChild(close);
this._enableDrag(p, header);
p.appendChild(header);
const tabs = this._el('div', 'wbt-tabs');
this._style(tabs, { display: 'flex', gap: '0', 'border-bottom': '1px solid #aaa', background: '#eee' });
const body = this._el('div', 'wbt-body');
this._style(body, { padding: '10px 12px' });
const sections = [
['feed', 'Feed', () => this._renderFeed()],
['sources', 'Sources', () => this._renderSources()],
['profiles', 'Profiles', () => this._renderProfiles()],
['look', 'Look', () => this._renderLook()],
['stats', 'Stats', () => this._renderStats()],
['setup', 'Setup', () => this._renderSetup()],
];
for (const [id, label, render] of sections) {
const tab = this._el('div', 'wbt-tab', label);
this._style(tab, {
padding: '5px 10px', cursor: 'pointer',
border: '1px solid transparent', 'border-bottom': '0',
'font-size': '11px',
background: id === this._activeTab ? '#f5f5f5' : 'transparent',
'border-top-color': id === this._activeTab ? '#aaa' : 'transparent',
'border-left-color': id === this._activeTab ? '#aaa' : 'transparent',
'border-right-color': id === this._activeTab ? '#aaa' : 'transparent',
});
if (id === this._activeTab) tab.classList.add('active');
tab.onclick = () => {
this._activeTab = id;
this._renderPanel(p);
};
tabs.appendChild(tab);
}
const section = sections.find(s => s[0] === this._activeTab);
const content = section ? section[2]() : this._renderFeed();
body.appendChild(content);
p.appendChild(tabs);
p.appendChild(body);
}
// Style helpers used by section renderers below.
_styleBtn(b, primary) {
this._style(b, {
padding: '3px 9px',
border: '1px solid ' + (primary ? '#800' : '#888'),
background: primary ? '#c00' : '#ddd',
color: primary ? '#fff' : '#222',
'border-radius': '2px', cursor: 'pointer',
font: '12px sans-serif', 'white-space': 'nowrap',
});
}
_styleInput(i) {
this._style(i, {
flex: '1', padding: '3px 5px',
border: '1px solid #aaa', 'border-radius': '2px',
font: '12px sans-serif', 'min-width': '0',
background: '#fff', color: '#222',
});
}
_styleRow(r) {
this._style(r, { display: 'flex', gap: '6px', 'align-items': 'center', margin: '4px 0' });
}
_styleSec(s) {
this._style(s, { 'margin-bottom': '14px' });
}
_styleH4(h) {
this._style(h, {
margin: '0 0 6px', 'font-size': '12px', color: '#333',
'border-bottom': '1px dotted #aaa', 'padding-bottom': '3px',
font: 'bold 12px sans-serif',
});
}
_styleList(l) {
this._style(l, {
background: '#fff', border: '1px solid #aaa',
'border-radius': '2px', 'min-height': '28px',
'max-height': '110px', 'overflow-y': 'auto',
});
}
_styleItem(it) {
this._style(it, {
padding: '3px 6px', 'border-bottom': '1px dotted #ddd',
display: 'flex', 'align-items': 'center', gap: '6px', cursor: 'grab',
});
}
_styleMute(m) {
this._style(m, { color: '#666', 'font-size': '11px' });
}
_styleToggle(l) {
this._style(l, { display: 'flex', 'align-items': 'center', gap: '6px', margin: '3px 0', cursor: 'pointer' });
}
// ---- Tabs --------------------------------------------------
_renderFeed() {
const wrap = this._el('div');
// Active toggle
wrap.appendChild(this._toggle('Active', Store.isActive(), v => { Store.setActive(v); }));
// Date picker
const dateSec = this._el('div', 'wbt-sec');
dateSec.appendChild(this._el('h4', null, 'Date'));
const dateRow = this._el('div', 'wbt-row');
const date = Store.getDate() || '';
const dateInput = this._el('input');
dateInput.type = 'date';
dateInput.value = date;
const applyDate = (val) => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(val) || isNaN(new Date(val).getTime())) return;
// Re-anchor the rolling clock at the newly chosen date instead
// of stopping it, so picking an era keeps it rolling from there.
if (Store.isClockActive()) Store.startClock(val);
else Store.setDate(val);
if (dateInput.value !== val) dateInput.value = val;
if (dateText.value !== val) dateText.value = val;
this._reloadFeed();
};
dateInput.onchange = () => applyDate(dateInput.value);
dateRow.appendChild(dateInput);
// Typeable fallback: on mobile (e.g. the Android kiosk) the native
// <input type=date> picker often won't open a keyboard, so a plain
// text box lets the user type YYYY-MM-DD. Kept in sync with the picker.
const dateText = this._el('input');
dateText.type = 'text';
dateText.placeholder = 'YYYY-MM-DD';
dateText.value = date;
dateText.setAttribute('inputmode', 'numeric');
dateText.setAttribute('autocomplete', 'off');
dateText.maxLength = 10;
this._style(dateText, { 'max-width': '120px' });
dateText.onchange = () => applyDate(dateText.value.trim());
dateText.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); applyDate(dateText.value.trim()); }
});
dateRow.appendChild(dateText);
dateSec.appendChild(dateRow);
// Clock
const clockRow = this._el('div', 'wbt-row');
const clockBtn = this._el('button', 'wbt-btn', Store.isClockActive() ? 'Stop clock' : 'Start rolling clock');
clockBtn.onclick = () => {
if (Store.isClockActive()) Store.stopClock();
else if (dateInput.value) Store.startClock(dateInput.value);
this._renderPanel(document.getElementById('wbt-panel'));
this._reloadFeed();
};
clockRow.appendChild(clockBtn);
dateSec.appendChild(clockRow);
if (Store.isClockActive()) {
const now = Store.getCurrentDate();
dateSec.appendChild(this._el('div', 'wbt-mute', 'Sim time: ' + now));
}
wrap.appendChild(dateSec);
// Source toggles
const togSec = this._el('div', 'wbt-sec');
togSec.appendChild(this._el('h4', null, 'Sources'));
togSec.appendChild(this._toggle('"Similar" (CF) source', Store.isSimilarEnabled(), v => Store.setSimilarEnabled(v)));
togSec.appendChild(this._toggle('Trending discovery', Store.isDiscoveryEnabled(), v => Store.setDiscoveryEnabled(v)));
togSec.appendChild(this._toggle('Watch-history learning', Store.isLearningEnabled(), v => Store.setLearningEnabled(v)));
togSec.appendChild(this._toggle('Auto-subscribe on YouTube to bygone subs', Store.isAutoSyncSubs(), v => {
Store.setAutoSyncSubs(v);
if (v) App._scheduleSubSync(500);
}));
wrap.appendChild(togSec);
// Reload + clear caches
const actions = this._el('div', 'wbt-sec');
const row = this._el('div', 'wbt-row');
const reload = this._el('button', 'wbt-btn wbt-btn-primary', 'Reload feed');
reload.onclick = () => this._reloadFeed();
const clear = this._el('button', 'wbt-btn', 'Clear cache');
clear.onclick = () => {
try { for (const k of GM_listValues()) if (k.startsWith('bygone_cache_')) GM_deleteValue(k); } catch {}
this._reloadFeed();
};
row.appendChild(reload); row.appendChild(clear);
actions.appendChild(row);
wrap.appendChild(actions);
return wrap;
}
_renderSources() {
const wrap = this._el('div');
// Subscriptions
const subSec = this._listSection('Subscriptions',
Store.getSubscriptions(),
(s) => s.name + (s.weight ? ` (w${s.weight})` : ''),
'Channel name…',
async (name) => {
if (!name) return;
const ch = await this.api.resolveChannel(name).catch(() => null);
const subs = Store.getSubscriptions();
subs.push({ id: ch ? ch.id : null, name: ch ? ch.name : name, weight: 3 });
Store.setSubscriptions(subs);
// Newly added → push to YouTube account.
App._scheduleSubSync(800);
},
(newOrder) => Store.setSubscriptions(newOrder));
wrap.appendChild(subSec);
// Search terms
wrap.appendChild(this._listSection('Search terms',
Store.getSearchTerms(),
(t) => (typeof t === 'string' ? t : t.term) + (t.weight ? ` (w${t.weight})` : ''),
'Search query…',
(q) => { if (!q) return; const t = Store.getSearchTerms(); t.push({ term: q, weight: 3 }); Store.setSearchTerms(t); },
(newOrder) => Store.setSearchTerms(newOrder)));
// Topics
wrap.appendChild(this._listSection('Custom topics',
Store.getTopics(),
(t) => (typeof t === 'string' ? t : t.name) + (t.weight ? ` (w${t.weight})` : ''),
'Topic name…',
(n) => { if (!n) return; const t = Store.getTopics(); t.push({ name: n, weight: 3 }); Store.setTopics(t); },
(newOrder) => Store.setTopics(newOrder)));
// Categories
const catSec = this._el('div', 'wbt-sec');
catSec.appendChild(this._el('h4', null, 'Categories'));
const selected = new Set(Store.getCategories());
for (const [id, name] of Object.entries(CONFIG.categories)) {
const row = this._el('label', 'wbt-toggle');
const cb = this._el('input');
cb.type = 'checkbox';
cb.checked = selected.has(Number(id));
cb.onchange = () => {
if (cb.checked) selected.add(Number(id));
else selected.delete(Number(id));
Store.setCategories(Array.from(selected));
};
row.appendChild(cb);
row.appendChild(document.createTextNode(' ' + name));
catSec.appendChild(row);
}
wrap.appendChild(catSec);
// Blocked channels
wrap.appendChild(this._listSection('Blocked channels',
Store.getBlockedChannels(),
(b) => b.name,
'Channel name to block…',
(n) => { if (!n) return; const b = Store.getBlockedChannels(); b.push({ name: n, id: null }); Store.setBlockedChannels(b); },
(newOrder) => Store.setBlockedChannels(newOrder)));
// Global negatives
wrap.appendChild(this._listSection('Global negative keywords',
Store.getGlobalNegatives().map(n => ({ name: n })),
(n) => n.name,
'Negative keyword…',
(n) => { if (!n) return; const list = Store.getGlobalNegatives(); list.push(n); Store.setGlobalNegatives(list); },
(newOrder) => Store.setGlobalNegatives(newOrder.map(n => n.name))));
return wrap;
}
// Generic list section with add + remove + drag-reorder.
_listSection(title, items, formatItem, placeholder, onAdd, onReorder) {
const sec = this._el('div', 'wbt-sec');
sec.appendChild(this._el('h4', null, title));
const list = this._el('div', 'wbt-list');
items.forEach((item, idx) => {
const row = this._el('div', 'wbt-item');
row.draggable = true;
row.dataset.idx = String(idx);
row.ondragstart = (e) => { this._dragSrc = idx; row.classList.add('dragging'); try { e.dataTransfer.effectAllowed = 'move'; } catch {} };
row.ondragend = () => row.classList.remove('dragging');
row.ondragover = (e) => { e.preventDefault(); };
row.ondrop = (e) => {
e.preventDefault();
const src = this._dragSrc; this._dragSrc = null;
if (src === null || src === idx) return;
const reordered = items.slice();
const [moved] = reordered.splice(src, 1);
reordered.splice(idx, 0, moved);
onReorder(reordered);
this._renderPanel(document.getElementById('wbt-panel'));
};
row.appendChild(this._el('span', 'wbt-item-name', formatItem(item)));
const rm = this._el('button', 'wbt-btn wbt-btn-x', '×');
rm.onclick = () => {
const next = items.slice();
next.splice(idx, 1);
onReorder(next);
this._renderPanel(document.getElementById('wbt-panel'));
};
row.appendChild(rm);
list.appendChild(row);
});
sec.appendChild(list);
const addRow = this._el('div', 'wbt-row');
const input = this._el('input');
input.type = 'text';
input.placeholder = placeholder;
const addBtn = this._el('button', 'wbt-btn', '+');
const doAdd = async () => {
const val = input.value.trim();
input.value = '';
await onAdd(val);
this._renderPanel(document.getElementById('wbt-panel'));
};
addBtn.onclick = doAdd;
input.onkeydown = (e) => { if (e.key === 'Enter') doAdd(); };
addRow.appendChild(input);
addRow.appendChild(addBtn);
sec.appendChild(addRow);
return sec;
}
_renderProfiles() {
const wrap = this._el('div');
wrap.appendChild(this._el('h4', null, 'Profiles'));
const profiles = Store.getProfiles();
const names = Object.keys(profiles);
if (!names.length) wrap.appendChild(this._el('div', 'wbt-mute', 'No saved profiles yet.'));
for (const name of names) {
const row = this._el('div', 'wbt-item');
row.appendChild(this._el('span', 'wbt-item-name', name));
const load = this._el('button', 'wbt-btn', 'Load');
load.onclick = () => { Store.loadProfile(name); this._renderPanel(document.getElementById('wbt-panel')); this._reloadFeed(); App._scheduleSubSync(800); };
const exp = this._el('button', 'wbt-btn', 'Export');
exp.onclick = () => {
const blob = new Blob([Store.exportProfile(name)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = this._el('a');
a.href = url; a.download = `bygone-profile-${name}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const del = this._el('button', 'wbt-btn wbt-btn-x', '×');
del.onclick = () => { if (confirm(`Delete profile "${name}"?`)) { Store.deleteProfile(name); this._renderPanel(document.getElementById('wbt-panel')); } };
row.appendChild(load); row.appendChild(exp); row.appendChild(del);
wrap.appendChild(row);
}
const addRow = this._el('div', 'wbt-row');
const input = this._el('input');
input.type = 'text';
input.placeholder = 'New profile name…';
const saveBtn = this._el('button', 'wbt-btn', 'Save current as…');
saveBtn.onclick = () => {
const n = input.value.trim();
if (!n) return;
Store.saveProfile(n);
input.value = '';
this._renderPanel(document.getElementById('wbt-panel'));
};
const blankBtn = this._el('button', 'wbt-btn', 'New blank');
blankBtn.onclick = () => {
const n = input.value.trim();
if (!n) { input.placeholder = 'enter a name first…'; input.focus(); return; }
if (Store.getProfiles()[n] && !confirm(`Overwrite "${n}" with a blank profile?`)) return;
Store.createBlankProfile(n);
input.value = '';
this._renderPanel(document.getElementById('wbt-panel'));
};
addRow.appendChild(input); addRow.appendChild(saveBtn); addRow.appendChild(blankBtn);
wrap.appendChild(addRow);
// Import
const impRow = this._el('div', 'wbt-row');
const imp = this._el('input');
imp.type = 'file';
imp.accept = 'application/json';
imp.onchange = () => {
const f = imp.files && imp.files[0];
if (!f) return;
const r = new FileReader();
r.onload = () => {
try { Store.importProfile(r.result); alert('Profile imported.'); this._renderPanel(document.getElementById('wbt-panel')); }
catch (e) { alert('Import failed: ' + e.message); }
};
r.readAsText(f);
};
impRow.appendChild(imp);
wrap.appendChild(impRow);
// Full export/import (ALL data)
wrap.appendChild(this._el('h4', null, 'Full Backup'));
const expAllRow = this._el('div', 'wbt-row');
const expAllBtn = this._el('button', 'wbt-btn wbt-btn-primary', 'Export ALL data');
expAllBtn.onclick = () => {
const blob = new Blob([Store.exportAll()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = this._el('a');
a.href = url; a.download = `bygone-yt-full-backup.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
expAllRow.appendChild(expAllBtn);
wrap.appendChild(expAllRow);
const impAllRow = this._el('div', 'wbt-row');
const impAll = this._el('input');
impAll.type = 'file';
impAll.accept = 'application/json';
impAll.onchange = () => {
const f = impAll.files && impAll.files[0];
if (!f) return;
const r = new FileReader();
r.onload = () => {
try { Store.importAll(r.result); alert('Full backup restored.'); this._renderPanel(document.getElementById('wbt-panel')); }
catch (e) { alert('Import failed: ' + e.message); }
};
r.readAsText(f);
};
impAllRow.appendChild(impAll);
wrap.appendChild(impAllRow);
return wrap;
}
_renderLook() {
const wrap = this._el('div');
wrap.appendChild(this._el('h4', null, 'APK page look'));
const dark = this._el('input');
dark.type = 'checkbox';
dark.checked = Store.getKioskDarkMode();
const darkRow = this._el('label', 'wbt-toggle');
darkRow.appendChild(dark);
darkRow.appendChild(document.createTextNode('Dark mode'));
dark.onchange = () => {
Store.setKioskDarkMode(dark.checked);
this._broadcastKioskLookPrefs();
};
wrap.appendChild(darkRow);
const zoomRow = this._el('div', 'wbt-row');
const zoomLabel = this._el('span', null, 'UI size');
this._style(zoomLabel, { width: '52px', 'font-size': '11px', color: '#333' });
const zoom = this._el('input');
zoom.type = 'range';
zoom.min = '1';
zoom.max = '1.8';
zoom.step = '0.05';
zoom.value = String(Store.getKioskZoom());
this._style(zoom, { flex: '1' });
const zoomValue = this._el('span', null, Math.round(Store.getKioskZoom() * 100) + '%');
this._style(zoomValue, { width: '38px', 'font-size': '11px', color: '#333', 'text-align': 'right' });
const resetZoom = this._el('button', 'wbt-btn', 'Reset');
const applyZoom = () => {
Store.setKioskZoom(zoom.value);
zoomValue.textContent = Math.round(Store.getKioskZoom() * 100) + '%';
this._broadcastKioskLookPrefs();
};
zoom.oninput = applyZoom;
resetZoom.onclick = () => {
zoom.value = '1.38';
applyZoom();
};
zoomRow.appendChild(zoomLabel);
zoomRow.appendChild(zoom);
zoomRow.appendChild(zoomValue);
zoomRow.appendChild(resetZoom);
wrap.appendChild(zoomRow);
const hint = this._el('div', 'wbt-mute', 'These controls are read by the APK page layer. UI size enlarges cards, thumbnails, titles, and touch targets without moving the floating button.');
wrap.appendChild(hint);
wrap.appendChild(this._el('h4', null, 'Custom logo'));
const cur = Store.getCustomLogo();
if (cur) {
const img = this._el('img');
img.src = cur;
img.style.maxWidth = '100%';
img.style.maxHeight = '60px';
wrap.appendChild(img);
}
const row = this._el('div', 'wbt-row');
const file = this._el('input');
file.type = 'file';
file.accept = 'image/*';
file.onchange = () => {
const f = file.files && file.files[0];
if (!f) return;
const r = new FileReader();
r.onload = () => {
Store.setCustomLogo(r.result);
this._renderPanel(document.getElementById('wbt-panel'));
this._applyCustomLogo();
};
r.readAsDataURL(f);
};
const clear = this._el('button', 'wbt-btn', 'Clear');
clear.onclick = () => { Store.clearCustomLogo(); this._renderPanel(document.getElementById('wbt-panel')); };
row.appendChild(file); row.appendChild(clear);
wrap.appendChild(row);
return wrap;
}
_renderStats() {
const wrap = this._el('div');
wrap.appendChild(this._el('h4', null, 'Pool'));
const pool = this._el('table', 'wbt-stats');
const row = (k, v) => { const tr = this._el('tr'); tr.appendChild(this._el('td', null, k)); tr.appendChild(this._el('td', null, String(v))); pool.appendChild(tr); };
row('Pool size', Interceptor.poolSize());
row('Used', Interceptor.usedCount());
row('Active', Interceptor.isActive());
wrap.appendChild(pool);
wrap.appendChild(this._el('h4', null, 'Learning'));
const interests = Store.getCachedInterests();
const lc = interests ? InterestModel.getLearnedChannels(interests) : [];
const lk = interests ? InterestModel.getLearnedKeywords(interests) : [];
if (!lc.length && !lk.length) {
wrap.appendChild(this._el('div', 'wbt-mute', 'No learning data yet — watch some videos to build a profile.'));
} else {
wrap.appendChild(this._el('div', 'wbt-mute', 'Top channels:'));
for (const c of lc.slice(0, 6)) wrap.appendChild(this._el('div', null, `• ${c.name} (${c.score.toFixed(1)})`));
wrap.appendChild(this._el('div', 'wbt-mute', 'Top keywords:'));
for (const k of lk.slice(0, 6)) wrap.appendChild(this._el('div', null, `• ${k.keyword}`));
}
const clearBtn = this._el('button', 'wbt-btn', 'Clear learning data');
clearBtn.onclick = () => { if (confirm('Clear all watch history + learning?')) { Store.clearLearningData(); this._renderPanel(document.getElementById('wbt-panel')); } };
wrap.appendChild(clearBtn);
wrap.appendChild(this._el('h4', null, 'Diagnostics'));
const probeBtn = this._el('button', 'wbt-btn', 'Copy LOHP probe to clipboard');
probeBtn.onclick = () => {
const cards = Interceptor.findCards(document);
const lines = [`bygone-yt v${VERSION} LOHP probe`, `URL: ${location.href}`, `Cards found: ${cards.length}`, ''];
for (let i = 0; i < Math.min(cards.length, 20); i++) {
const c = cards[i];
const tag = c.tagName.toLowerCase();
const cls = c.className || '';
const swept = c.getAttribute('data-bygone-swept') || '';
const ok = c.getAttribute('data-bygone-ok') || '';
const keep = c.getAttribute('data-bygone-keep') || '';
const redated = c.getAttribute('data-bygone-redated') || '';
const link = c.querySelector('a[href*="/watch"]');
const href = link ? link.getAttribute('href') : 'NO-LINK';
const hasImg = !!c.querySelector('img[src*="ytimg"], img.bygone-thumb');
const vis = getComputedStyle(c).visibility;
const disp = getComputedStyle(c).display;
lines.push(`[${i}] <${tag} class="${cls.slice(0,80)}">`);
lines.push(` href=${href} img=${hasImg} swept="${swept}" ok="${ok}" keep="${keep}" redated="${redated}" vis=${vis} disp=${disp}`);
let parent = c.parentElement;
const ancestry = [];
for (let j = 0; j < 4 && parent; j++) {
ancestry.push(`${parent.tagName.toLowerCase()}${parent.className ? '.' + parent.className.split(/\s+/)[0] : ''}`);
parent = parent.parentElement;
}
lines.push(` parents: ${ancestry.join(' > ')}`);
}
const unmatched = document.querySelectorAll('a.lohp-video-link');
if (unmatched.length) {
lines.push('', `--- Unmatched a.lohp-video-link: ${unmatched.length} ---`);
for (let i = 0; i < Math.min(unmatched.length, 10); i++) {
const a = unmatched[i];
let parent = a.parentElement;
const ancestry = [];
for (let j = 0; j < 6 && parent; j++) {
ancestry.push(`${parent.tagName.toLowerCase()}${parent.id ? '#' + parent.id : ''}${parent.className ? '.' + parent.className.split(/\s+/)[0] : ''}`);
parent = parent.parentElement;
}
lines.push(`[${i}] href=${a.getAttribute('href')} text="${(a.textContent||'').trim().slice(0,50)}"`);
lines.push(` parents: ${ancestry.join(' > ')}`);
}
}
const allVideoLinks = document.querySelectorAll('a[href*="/watch?v="]');
const notSwept = [];
for (const a of allVideoLinks) {
const card = a.closest('[data-bygone-swept], [data-bygone-ok]');
if (!card) notSwept.push(a);
}
if (notSwept.length) {
lines.push('', `--- Unswept /watch links: ${notSwept.length} ---`);
for (let i = 0; i < Math.min(notSwept.length, 10); i++) {
const a = notSwept[i];
let parent = a.parentElement;
const ancestry = [];
for (let j = 0; j < 6 && parent; j++) {
ancestry.push(`${parent.tagName.toLowerCase()}${parent.id ? '#' + parent.id : ''}${parent.className ? '.' + parent.className.split(/\s+/)[0] : ''}`);
parent = parent.parentElement;
}
lines.push(`[${i}] href=${a.getAttribute('href')} text="${(a.textContent||'').trim().slice(0,50)}"`);
lines.push(` parents: ${ancestry.join(' > ')}`);
}
}
const txt = lines.join('\n');
navigator.clipboard.writeText(txt).then(() => {
probeBtn.textContent = 'Copied!';
setTimeout(() => { probeBtn.textContent = 'Copy LOHP probe to clipboard'; }, 2000);
});
};
wrap.appendChild(probeBtn);
const fullProbeBtn = this._el('button', 'wbt-btn', 'Copy FULL homepage DOM probe');
fullProbeBtn.onclick = () => {
const lines = [`bygone-yt v${VERSION} FULL DOM probe`, `URL: ${location.href}`, `Date: ${new Date().toISOString()}`, ''];
const content = document.querySelector('#content, #page-container, #page, body');
if (!content) { lines.push('NO content root found'); }
else {
// All watch links on page with full ancestry + surrounding HTML
const allLinks = document.querySelectorAll('a[href*="/watch?v="]');
lines.push(`=== ALL /watch links: ${allLinks.length} ===`, '');
const seen = new Set();
for (let i = 0; i < Math.min(allLinks.length, 30); i++) {
const a = allLinks[i];
const href = a.getAttribute('href') || '';
const text = (a.textContent || '').trim().slice(0, 60);
const hasImg = !!a.querySelector('img');
const imgSrc = a.querySelector('img') ? (a.querySelector('img').getAttribute('src') || '').slice(0, 80) : 'NONE';
const aVis = getComputedStyle(a).visibility;
const aDisp = getComputedStyle(a).display;
const aClass = (a.className || '').slice(0, 80);
lines.push(`[${i}] <a class="${aClass}" href="${href}">`);
lines.push(` text="${text}" hasImg=${hasImg} imgSrc=${imgSrc}`);
lines.push(` vis=${aVis} disp=${aDisp}`);
// Walk up 8 ancestors
const ancestry = [];
let p = a.parentElement;
for (let j = 0; j < 8 && p && p !== document.body; j++) {
const pTag = p.tagName.toLowerCase();
const pId = p.id ? '#' + p.id : '';
const pCls = p.className ? '.' + (p.className + '').split(/\s+/).slice(0, 3).join('.') : '';
const pVis = getComputedStyle(p).visibility;
const pDisp = getComputedStyle(p).display;
const swept = p.getAttribute('data-bygone-swept') || '';
const ok = p.getAttribute('data-bygone-ok') || '';
ancestry.push(`${pTag}${pId}${pCls}[vis=${pVis},disp=${pDisp},swept="${swept}",ok="${ok}"]`);
p = p.parentElement;
}
lines.push(` ancestry: ${ancestry.join(' > ')}`);
// Sibling content (what's next to this link)
const par = a.parentElement;
if (par && !seen.has(par)) {
seen.add(par);
const parHTML = (par.innerHTML || '').replace(/\s+/g, ' ').slice(0, 400);
lines.push(` parent innerHTML: ${parHTML}`);
}
lines.push('');
}
// All img elements on page
const allImgs = document.querySelectorAll('img');
let visibleImgs = 0, hiddenImgs = 0, bygoneImgs = 0;
for (const img of allImgs) {
const v = getComputedStyle(img).visibility;
const d = getComputedStyle(img).display;
if (img.classList.contains('bygone-thumb')) bygoneImgs++;
else if (v === 'visible' && d !== 'none' && img.offsetWidth > 5) visibleImgs++;
else hiddenImgs++;
}
lines.push(`=== IMAGES: total=${allImgs.length} visible=${visibleImgs} hidden=${hiddenImgs} bygone-thumb=${bygoneImgs} ===`, '');
// Sample first 10 visible imgs
let imgIdx = 0;
for (const img of allImgs) {
if (imgIdx >= 10) break;
const v = getComputedStyle(img).visibility;
const d = getComputedStyle(img).display;
if (v !== 'visible' || d === 'none' || img.offsetWidth < 5) continue;
const src = (img.getAttribute('src') || '').slice(0, 100);
const cls = (img.className || '').slice(0, 60);
const w = img.offsetWidth;
const h = img.offsetHeight;
lines.push(` img[${imgIdx}] class="${cls}" ${w}x${h} src=${src}`);
imgIdx++;
}
lines.push('');
// _findCards result
const cards = Interceptor.findCards(document);
lines.push(`=== _findCards: ${cards.length} cards ===`);
// Unique container classes
const clsCounts = {};
for (const c of cards) {
const k = c.tagName.toLowerCase() + '.' + (c.className || '').split(/\s+/).sort().join('.');
clsCounts[k] = (clsCounts[k] || 0) + 1;
}
for (const [k, v] of Object.entries(clsCounts)) lines.push(` ${v}x ${k}`);
lines.push('');
// Elements with hide CSS that are still visible
const hideSelectors = [
'ytd-rich-item-renderer', 'ytd-grid-video-renderer', 'ytd-video-renderer',
'ytd-compact-video-renderer', 'yt-lockup-view-model', '.yt-lockup-view-model',
'.lohp-large-shelf-container', '.lohp-medium-shelf', '.lohp-media-object',
'.video-list-item', '.channels-content-item', '.feed-item-container .yt-lockup',
'.yt-shelf-grid-item', '.yt-uix-shelfslider-item', '.expanded-shelf-content-item',
'.context-data-item.yt-lockup'
];
lines.push('=== Hide CSS selector hits ===');
for (const sel of hideSelectors) {
const els = document.querySelectorAll(sel);
if (els.length) lines.push(` "${sel}": ${els.length} elements`);
}
lines.push('');
// Top-level structure of content area
lines.push('=== Content area children (first 2 levels) ===');
const root = document.querySelector('#page-container, #page, #content, body');
if (root) {
for (const child of Array.from(root.children).slice(0, 20)) {
const cTag = child.tagName.toLowerCase();
const cId = child.id ? '#' + child.id : '';
const cCls = child.className ? '.' + (child.className + '').split(/\s+/).slice(0, 3).join('.') : '';
const cKids = child.children.length;
lines.push(` <${cTag}${cId}${cCls}> (${cKids} children)`);
for (const gc of Array.from(child.children).slice(0, 10)) {
const gTag = gc.tagName.toLowerCase();
const gId = gc.id ? '#' + gc.id : '';
const gCls = gc.className ? '.' + (gc.className + '').split(/\s+/).slice(0, 3).join('.') : '';
const gKids = gc.children.length;
lines.push(` <${gTag}${gId}${gCls}> (${gKids} children)`);
}
}
}
}
const txt = lines.join('\n');
navigator.clipboard.writeText(txt).then(() => {
fullProbeBtn.textContent = 'Copied!';
setTimeout(() => { fullProbeBtn.textContent = 'Copy FULL homepage DOM probe'; }, 2000);
});
};
wrap.appendChild(fullProbeBtn);
// GAP probe: find visible, tall, nearly-empty containers (the blank
// gaps left after pruning the logged-in feed), plus the LOHP's feed
// list children with their heights so the reserved space is obvious.
const gapProbeBtn = this._el('button', 'wbt-btn', 'Copy GAP probe');
gapProbeBtn.onclick = () => {
const L = [`bygone-yt v${VERSION} GAP probe`, `URL: ${location.href}`, ''];
const LOHP = '.lohp-media-object,.lohp-large-shelf-container,.lohp-medium-shelf,.lohp-newspaper-shelf';
// 1) Tall empty visible containers.
const out = [];
document.querySelectorAll('div,li,ul,section,ol').forEach(e => {
const cs = getComputedStyle(e);
if (cs.display === 'none' || cs.visibility === 'hidden') return;
if (e.offsetHeight < 60) return;
if (e.querySelector('a[href*="/watch"]')) return;
if (e.querySelector(LOHP)) return;
if ((e.textContent || '').trim().length > 15) return;
if (e.children.length > 4) return;
out.push({
tag: e.tagName.toLowerCase(),
cls: (e.className || '').toString().slice(0, 55),
id: e.id || '',
h: e.offsetHeight, kids: e.children.length,
mt: cs.marginTop, mb: cs.marginBottom, minH: cs.minHeight,
});
});
out.sort((a, b) => b.h - a.h);
L.push('=== TALL EMPTY CONTAINERS (gap suspects) ===');
L.push(JSON.stringify(out.slice(0, 18), null, 2), '');
// 2) LOHP feed-list ancestry + each child's height.
const lohp = document.querySelector('.lohp-newspaper-shelf') || document.querySelector(LOHP);
if (lohp) {
L.push('=== LOHP ancestry (tag.class [h]) ===');
let p = lohp, depth = 0;
while (p && p !== document.body && depth < 12) {
const cs = getComputedStyle(p);
L.push(` ${p.tagName.toLowerCase()}${p.id ? '#' + p.id : ''}.${(p.className || '').toString().split(/\s+/).slice(0, 3).join('.')} [h=${p.offsetHeight} minH=${cs.minHeight} display=${cs.display}]`);
p = p.parentElement; depth++;
}
L.push('');
// The feed list = nearest ancestor that has multiple element children.
let feed = lohp.parentElement;
while (feed && feed !== document.body && feed.children.length < 2) feed = feed.parentElement;
if (feed) {
L.push(`=== FEED LIST children of <${feed.tagName.toLowerCase()}${feed.id ? '#' + feed.id : ''}.${(feed.className || '').toString().split(/\s+/).slice(0, 2).join('.')}> ===`);
Array.from(feed.children).forEach((c, i) => {
const cs = getComputedStyle(c);
L.push(` [${i}] ${c.tagName.toLowerCase()}.${(c.className || '').toString().split(/\s+/).slice(0, 3).join('.')} h=${c.offsetHeight} disp=${cs.display} lohp=${!!c.querySelector(LOHP)} watch=${!!c.querySelector('a[href*="/watch"]')} txt=${(c.textContent || '').trim().length}`);
});
}
}
navigator.clipboard.writeText(L.join('\n')).then(() => {
gapProbeBtn.textContent = 'Copied!';
setTimeout(() => { gapProbeBtn.textContent = 'Copy GAP probe'; }, 2000);
});
};
wrap.appendChild(gapProbeBtn);
return wrap;
}
_renderSetup() {
const wrap = this._el('div');
wrap.appendChild(this._el('h4', null, 'Required Extensions'));
const v3Ok = _checkV3();
const v3Row = this._el('div', 'wbt-row');
const v3Status = this._el('span', null, v3Ok ? '✅ V3 detected' : '❌ V3 not detected');
this._style(v3Status, { color: v3Ok ? '#080' : '#c00', 'font-weight': 'bold', 'font-size': '12px' });
v3Row.appendChild(v3Status);
wrap.appendChild(v3Row);
if (!v3Ok) {
const warn = this._el('div');
this._style(warn, {
background: '#fff3cd', border: '1px solid #e0c36a',
'border-radius': '4px', padding: '8px 10px', margin: '8px 0',
color: '#664d03', 'font-size': '12px', 'line-height': '1.5',
});
warn.innerHTML = '<b>bygone-yt requires V3 and StarTube to work.</b><br>' +
'V3 ("Get Old YouTube Layout") provides the 2013 YouTube layout.<br>' +
'StarTube is the companion userscript that V3 depends on.<br><br>' +
'Without these, bygone-yt cannot function — the page will not render correctly ' +
'and you may experience refresh loops or broken layouts.<br><br>' +
'<b>Install both V3 and StarTube first, then reload the page.</b>';
wrap.appendChild(warn);
} else {
const ok = this._el('div', 'wbt-mute', 'V3 is installed and active. bygone-yt is ready to use.');
wrap.appendChild(ok);
}
wrap.appendChild(this._el('h4', null, 'About'));
const about = this._el('div', 'wbt-mute');
about.textContent = 'bygone-yt v' + VERSION + ' — YouTube time machine for V3/StarTube. ' +
'Set a date and browse YouTube as it was back then.';
wrap.appendChild(about);
return wrap;
}
// ---- Helpers -----------------------------------------------
// Style map keyed off class name. _el applies these inline whenever
// an element is created with a known class. This is what makes the
// UI work even when GM_addStyle is blocked or V3 strips our <style>.
static _STYLE_MAP = {
'wbt-sec': { 'margin-bottom': '14px' },
'wbt-row': { display: 'flex', gap: '6px', 'align-items': 'center', margin: '4px 0' },
'wbt-btn': {
padding: '3px 9px', border: '1px solid #888', background: '#ddd',
color: '#222', 'border-radius': '2px', cursor: 'pointer',
font: '12px sans-serif', 'white-space': 'nowrap',
},
'wbt-btn-primary': {
padding: '3px 9px', border: '1px solid #800', background: '#c00',
color: '#fff', 'border-radius': '2px', cursor: 'pointer',
font: '12px sans-serif', 'white-space': 'nowrap',
},
'wbt-btn-x': { padding: '0 6px', 'font-weight': 'bold', color: '#c00', border: '1px solid #888', background: '#ddd', cursor: 'pointer', 'border-radius': '2px', font: '12px sans-serif' },
'wbt-list': {
background: '#fff', border: '1px solid #aaa',
'border-radius': '2px', 'min-height': '28px',
'max-height': '110px', 'overflow-y': 'auto',
},
'wbt-item': {
padding: '3px 6px', 'border-bottom': '1px dotted #ddd',
display: 'flex', 'align-items': 'center', gap: '6px', cursor: 'grab',
},
'wbt-item-name': { flex: '1', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
'wbt-toggle': { display: 'flex', 'align-items': 'center', gap: '6px', margin: '3px 0', cursor: 'pointer' },
'wbt-mute': { color: '#666', 'font-size': '11px' },
'wbt-stats': { 'border-collapse': 'collapse' },
};
_el(tag, cls, text) {
const el = document.createElement(tag);
if (cls) {
el.className = cls;
// Apply inline styles for every class on the element.
for (const c of cls.split(/\s+/)) {
const sty = UI._STYLE_MAP[c];
if (sty) for (const k in sty) {
try { el.style.setProperty(k, sty[k], 'important'); } catch {}
}
}
}
if (text !== undefined && text !== null) el.textContent = text;
// <h4> headings — pure tag-based default style (used inside panels).
if (tag === 'h4') {
try {
el.style.setProperty('margin', '0 0 6px', 'important');
el.style.setProperty('font', 'bold 12px sans-serif', 'important');
el.style.setProperty('color', '#333', 'important');
el.style.setProperty('border-bottom', '1px dotted #aaa', 'important');
el.style.setProperty('padding-bottom', '3px', 'important');
} catch {}
}
// <input type="text"|"date"|"number">, <select> — style at use site
// since type isn't known until later. Done with a microtask defer.
if (tag === 'input' || tag === 'select') {
queueMicrotask(() => {
if (el.type === 'checkbox' || el.type === 'radio' || el.type === 'file') return;
try {
el.style.setProperty('flex', '1', 'important');
el.style.setProperty('padding', '3px 5px', 'important');
el.style.setProperty('border', '1px solid #aaa', 'important');
el.style.setProperty('border-radius', '2px', 'important');
el.style.setProperty('font', '12px sans-serif', 'important');
el.style.setProperty('min-width', '0', 'important');
el.style.setProperty('background', '#fff', 'important');
el.style.setProperty('color', '#222', 'important');
} catch {}
});
}
// Plain buttons (without wbt-btn class) — style anyway.
if (tag === 'button' && (!cls || !/wbt-btn/.test(cls))) {
try {
el.style.setProperty('padding', '3px 9px', 'important');
el.style.setProperty('border', '1px solid #888', 'important');
el.style.setProperty('background', '#ddd', 'important');
el.style.setProperty('color', '#222', 'important');
el.style.setProperty('border-radius', '2px', 'important');
el.style.setProperty('cursor', 'pointer', 'important');
el.style.setProperty('font', '12px sans-serif', 'important');
} catch {}
}
return el;
}
_toggle(label, value, onChange) {
const lab = this._el('label', 'wbt-toggle');
const cb = this._el('input');
cb.type = 'checkbox';
cb.checked = !!value;
cb.onchange = () => onChange(cb.checked);
lab.appendChild(cb);
lab.appendChild(document.createTextNode(' ' + label));
return lab;
}
_enableDrag(panel, handle) {
let dragging = false, ox = 0, oy = 0;
handle.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'SPAN' || e.target.tagName === 'BUTTON') return;
dragging = true;
const r = panel.getBoundingClientRect();
ox = e.clientX - r.left; oy = e.clientY - r.top;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
panel.style.left = (e.clientX - ox) + 'px';
panel.style.top = (e.clientY - oy) + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
});
document.addEventListener('mouseup', () => { dragging = false; });
}
_reloadFeed() {
App.primeInterceptor().catch(e => console.warn('[bygone] reload failed:', e));
}
_applyCustomLogo() {
const url = Store.getCustomLogo();
if (!url) return;
const apply = () => {
document.querySelectorAll('#logo img, #logo-icon img, .logo-icon img, a#logo img, .v3-logo img, #masthead-logo-link img')
.forEach(img => { img.src = url; });
};
apply();
setTimeout(apply, 800);
setTimeout(apply, 2500);
}
_ensureKioskLookStyle() {
if (document.getElementById('bygone-kiosk-look-page-style')) return;
const style = document.createElement('style');
style.id = 'bygone-kiosk-look-page-style';
style.textContent = [
'html[data-bygone-kiosk-dark="1"],html[data-bygone-kiosk-dark="1"] body{background:#0f0f0f!important;color:#e8eaed!important;}',
'html[data-bygone-kiosk-dark="1"] #page,html[data-bygone-kiosk-dark="1"] #content,html[data-bygone-kiosk-dark="1"] #body-container,html[data-bygone-kiosk-dark="1"] #masthead,html[data-bygone-kiosk-dark="1"] #watch7-container,html[data-bygone-kiosk-dark="1"] #watch7-main-container,html[data-bygone-kiosk-dark="1"] #watch7-sidebar,html[data-bygone-kiosk-dark="1"] #guide,html[data-bygone-kiosk-dark="1"] .yt-card,html[data-bygone-kiosk-dark="1"] .feed-item-container,html[data-bygone-kiosk-dark="1"] .yt-lockup,html[data-bygone-kiosk-dark="1"] .comment,html[data-bygone-kiosk-dark="1"] .post{background:#0f0f0f!important;color:#e8eaed!important;border-color:#303134!important;}',
'html[data-bygone-kiosk-dark="1"] .metadata,html[data-bygone-kiosk-dark="1"] .yt-lockup-meta,html[data-bygone-kiosk-dark="1"] .yt-lockup-byline{color:#aeb3b8!important;}',
'html[data-bygone-kiosk-dark="1"] a{color:#8ab4f8!important;}',
'html[data-bygone-kiosk-zoom="1"]{--bygone-kiosk-zoom:1.38;--bygone-card-thumb:calc(196px * var(--bygone-kiosk-zoom));--bygone-side-thumb:calc(118px * var(--bygone-kiosk-zoom));}',
'html[data-bygone-kiosk-zoom="1"] body{font-size:calc(12px * var(--bygone-kiosk-zoom))!important;transform:none!important;zoom:1!important;width:auto!important;min-height:auto!important;}',
'html[data-bygone-kiosk-zoom="1"] #wbt-fab{position:fixed!important;right:calc(24px + env(safe-area-inset-right,0px))!important;bottom:calc(24px + env(safe-area-inset-bottom,0px))!important;width:64px!important;height:64px!important;font-size:25px!important;line-height:64px!important;transform:none!important;zoom:1!important;}',
'html[data-bygone-kiosk-zoom="1"] #wbt-panel{position:fixed!important;right:12px!important;bottom:calc(96px + env(safe-area-inset-bottom,0px))!important;width:min(430px,calc(100vw - 24px))!important;max-height:calc(100vh - 128px - env(safe-area-inset-top,0px) - env(safe-area-inset-bottom,0px))!important;font-size:14px!important;transform:none!important;zoom:1!important;}',
'html[data-bygone-kiosk-zoom="1"] .yt-lockup-title,html[data-bygone-kiosk-zoom="1"] .yt-lockup-title a,html[data-bygone-kiosk-zoom="1"] .lohp-video-link,html[data-bygone-kiosk-zoom="1"] #video-title,html[data-bygone-kiosk-zoom="1"] .title{font-size:calc(13px * var(--bygone-kiosk-zoom))!important;line-height:1.25!important;}',
'html[data-bygone-kiosk-zoom="1"] .yt-lockup-meta,html[data-bygone-kiosk-zoom="1"] .yt-lockup-byline,html[data-bygone-kiosk-zoom="1"] .lohp-video-metadata,html[data-bygone-kiosk-zoom="1"] .metadata,html[data-bygone-kiosk-zoom="1"] .bygone-meta{font-size:calc(11px * var(--bygone-kiosk-zoom))!important;line-height:1.3!important;}',
'html[data-bygone-kiosk-zoom="1"] .yt-uix-shelfslider-item,html[data-bygone-kiosk-zoom="1"] .yt-lockup-grid,html[data-bygone-kiosk-zoom="1"] .context-data-item.yt-lockup-grid{width:var(--bygone-card-thumb)!important;max-width:var(--bygone-card-thumb)!important;margin-right:calc(14px * var(--bygone-kiosk-zoom))!important;margin-bottom:calc(16px * var(--bygone-kiosk-zoom))!important;}',
'html[data-bygone-kiosk-zoom="1"] .lohp-medium-shelves-container{display:flex!important;flex-wrap:wrap!important;gap:calc(12px * var(--bygone-kiosk-zoom))!important;}',
'html[data-bygone-kiosk-zoom="1"] .lohp-medium-shelf{width:var(--bygone-card-thumb)!important;max-width:var(--bygone-card-thumb)!important;margin:0 calc(12px * var(--bygone-kiosk-zoom)) calc(16px * var(--bygone-kiosk-zoom)) 0!important;}',
'html[data-bygone-kiosk-zoom="1"] .yt-thumb,html[data-bygone-kiosk-zoom="1"] .ux-thumb-wrap,html[data-bygone-kiosk-zoom="1"] .video-thumb,html[data-bygone-kiosk-zoom="1"] .yt-lockup-thumbnail,html[data-bygone-kiosk-zoom="1"] .lohp-media-object{width:var(--bygone-card-thumb)!important;max-width:var(--bygone-card-thumb)!important;height:auto!important;}',
'html[data-bygone-kiosk-zoom="1"] .yt-thumb img,html[data-bygone-kiosk-zoom="1"] .ux-thumb-wrap img,html[data-bygone-kiosk-zoom="1"] .video-thumb img,html[data-bygone-kiosk-zoom="1"] .yt-lockup-thumbnail img,html[data-bygone-kiosk-zoom="1"] .lohp-media-object img,html[data-bygone-kiosk-zoom="1"] img.bygone-thumb{width:100%!important;height:auto!important;aspect-ratio:16/9!important;object-fit:cover!important;}',
'html[data-bygone-kiosk-zoom="1"] #watch7-sidebar .video-list-item .video-thumb,html[data-bygone-kiosk-zoom="1"] #watch7-sidebar .yt-lockup-thumbnail,html[data-bygone-kiosk-zoom="1"] #watch7-sidebar .yt-thumb{width:var(--bygone-side-thumb)!important;max-width:var(--bygone-side-thumb)!important;}',
].join('\n');
(document.head || document.documentElement).appendChild(style);
}
_applyKioskLookPrefs() {
const root = document.documentElement;
if (!root) return;
this._ensureKioskLookStyle();
if (Store.getKioskDarkMode()) root.setAttribute('data-bygone-kiosk-dark', '1');
else root.removeAttribute('data-bygone-kiosk-dark');
root.setAttribute('data-bygone-kiosk-zoom', '1');
root.style.setProperty('--bygone-kiosk-zoom', String(Store.getKioskZoom()));
}
_broadcastKioskLookPrefs() {
this._applyKioskLookPrefs();
try {
const target = (typeof unsafeWindow !== 'undefined' && unsafeWindow) || window;
const Ctor = target.CustomEvent || CustomEvent;
target.dispatchEvent(new Ctor('bygone-kiosk-look-changed', {
detail: { dark: Store.getKioskDarkMode(), zoom: Store.getKioskZoom() }
}));
} catch {}
}
}
// ============================================================
// APP — wire everything
// ============================================================
class App {
static async init() {
// Validate stored time offset (max 24h drift; reset garbage values).
const offset = Store.getTimeOffset();
if (Math.abs(offset) > 86400000) Store.setTimeOffset(0);
// On version bump, clear cached source results (they may be stale or
// use shapes the new code doesn't understand). ALSO wipe the
// impression park + seen-id list: with `recordImpressions`
// logging 60 ids per load and parking after 3 impressions for
// 7 days, repeated reloads (testing, dev cycles, or even
// ordinary use) shrink the visible pool to a handful of
// videos. A version bump is the safe time to reset.
const lastVersion = Store._get('bygone_last_version', 0);
if (lastVersion < VERSION) {
try { for (const k of GM_listValues()) if (k.startsWith('bygone_cache_')) GM_deleteValue(k); } catch {}
try { Store.setImpressions({}); } catch {}
try { Store.setSeenIds([]); } catch {}
Store._set('bygone_last_version', VERSION);
}
// Sync with external time (non-blocking, silent on failure).
App._syncTime();
// Wait for body (V3 strips the YT shell so we don't wait for ytd-app).
await App._waitForBody();
const api = new YouTubeAPI();
const feedEngine = new FeedEngine(api);
App._api = api;
App._feedEngine = feedEngine;
App._ui = new UI(api, feedEngine);
// Default date = 5 years ago if unset.
if (!Store.getDate()) {
const d = new Date();
d.setFullYear(d.getFullYear() - 5);
Store.setDate(Store._formatLocalDate ? Store._formatLocalDate(d) : d.toISOString().split('T')[0]);
}
// MOUNT UI FIRST — before priming the pool. The feed build can
// take up to 30 s (or fail if no sources are configured), and
// awaiting it before showing the panel meant a fresh install
// saw no UI at all. The user needs the panel to even add
// sources, so it has to come up immediately.
try { App._ui.init(); } catch (e) { console.error('[bygone] UI mount failed:', e); }
setTimeout(() => App._maybeShowDependencyPrompt(), 800);
setInterval(() => {
if (!document.getElementById('wbt-panel') && !document.getElementById('wbt-fab')) {
try { App._ui.init(); } catch {}
}
}, 5000);
// Prime the interceptor pool IN THE BACKGROUND so V3 gets its
// videos as soon as they're ready, but the UI stays responsive.
App.primeInterceptor().catch(e => console.error('[bygone] prime failed:', e));
// Apply custom logo on every nav.
const applyLogo = () => { try { App._ui._applyCustomLogo(); } catch {} };
applyLogo();
window.addEventListener('yt-navigate-finish', applyLogo);
window.addEventListener('popstate', applyLogo);
// Apply APK page look prefs on load/nav. The APK extension also
// watches these same localStorage keys and applies them earlier.
const applyLook = () => { try { App._ui._applyKioskLookPrefs(); } catch {} };
applyLook();
window.addEventListener('yt-navigate-finish', applyLook);
window.addEventListener('popstate', applyLook);
// Watch-history tracking: on watch page, after ≥ 15 s of watching,
// record the watch event.
App._wireWatchTracking();
// Subscribe hijack + auto-sync bygone subs to YouTube account.
App._installSubscribeHijack();
App._scheduleSubSync(2000); // initial sync after 2 s
// Search-date-filter: append before:YYYY-MM-DD to search_query
// (in the URL) but keep the visible search input clean.
App._installSearchHijack();
// Channel page handler — fetch that channel's videos by ID.
window.addEventListener('yt-navigate-finish', () => {
if (Interceptor.isChannelPage()) {
setTimeout(() => App._handleChannelPage().catch(e => console.warn('[bygone] channel page error:', e)), 800);
} else {
App._channelPageActive = null;
}
});
if (Interceptor.isChannelPage()) {
setTimeout(() => App._handleChannelPage().catch(e => console.warn('[bygone] channel page error:', e)), 1500);
}
console.log(`[bygone] v2 (${VERSION}) ready. Date: ${Store.getCurrentDate()}`);
}
static _depsReady() {
return { v3: _checkV3(), starTube: _checkStarTube() };
}
static _maybeShowDependencyPrompt() {
try {
if (Store.hasSeenDependencyPrompt()) return;
const deps = App._depsReady();
if (deps.v3 && deps.starTube) return;
Store.markDependencyPromptSeen();
App._renderDependencyPrompt(deps);
} catch (e) {
console.warn('[bygone] dependency prompt failed:', e);
}
}
static _renderDependencyPrompt(deps) {
if (!document.body || document.getElementById('wbt-dep-modal')) return;
const modal = document.createElement('div');
modal.id = 'wbt-dep-modal';
const box = document.createElement('div');
box.id = 'wbt-dep-modal-box';
const title = document.createElement('div');
title.textContent = 'bygone-yt needs V3 and StarTube';
title.style.cssText = 'font:bold 14px sans-serif;margin-bottom:8px;color:#222;';
box.appendChild(title);
const missing = [];
if (!deps.v3) missing.push('V3 / VORAPIS');
if (!deps.starTube) missing.push('StarTube');
const body = document.createElement('div');
body.style.cssText = 'line-height:1.45;color:#333;margin-bottom:8px;';
body.textContent = 'Missing: ' + missing.join(', ') + '. Install the missing scripts, then reload YouTube.';
box.appendChild(body);
const note = document.createElement('div');
note.textContent = 'This message only appears once. The Setup tab will still show dependency status later.';
note.style.cssText = 'font-size:11px;color:#666;margin-bottom:10px;';
box.appendChild(note);
const actions = document.createElement('div');
actions.className = 'wbt-dep-actions';
const mkBtn = (label, primary, onClick) => {
const b = document.createElement('button');
b.textContent = label;
b.className = primary ? 'wbt-btn wbt-btn-primary' : 'wbt-btn';
b.style.cssText = 'padding:4px 9px;border:1px solid ' + (primary ? '#800' : '#888') +
';background:' + (primary ? '#c00' : '#ddd') +
';color:' + (primary ? '#fff' : '#222') +
';border-radius:2px;cursor:pointer;font:12px sans-serif;';
b.addEventListener('click', onClick);
return b;
};
const close = () => { try { modal.remove(); } catch (_) {} };
if (!deps.v3) {
actions.appendChild(mkBtn('Install V3', true, () => {
window.open(CONFIG.installUrls.v3, '_blank', 'noopener,noreferrer');
close();
}));
}
if (!deps.starTube) {
actions.appendChild(mkBtn('Install StarTube', true, () => {
window.open(CONFIG.installUrls.starTube, '_blank', 'noopener,noreferrer');
close();
}));
}
actions.appendChild(mkBtn('Open setup', false, () => {
close();
try {
if (App._ui) App._ui._activeTab = 'setup';
App._ui.init();
const panel = document.getElementById('wbt-panel');
if (panel) {
panel.classList.remove('wbt-hidden');
panel.style.setProperty('display', 'block', 'important');
}
} catch (_) {}
}));
actions.appendChild(mkBtn('Close', false, close));
box.appendChild(actions);
modal.appendChild(box);
document.body.appendChild(modal);
}
// ---- Search-date-filter hijack ---------------------------
// Goal: every search the user runs should be limited to videos
// published before the configured time-machine date, but the
// search bar itself should NEVER show the `before:YYYY-MM-DD`
// tag. The tag lives only in the URL / link target.
//
// Two paths cover this:
// 1. Submit-time: intercept the search form's submit and
// temporarily mutate the input.value to append the tag.
// YT reads input.value to build the navigation URL, so
// the resulting /results URL has `before:` in it. We
// restore the visible value on the next tick so the user
// never sees the tag.
// 2. URL-fixup: if a /results page is reached without
// the tag (back-button, deep link, programmatic nav),
// redirect to the same URL with the tag appended.
// After landing on /results, a low-frequency interval keeps
// the visible input scrubbed (YT re-renders it on nav).
static _installSearchHijack() {
const TAG_RE = /\s*before:\d{4}-\d{2}-\d{2}/g;
const cleanOf = (s) => (s || '').replace(TAG_RE, '').trim();
const hasTag = (s) => /before:\d{4}-\d{2}-\d{2}/.test(s || '');
const findSearchInput = () => {
return document.querySelector(
'input#search, input[name="search_query"], input#masthead-search-term'
);
};
const scrubVisibleInput = () => {
const inp = findSearchInput();
if (!inp) return;
if (hasTag(inp.value)) inp.value = cleanOf(inp.value);
};
// Path 2: URL-level fixup on /results.
const applyUrlFixup = () => {
if (!Store.isActive()) return;
const p = location.pathname;
if (p !== '/results' && p !== '/results/') return;
const dateStr = Store.getCurrentDate();
if (!dateStr) return;
const params = new URLSearchParams(location.search);
const query = params.get('search_query') || '';
if (!query) return;
if (hasTag(query)) { scrubVisibleInput(); return; }
params.set('search_query', `${query} before:${dateStr}`.trim());
window.location.replace(`/results?${params.toString()}`);
};
// Path 1: capture-phase submit hook. Runs before YT's own
// handlers, so by the time YT reads input.value to build
// the URL, the tag is already there.
document.addEventListener('submit', (e) => {
if (!Store.isActive()) return;
const form = e.target;
if (!form || form.tagName !== 'FORM') return;
const input = form.querySelector(
'input[name="search_query"], input#search, input#masthead-search-term'
);
if (!input || !input.value.trim()) return;
if (hasTag(input.value)) return;
const dateStr = Store.getCurrentDate();
if (!dateStr) return;
const original = input.value;
try { Store.addSearchQuery(original); } catch (_) {}
input.value = `${original.trim()} before:${dateStr}`;
// Restore the visible value after YT has read it to
// build the navigation URL. Microtask is too early
// (sometimes runs before YT's submit handler); a 0ms
// timeout is safe.
setTimeout(() => {
const live = findSearchInput();
if (live) live.value = original;
}, 0);
}, true);
// Same idea for Enter keydown — some YT layouts fire nav
// directly off the keypress without a form submit event.
document.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || e.isComposing) return;
if (!Store.isActive()) return;
const t = e.target;
if (!t || !t.matches) return;
if (!t.matches('input[name="search_query"], input#search, input#masthead-search-term')) return;
if (!t.value.trim() || hasTag(t.value)) return;
const dateStr = Store.getCurrentDate();
if (!dateStr) return;
const original = t.value;
try { Store.addSearchQuery(original); } catch (_) {}
t.value = `${original.trim()} before:${dateStr}`;
setTimeout(() => {
const live = findSearchInput();
if (live) live.value = original;
}, 0);
}, true);
// Run URL fixup on initial load + every nav.
applyUrlFixup();
window.addEventListener('yt-navigate-finish', () => {
applyUrlFixup();
setTimeout(scrubVisibleInput, 100);
setTimeout(scrubVisibleInput, 500);
});
window.addEventListener('popstate', () => {
applyUrlFixup();
setTimeout(scrubVisibleInput, 100);
});
// Low-frequency scrubber for YT re-renders of the input.
setInterval(() => {
const p = location.pathname;
if (p === '/results' || p === '/results/') scrubVisibleInput();
}, 1000);
}
// ---- Subscribe button click hijack -----------------------
// When the user clicks any "Subscribe" button anywhere on
// YouTube (V3 2013 markup OR modern), add the channel to the
// bygone subscriptions list. Doesn't BLOCK YouTube's own
// subscribe flow — just records the channel for us.
static _installSubscribeHijack() {
const isSubButton = (el) => {
for (let i = 0; i < 8 && el && el !== document.body; i++) {
const cls = (el.className && el.className.toString && el.className.toString()) || '';
if (/yt-uix-button-subscribe-branded|yt-uix-subscription-button|subscribe-button-renderer|ytd-subscribe-button/i.test(cls)) return el;
const aria = el.getAttribute && (el.getAttribute('aria-label') || '');
if (/^subscribe/i.test(aria) || /^subscribed/i.test(aria)) return el;
const txt = (el.textContent || '').trim().toLowerCase();
if (txt === 'subscribe' || txt === 'subscribed') return el;
el = el.parentElement;
}
return null;
};
document.addEventListener('click', async (e) => {
if (!isSubButton(e.target)) return;
// Detect whether the action was a SUBSCRIBE or an
// UNSUBSCRIBE by reading state RIGHT before the click
// takes effect. If the button currently reads
// "Subscribed", the click will unsubscribe.
let wasSubscribed = false;
try {
let el = e.target;
for (let i = 0; i < 8 && el; i++) {
const txt = (el.textContent || '').trim().toLowerCase();
if (txt === 'subscribed') { wasSubscribed = true; break; }
if (txt === 'subscribe') { wasSubscribed = false; break; }
el = el.parentElement;
}
} catch {}
// Give YouTube's own handler a moment to fire so the page
// state settles, then extract channel info from the DOM.
setTimeout(async () => {
try {
const info = await App._extractChannelInfo();
if (!info || !info.id) return;
const subs = Store.getSubscriptions();
const i = subs.findIndex(s => s.id === info.id);
if (wasSubscribed) {
// User just unsubscribed → remove from bygone.
if (i >= 0) {
subs.splice(i, 1);
Store.setSubscriptions(subs);
console.log('[bygone] removed subscription:', info.name);
}
} else {
// User just subscribed → add to bygone.
if (i < 0) {
subs.push({ id: info.id, name: info.name, weight: 3 });
Store.setSubscriptions(subs);
Store.markSubSynced(info.id); // already on YT
console.log('[bygone] added subscription:', info.name);
}
}
} catch (err) {
console.warn('[bygone] sub hijack error:', err);
}
}, 200);
}, true);
}
// Pull channel ID + name from the current page (works on
// channel pages, watch pages, and anywhere a channel link is
// visible near the top of the page).
static async _extractChannelInfo() {
const path = location.pathname;
// Direct /channel/UC...
let m = path.match(/^\/channel\/(UC[A-Za-z0-9_-]+)/);
if (m) {
const id = m[1];
const name = App._scrapeChannelName() || id;
return { id, name };
}
// /@handle, /c/, /user/ — find a channel link on the page
// whose href is /channel/UC...
const link = document.querySelector('a[href^="/channel/UC"]');
if (link) {
const href = link.getAttribute('href') || '';
const m2 = href.match(/^\/channel\/(UC[A-Za-z0-9_-]+)/);
if (m2) {
const id = m2[1];
const name = (link.textContent || '').trim() || App._scrapeChannelName() || id;
return { id, name };
}
}
// Fall back: try to resolve via the name shown on the page
// (slower; one API hit).
const name = App._scrapeChannelName();
if (!name) return null;
try {
const ch = await App._api.resolveChannel(name);
if (ch && ch.id) return { id: ch.id, name: ch.name || name };
} catch {}
return null;
}
static _scrapeChannelName() {
const sels = [
'.qualified-channel-title-text',
'.channel-header-profile-image-container .channel-title',
'#channel-header-container .channel-name',
'#channel-name',
'ytd-channel-name',
'.ytd-channel-name',
'.attribution .g-hovercard',
'.attribution .yt-user-name',
'.attribution',
];
for (const sel of sels) {
const el = document.querySelector(sel);
if (el && el.textContent && el.textContent.trim()) {
return el.textContent.trim().replace(/^by\s+/i, '');
}
}
return null;
}
// Auto-sync bygone subscriptions to the user's YouTube account.
// For every bygone sub with a channel ID that hasn't been synced
// yet, fire a subscribe API call. Rate-limited via the YouTubeAPI
// internal cooldown.
static async _syncSubsToYouTube() {
if (!Store.isAutoSyncSubs()) return;
const subs = Store.getSubscriptions();
const synced = new Set(Store.getSyncedSubIds());
const pending = subs.filter(s => s && s.id && !synced.has(s.id));
if (!pending.length) return;
console.log(`[bygone] syncing ${pending.length} subscription(s) to YouTube…`);
for (const sub of pending) {
try {
const ok = await App._api.subscribeToChannel(sub.id);
if (ok) {
Store.markSubSynced(sub.id);
console.log('[bygone] subscribed on YouTube:', sub.name);
}
} catch (e) {
console.warn('[bygone] sync error for', sub.name, e.message);
}
}
}
// Debounced trigger — coalesces multiple changes into one batch
// (e.g. when the user adds three subs in a row in the panel).
static _scheduleSubSync(delay) {
if (App._syncTimer) clearTimeout(App._syncTimer);
App._syncTimer = setTimeout(() => {
App._syncTimer = null;
App._syncSubsToYouTube().catch(e => console.warn('[bygone] sync failed:', e));
}, delay || 1500);
}
// Build the feed and feed it into the interceptor pool. Also wires
// the lazy fetcher for infinite scroll.
static async primeInterceptor() {
try {
const date = Store.getCurrentDate();
if (!date) return;
const videos = await App._feedEngine.buildHomeFeed(date);
if (videos && videos.length) {
Interceptor.setVideos(videos);
console.log('[bygone] primed with', videos.length, 'videos');
App._enrichExactDates(videos, date); // background, non-blocking
}
Interceptor.setLazyFetcher(async (page) => {
const cur = Store.getCurrentDate();
if (!cur) return [];
const more = await App._feedEngine.buildHomeFeedMore(cur, page, Interceptor.getPoolIds());
App._enrichExactDates(more, cur);
return more;
});
} catch (e) {
console.error('[bygone] prime failed', e);
}
}
// Fetch EXACT publish dates for the videos most likely to be shown, so
// their "X ago" reads precisely against the set date instead of being
// guessed from year-granular strings. Only the ambiguous ones (coarse
// approx within ~400 days of the set date, where year-granularity
// fails) are fetched; clearly-older videos relativize fine already.
// Results cache persistently per id, so this cost is paid once across
// loads. Runs in the background and refreshes displayed dates per
// chunk as they arrive. Guarded so only one run happens at a time.
static _enrichRunning = false;
static async _enrichExactDates(videos, setDateStr) {
if (App._enrichRunning || !videos || !videos.length) return;
// Home-feed only. The watch page doesn't show the home feed, and
// firing dozens of /next calls there floods the session with API
// traffic (on top of the page's own player/next/comment requests),
// which can get the session rate-limited and the player reloading.
const _p = location.pathname;
if (_p !== '/' && _p !== '' && _p !== '/feed/trending') return;
const anchor = new Date(setDateStr).getTime();
if (isNaN(anchor)) return;
const AMBIG_MS = 400 * 86400000;
const need = [];
for (const v of videos) {
if (!v || !v.id || Store.getExactDate(v.id)) continue;
const approx = DateHelper.approxPublishDate(v.relativeDate);
if (approx && Math.abs(anchor - approx.getTime()) > AMBIG_MS) continue;
need.push(v.id);
if (need.length >= 80) break; // prioritise the soonest-shown
}
if (!need.length) return;
App._enrichRunning = true;
try {
const CHUNK = 10;
for (let i = 0; i < need.length; i += CHUNK) {
const slice = need.slice(i, i + CHUNK);
const map = {};
await Promise.all(slice.map(async id => {
const iso = await App._api.fetchExactDate(id);
if (iso) map[id] = iso;
}));
if (Object.keys(map).length) {
Store.addExactDates(map);
try { Interceptor.refreshAllDates(); } catch (_) {}
}
// Bail if the user changed the era mid-fetch.
if (Store.getCurrentDate() !== setDateStr) break;
}
console.log('[bygone] exact-date enrichment done for', need.length, 'videos');
} catch (_) {
} finally {
App._enrichRunning = false;
}
}
static _channelPageActive = null;
static async _handleChannelPage() {
if (!Interceptor.isChannelPage()) return;
const date = Store.getCurrentDate();
if (!date) return;
const info = await App._extractChannelInfo();
if (!info || !info.id) {
console.warn('[bygone] channel page: could not extract channelId');
return;
}
if (App._channelPageActive === info.id) return;
App._channelPageActive = info.id;
console.log('[bygone] channel page: fetching videos for', info.id, info.name);
try {
const videos = await App._api.getChannelVideos(info.name, {
channelId: info.id,
publishedBefore: date,
maxResults: 50,
});
if (!videos || !videos.length) {
console.log('[bygone] channel page: no videos found before', date);
return;
}
videos.sort((a, b) => {
const da = DateHelper.approxPublishDate(a.relativeDate);
const db = DateHelper.approxPublishDate(b.relativeDate);
if (!da && !db) return 0;
if (!da) return 1;
if (!db) return -1;
return db.getTime() - da.getTime();
});
for (const v of videos) {
if (v.relativeDate) {
try {
v.relativeDate = DateHelper.recalcForFeed(v.relativeDate, date, v.id) || v.relativeDate;
} catch (_) {}
}
}
console.log('[bygone] channel page: fetched', videos.length, 'videos for', info.id);
const tryRewrite = () => {
const cards = Interceptor.findCards(document);
if (!cards.length) return false;
const channelCards = cards.filter(c => {
const a = c.querySelector('a[href*="/watch"]');
return !!a;
});
const inner = [];
let wrote = 0;
for (let i = 0; i < channelCards.length; i++) {
if (i < videos.length) {
try {
Interceptor.rewriteCard(channelCards[i], videos[i], inner);
channelCards[i].setAttribute('data-bygone-ok', '1');
wrote++;
} catch (_) {}
} else {
try { channelCards[i].style.setProperty('display', 'none', 'important'); } catch (_) {}
}
}
return wrote > 0;
};
if (!tryRewrite()) {
setTimeout(tryRewrite, 500);
setTimeout(tryRewrite, 1500);
setTimeout(tryRewrite, 3000);
}
} catch (e) {
console.error('[bygone] channel page fetch failed:', e);
}
}
static _syncTime() {
try {
GM_xmlhttpRequest({
method: 'GET', url: 'https://worldtimeapi.org/api/ip', timeout: 5000,
onload(res) {
try {
const data = JSON.parse(res.responseText);
if (!data || !data.unixtime) return;
const drift = data.unixtime * 1000 - Date.now();
// Cap drift at 24h — anything bigger is garbage.
if (Math.abs(drift) > 86400000) return;
Store.setTimeOffset(Math.abs(drift) > 30000 ? drift : 0);
} catch {}
},
onerror() {}, ontimeout() {},
});
} catch {}
}
static _waitForBody() {
return new Promise(resolve => {
let waited = 0;
const check = () => {
if (document.body) return resolve();
waited += 200;
if (waited >= 10000) return resolve();
setTimeout(check, 200);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', check, { once: true });
else check();
});
}
// On watch page, count a "watch" after 15s of being on it. Cheap,
// doesn't try to read the video element (which V3's player wraps).
static _wireWatchTracking() {
let timer = null;
const tick = () => {
if (timer) { clearTimeout(timer); timer = null; }
if (!location.pathname.startsWith('/watch')) return;
const m = location.search.match(/[?&]v=([A-Za-z0-9_-]+)/);
if (!m) return;
const videoId = m[1];
timer = setTimeout(() => {
if (!location.pathname.startsWith('/watch')) return;
if (location.search.indexOf(videoId) === -1) return;
const titleEl = document.querySelector('.watch-title, #eow-title, h1.title, .watch-page-title') || document.querySelector('title');
const chanEl = document.querySelector('.yt-user-name, .watch-user-name, .attribution .g-hovercard, .attribution');
let channelId = null;
const chanLink = document.querySelector(
'a[href*="/channel/UC"], .yt-user-name[href*="/channel/"], .attribution a[href*="/channel/"]'
);
if (chanLink) {
const cm = (chanLink.getAttribute('href') || '').match(/\/channel\/(UC[A-Za-z0-9_-]+)/);
if (cm) channelId = cm[1];
}
if (!channelId) {
const pv = Interceptor.getPoolVideo(videoId);
if (pv && pv.channelId) channelId = pv.channelId;
}
Store.addWatchEvent({
videoId,
title: titleEl ? titleEl.textContent.trim().slice(0, 200) : '',
channel: chanEl ? chanEl.textContent.trim().slice(0, 80) : '',
channelId,
ts: Date.now(),
});
}, 15000);
};
window.addEventListener('yt-navigate-finish', tick);
window.addEventListener('popstate', tick);
tick();
}
}
// ============================================================
// ENTRY
// ============================================================
App.init();
})();