Files
goon_plugin/src/viewer/index.js
T
robot 42c1ad75c8
CI / test (pull_request) Successful in 6m29s
feat(viewer): persist hide/show instructions state in chrome.storage.local
Read the instructions panel's visibility on mount from
chrome.storage.local['goon_instructions_visible'] (absent => shown, the
first-time default) and persist the new value on every user toggle, so a
'hide instructions' choice survives popup reloads and service-worker
termination and is respected across the app via the shared storage key.

Adds unit tests: stored-hidden honored on mount, persistence on both toggle
directions, and first-time (absent key) defaulting to shown.
2026-09-30 03:09:25 +00:00

1386 lines
60 KiB
JavaScript

/**
* index.js - Popup logic for the Goon Plugin.
* Runs inside the popup; scans the current window's tabs for videos
* and stores the results for the viewer to consume.
*/
const STORAGE_KEY = 'goon_videos';
const TAGGED_KEY = 'goon_tagged';
// Durable "hide instructions" toggle. Absent key === SHOWN (true), matching the
// first-time "expanded on mount" behavior. chrome.storage.local is extension-wide,
// so the viewer window and every popup instance read/write the same value — the
// preference is respected application-wide and survives reloads / SW termination.
const INSTRUCTIONS_VISIBLE_KEY = 'goon_instructions_visible';
// Set to true to enable verbose console output in the popup.
const DEBUG = true; // TEMP: enabled for debugging — revert to false when done
function log(...args) { if (DEBUG) console.log('[vidplex]', ...args); }
function warn(...args) { if (DEBUG) console.warn('[vidplex]', ...args); }
/** Log a message to the active tab's page console (visible to the user in DevTools). */
async function logToPage(msg) {
if (!DEBUG) return;
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) return;
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: (m) => { console.log('[vidplex-page]', m); },
args: [msg],
});
} catch { /* ignore — tab may have navigated away */ }
}
// UI references
const findBtn = document.getElementById('find-btn');
const clearBtn = document.getElementById('clear-btn');
const openBtn = document.getElementById('open-btn');
const statusEl = document.getElementById('status');
const listEl = document.getElementById('video-list');
const btnRow = document.getElementById('btn-row');
const instructionsToggle = document.getElementById('instructions-toggle');
const instructionsBody = document.getElementById('instructions-body');
listEl.style.wordBreak = 'break-word';
listEl.style.whiteSpace = 'pre-wrap';
function setInstructionsExpanded(expanded) {
if (!instructionsToggle || !instructionsBody) return;
instructionsBody.hidden = !expanded;
instructionsToggle.setAttribute('aria-expanded', String(expanded));
instructionsToggle.textContent = expanded ? 'Hide instructions' : 'Show instructions';
}
if (instructionsToggle && instructionsBody) {
// On mount, read the stored visibility. An absent key means SHOWN (true), so a
// first-time user still sees the steps. The viewer window reads the same
// extension-wide key, so the choice is respected across the whole app.
chrome.storage.local.get(INSTRUCTIONS_VISIBLE_KEY).then(stored => {
const visible = stored[INSTRUCTIONS_VISIBLE_KEY];
setInstructionsExpanded(visible === undefined ? true : visible);
}).catch(() => {
// Any read failure falls back to shown.
setInstructionsExpanded(true);
});
// On every user toggle, apply in-DOM and persist. Both directions write, so
// the state survives reloads and SW termination until the user flips it again.
instructionsToggle.addEventListener('click', () => {
const next = instructionsBody.hidden;
setInstructionsExpanded(next);
chrome.storage.local.set({ [INSTRUCTIONS_VISIBLE_KEY]: next });
});
}
/* ======================================================================== */
/* UI helpers */
/* ======================================================================== */
function setStatus(msg) {
statusEl.textContent = msg;
log(msg);
}
/** Load the current video list and tags from storage. */
function loadVideos() {
return chrome.storage.local.get([STORAGE_KEY, TAGGED_KEY]).then(data => ({
videos: data[STORAGE_KEY] || [],
tags: data[TAGGED_KEY] || [],
}));
}
/** Render the video list UI from stored video entries. */
function renderList(videos) {
listEl.innerHTML = '';
if (!videos || videos.length === 0) {
listEl.style.display = 'none';
return;
}
listEl.style.display = 'block';
for (const video of videos) {
const link = document.createElement('a');
link.className = 'video-item';
link.href = video.src || video.url || '';
link.textContent = video.label ? `${video.label}: ${link.href}` : link.href;
link.target = '_blank';
listEl.appendChild(link);
}
// Copy all URLs button
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-btn';
copyBtn.textContent = 'Copy All URLs';
copyBtn.addEventListener('click', () => {
const urls = videos.map(v => v.src || v.url);
navigator.clipboard.writeText(urls.join('\n')).then(() => {
copyBtn.textContent = 'Copied!';
setTimeout(() => { copyBtn.textContent = 'Copy All URLs'; }, 1500);
});
});
listEl.appendChild(copyBtn);
}
/** Show the main button row and render saved videos. */
function showActions() {
btnRow.style.display = 'flex';
loadVideos().then(({ videos, tags }) => {
if (videos.length > 0) {
const items = videos.map((video, i) => ({
src: video.src || video.url || '',
label: tags[i],
}));
renderList(items);
setStatus(`Using ${videos.length} video(s) from last search.`);
} else {
setStatus('');
}
});
}
/* ======================================================================== */
/* HLS deep probe - runs in target page context via executeScript */
/* ======================================================================== */
/**
* Deep probe a page for HLS manifest URLs.
* Walks known player objects, video elements, inline scripts, config globals,
* and interceptor-collected data.
* Returns an array of { url, player } objects.
*/
function m3u8DeepProbeFn() {
const manifests = [];
const seen = new Set();
// DEBUG: Log probe activity to console
function probeLog(...args) {
try { console.log('[vidplex-probe]', ...args); } catch(e) {}
}
// NOTE: the probe is injected with chrome.scripting's `world: 'MAIN'`, so it
// runs in the page's main world and can see page globals. `typeof chrome` is
// NOT a reliable world discriminator (extensions expose `chrome` in the
// isolated world too), so don't infer it that way.
probeLog('Deep probe started on:', window.location.href, '(main world)');
// Helpers available in the MAIN world context
function resolveUrl(url) {
if (!url) return null;
try {
if (url.startsWith('//')) return 'https:' + url;
if (url.startsWith('http')) return url;
return new URL(url, window.location.origin).href;
} catch { return null; }
}
function isManifestUrl(url) {
if (!url) return false;
const u = url.toLowerCase();
// Concrete HLS/manifest signals win over the template heuristics below: a
// real .m3u8/.hlsvod//hls/ path is fetchable even if it happens to carry a
// trailing '%' query token or a trailing slash — so check these first.
if (u.includes('.m3u8') || u.includes('.hlsvod') || u.includes('/hls/')) return true;
// Dynamic route TEMPLATES (e.g. /playlist/%1%/ or /videos/{id}/) are nav
// links, not fetchable manifests. A placeholder is a `%` that is NOT the
// start of a valid %-encoded hex pair (so real tokens like %2B, %2F are
// preserved) or a {name} brace token — so genuine %-encoded auth tokens are
// never mistaken for template placeholders.
if (/%(?!([0-9A-Fa-f]{2}))/i.test(u) || /\{[a-z0-9_]+\}/i.test(u)) return false;
// Plural collection paths are nav menus, not manifests.
if (u.includes('/playlists') || u.includes('/manifests')) return false;
// A singular manifest-ish path segment with no concrete file may still be a
// real (rare, extensionless) endpoint, so allow it when it carries http.
return (u.includes('/manifest') || u.includes('/playlist')) && u.includes('http');
}
function walk(obj, path, depth) {
if (depth > 5) return;
// Cycle guard: the live window objects hold circular references
// (window/_parent/_top/frames/_globalObject/_globalProxy are mutually
// reachable), so a depth cap alone does NOT terminate — descending a cycle
// re-walks the same identities along exponentially many paths. Each object
// tree gets its own visited set so an identity is walked at most once per
// tree (trees are independent, so the set is declared per call).
const visited = new WeakSet();
if (obj && typeof obj === 'object') {
if (visited.has(obj)) return;
visited.add(obj);
}
if (typeof obj === 'string') {
const match = obj.match(/https?:\/\/[^"'`\s)]+?(?:\.m3u8|\.hlsvod|\/hls\/|\/playlist[^"'`\s]*|\/manifest[^"'`\s]*)[^"'`\s)]*/gi);
if (match) {
for (const u of match) {
const resolved = resolveUrl(u);
if (resolved && isManifestUrl(resolved) && !seen.has(resolved)) {
seen.add(resolved); manifests.push({ url: resolved, player: path });
}
}
}
return;
}
if (!obj || typeof obj !== 'object') return;
try {
for (const k of Object.keys(obj)) {
walk(obj[k], `${path}.${k}`, depth + 1);
}
} catch { /* avoid circular ref errors */ }
}
// Standard <video> element attributes — skip these when scanning for custom
// HLS player properties. Define here so it's available for Strategy A2 onward.
const STANDARD_VIDEO_ATTRS = new Set([
'src', 'srcObject', 'crossOrigin', 'preload', 'autoplay', 'muted',
'defaultMuted', 'loop', 'controls', 'controlsList', 'disableRemotePlayback',
'volume', 'playbackRate', 'defaultPlaybackRate', 'playbackState',
'currentTime', 'startTime', 'duration', 'buffered', 'seekable',
'played', 'seeking', 'ended', 'readyState', 'networkState',
'videoWidth', 'videoHeight', 'error', 'textTracks', 'videoTracks',
'audioTracks', 'loadingBeganAt', 'label', 'id', 'className',
'classList', 'style', 'attributes', 'dataset', 'innerHTML', 'textContent',
'outerHTML', 'outerText', 'slot', 'accessKey', 'draggable', 'hidden',
'lang', 'spellcheck', 'tabIndex', 'title', 'translate',
'width', 'height', 'poster', 'referrerPolicy',
]);
// Strategy A: Known player globals (shallow + deep)
const playerGlobals = [
'hls', 'Hls', 'videojs', 'Video', 'flowplayer', 'jwplayer',
'__hls', '__hls_player', 'player', 'videoPlayer', 'mainPlayer',
'fhplayer', '__pj', 'playerInstance',
'PHPlayer', 'fh', '__phPlayer', '__ph', 'phPlayer', 'phPlayerInstance',
'bitgravity', 'bgPlayer', 'FG', 'Fhls',
];
let strategyACount = 0;
for (const name of playerGlobals) {
const obj = window[name];
if (!obj || typeof obj === 'function') continue;
probeLog('Strategy A: found global', name, '(not function)');
for (const key of ['url', '_url', 'src', 'manifestUrl', 'hlsUrl', 'playlistUrl', 'hls_url', 'mp4_url']) {
if (typeof obj[key] === 'string' && isManifestUrl(obj[key])) {
const resolved = resolveUrl(obj[key]);
if (resolved && !seen.has(resolved)) { seen.add(resolved); manifests.push({ url: resolved, player: `${name}.${key}` }); strategyACount++; }
}
}
walk(obj, name, 0);
}
probeLog('Strategy A (player globals): found', strategyACount, 'new manifests');
// Strategy A2: Hls instances — iterate all video elements and check if any
// HLS player instance (created via new Hls()) is bound to them. Even if our
// attachMedia patch missed the call (timing race), the player object still
// exists on the video element with a .url property.
let strategyA2Count = 0;
try {
for (const v of document.querySelectorAll('video')) {
// Check all custom properties on the video for HLS-like instances
for (const key of Object.keys(v)) {
if (STANDARD_VIDEO_ATTRS.has(key)) continue;
const obj = v[key];
if (!obj || typeof obj !== 'object') continue;
// HLS instances have .url, .media, .config, .networkControls, etc.
if (typeof obj.url === 'string' && isManifestUrl(obj.url)) {
const resolved = resolveUrl(obj.url);
if (resolved && !seen.has(resolved)) {
seen.add(resolved);
manifests.push({ url: resolved, player: `video.${key}.url (Hls instance)` });
strategyA2Count++;
probeLog('Strategy A2: found Hls instance on video.', key, '- url:', obj.url.substring(0, 100));
}
}
}
}
} catch(e) { /* ignore */ }
probeLog('Strategy A2 (Hls instances on video): found', strategyA2Count, 'new manifests');
// Strategy B: Video element HLS instances
// B1: Scan video elements for HLS instance references stored by players.
// NOTE: Hls.cast() in hls.js is a TypeScript type-cast utility (identity function),
// NOT a reverse-lookup. It returns its input unchanged — Hls.cast(video) just
// returns the <video> element with no .url property. Real players store the
// HLS instance directly on the video element as a custom property.
// Only check non-standard properties (skip built-in <video> attributes).
const allVideos = document.querySelectorAll('video');
probeLog('Strategy B: found', allVideos.length, '<video> elements');
let strategyBCount = 0;
for (const v of allVideos) {
for (const key of Object.keys(v)) {
if (STANDARD_VIDEO_ATTRS.has(key)) continue;
const obj = v[key];
if (!obj || typeof obj !== 'object') continue;
// Check for common HLS instance patterns on video elements
if (typeof obj === 'object' && typeof obj.url === 'string' && isManifestUrl(obj.url)) {
probeLog('Strategy B1: found HLS instance on video.', key, '- url:', obj.url.substring(0, 100));
const resolved = resolveUrl(obj.url);
if (resolved && !seen.has(resolved)) { seen.add(resolved); manifests.push({ url: resolved, player: `video.${key}.url` }); strategyBCount++; }
}
// Walk deep into player objects attached to video elements
walk(obj, `video.${key}`, 0);
}
}
probeLog('Strategy B1 (video custom props): found', strategyBCount, 'new manifests');
// B2: Check interceptor-set manifest annotation on video elements
let strategyB2Count = 0;
for (const v of document.querySelectorAll('video')) {
if (v._goon_hls_manifest && !seen.has(v._goon_hls_manifest)) {
seen.add(v._goon_hls_manifest);
manifests.push({ url: v._goon_hls_manifest, player: 'video._goon_hls_manifest' });
strategyB2Count++;
probeLog('Strategy B2: found _goon_hls_manifest on video:', v._goon_hls_manifest.substring(0, 100));
}
}
probeLog('Strategy B2 (interceptor annotation): found', strategyB2Count, 'new manifests');
// Strategy C: Inline scripts
let strategyCCount = 0;
for (const script of document.querySelectorAll('script:not([src])')) {
const text = script.textContent || '';
const matches = text.match(/https?:\/\/[^"'\s]+.(m3u8|hlsvod)[^"'\s]*/gi);
if (matches) {
for (const u of matches) {
if (!seen.has(u)) { seen.add(u); manifests.push({ url: u, player: 'inline-script' }); strategyCCount++; }
}
}
}
probeLog('Strategy C (inline scripts): found', strategyCCount, 'new manifests');
// Strategy D: Config globals
const configKeys = ['__dataLayer', '__INITIAL_STATE__', 'config', '__NEXT_DATA__', '__PRELOADED_STATE__', 'appData', 'pageData', '__data'];
let preDCount = manifests.length;
for (const name of configKeys) {
const obj = window[name];
if (obj && typeof obj === 'object') {
probeLog('Strategy D: walking config global', name);
walk(obj, name, 0);
}
}
probeLog('Strategy D (config globals): found', manifests.length - preDCount, 'new manifests');
// Strategy E: Interceptor-collected manifests — the most reliable source.
// The main-world interceptors (injected at document_start) capture .m3u8 URLs
// from XHR, fetch, and Hls.prototype.loadSource/attachMedia calls, and store
// them in window.__goon_plugin_data.manifests[]. This was the original
// Strategy 3/6 on master that was accidentally dropped during refactoring.
// For dynamic playlists, the interceptors also poll Hls instances via Hls.cast()
// and extract internal state (levels, url) — these URLs may not end in .m3u8.
let preECount = manifests.length;
if (window.__goon_plugin_data && Array.isArray(window.__goon_plugin_data.manifests)) {
probeLog('Strategy E: __goon_plugin_data has', window.__goon_plugin_data.manifests.length, 'interceptor manifests');
for (const url of window.__goon_plugin_data.manifests) {
if (url && typeof url === 'string' && !seen.has(url)) {
seen.add(url);
manifests.push({ url, player: '__goon_plugin_data' });
}
}
} else {
probeLog('Strategy E: __goon_plugin_data missing or has no manifests array', {
hasData: !!window.__goon_plugin_data,
hasArray: window.__goon_plugin_data ? Array.isArray(window.__goon_plugin_data.manifests) : false,
});
}
probeLog('Strategy E (interceptor data): found', manifests.length - preECount, 'new manifests');
// Strategy F: Dynamic playlist fallback — scan video elements for Hls-like
// instances and extract ANY URL, not just .m3u8. Some players use
// HLS.js dynamic playlists where the "manifest" is generated in-memory,
// so the URL might be a blob:, CDN endpoint, or data URI.
let strategyFCount = 0;
try {
for (const v of document.querySelectorAll('video')) {
for (const key of Object.keys(v)) {
if (STANDARD_VIDEO_ATTRS.has(key)) continue;
const obj = v[key];
if (!obj || typeof obj !== 'object') continue;
// Look for Hls-like objects: have .url, .levels, .attachMedia, etc.
const isHlsLike = (typeof obj.url === 'string' && obj.url.length > 5) ||
(obj.levels && Array.isArray(obj.levels)) ||
(typeof obj.attachMedia === 'function') ||
(typeof obj.loadSource === 'function');
if (!isHlsLike) continue;
// Extract URL without isManifestUrl filter
if (typeof obj.url === 'string' && obj.url.length > 5 && !seen.has(obj.url)) {
const resolved = resolveUrl(obj.url) || obj.url;
// Skip obvious media segments
const u = resolved.toLowerCase();
if (u.includes('.ts') || u.includes('.m4s') || u.includes('.aac')) continue;
seen.add(resolved);
manifests.push({ url: resolved, player: `video.${key}.url (dynamic HLS)` });
strategyFCount++;
probeLog('Strategy F: found dynamic HLS URL on video.', key, ':', resolved.substring(0, 150));
}
// Extract level URIs from levels array
if (obj.levels && Array.isArray(obj.levels)) {
for (let li = 0; li < obj.levels.length; li++) {
const lvl = obj.levels[li];
if (lvl && lvl.uri && !seen.has(lvl.uri)) {
const resolved = resolveUrl(lvl.uri) || lvl.uri;
seen.add(resolved);
manifests.push({ url: resolved, player: `video.${key}.levels[${li}].uri (dynamic)` });
strategyFCount++;
probeLog('Strategy F: found level URI:', resolved.substring(0, 120));
}
// Also check details.uri (populated after playlist load)
if (lvl && lvl.details && lvl.details.uri && !seen.has(lvl.details.uri)) {
const resolved = resolveUrl(lvl.details.uri) || lvl.details.uri;
seen.add(resolved);
manifests.push({ url: resolved, player: `video.${key}.levels[${li}].details.uri (dynamic)` });
strategyFCount++;
probeLog('Strategy F: found level details.uri:', resolved.substring(0, 120));
}
}
}
}
}
} catch(e) { /* ignore */ }
probeLog('Strategy F (dynamic HLS fallback): found', strategyFCount, 'new manifests');
// Strategy G: Non-enumerable + Symbol property detection on video elements.
// Object.keys() only returns enumerable string properties. Players like es6player
// hide instances via Object.defineProperty(enumerable:false) or Symbol keys.
// This runs in main-world via executeScript, so getOwnPropertyNames/Symbols work.
let strategyGCount = 0;
try {
for (const v of document.querySelectorAll('video')) {
// Non-enumerable string properties
const nonEnumProps = Object.getOwnPropertyNames(v);
for (const key of nonEnumProps) {
if (STANDARD_VIDEO_ATTRS.has(key)) continue;
try {
const obj = v[key];
if (!obj || typeof obj !== 'object') continue;
// Check if this object looks like an HLS instance (same heuristics as interceptors)
const isHlsLike = typeof obj.url === 'string' && obj.url.length > 5 ||
(obj.levels && Array.isArray(obj.levels)) ||
typeof obj.attachMedia === 'function' ||
typeof obj.loadSource === 'function' ||
typeof obj.getConfig === 'function' ||
typeof obj.detachMedia === 'function' ||
typeof obj.destroy === 'function' ||
(obj.networkControls && typeof obj.networkControls === 'object');
if (isHlsLike) {
probeLog('Strategy G: found HLS-like object on video via non-enumerable prop', key);
// Extract URL from non-enumerable property
if (typeof obj.url === 'string' && obj.url.length > 5) {
const resolved = resolveUrl(obj.url);
if (resolved && !seen.has(resolved)) {
seen.add(resolved);
manifests.push({ url: resolved, player: `video[G].${key}.url` });
strategyGCount++;
}
}
// Extract level URIs
if (obj.levels && Array.isArray(obj.levels)) {
for (let gi = 0; gi < obj.levels.length; gi++) {
const lvl = obj.levels[gi];
if (lvl && lvl.uri && !seen.has(lvl.uri)) {
const resolved = resolveUrl(lvl.uri) || lvl.uri;
seen.add(resolved);
manifests.push({ url: resolved, player: `video[G].${key}.levels[${gi}].uri` });
strategyGCount++;
}
}
}
} else {
// Also deep-walk nested objects for URL-like values (even without HLS signatures)
if (typeof obj === 'object' && obj !== null) {
walk(obj, `video[G].${key}`, 0);
}
}
} catch(e) { /* circular or inaccessible */ }
}
// Symbol properties
const symbolProps = Object.getOwnPropertySymbols(v);
for (const symKey of symbolProps) {
try {
const symObj = v[symKey];
if (!symObj || typeof symObj !== 'object') continue;
probeLog('Strategy G: video has Symbol prop', symKey.toString());
// Check HLS signatures on symbols too
if (typeof symObj.url === 'string' && symObj.url.length > 5) {
const resolved = resolveUrl(symObj.url);
if (resolved && !seen.has(resolved)) {
seen.add(resolved);
manifests.push({ url: resolved, player: `video[G].${symKey.toString()}.url` });
strategyGCount++;
}
}
if (symObj.levels && Array.isArray(symObj.levels)) {
for (let gi = 0; gi < symObj.levels.length; gi++) {
const lvl = symObj.levels[gi];
if (lvl && lvl.uri && !seen.has(lvl.uri)) {
const resolved = resolveUrl(lvl.uri) || lvl.uri;
seen.add(resolved);
manifests.push({ url: resolved, player: `video[G].${symKey.toString()}.levels[${gi}].uri` });
strategyGCount++;
}
}
}
// Deep-walk nested objects
walk(symObj, `video[G].${symKey.toString()}`, 0);
} catch(e) { /* inaccessible */ }
}
}
} catch(e) { /* ignore */ }
probeLog('Strategy G (non-enumerable/Symbol props): found', strategyGCount, 'new manifests');
// Strategy H: Full window global scan — enumerate ALL objects on window, not just
// known player names. Filter out built-ins and walk any object that looks like a
// player container (.url, .levels, .config with HLS keys, .media reference).
let strategyHCount = 0;
try {
const windowKeys = Object.keys(window);
for (const key of windowKeys) {
const val = window[key];
// Skip primitives
if (typeof val === 'function' || typeof val === 'string' ||
typeof val === 'number' || typeof val === 'boolean' ||
val === null || val === undefined) continue;
// Skip DOM elements directly on window
if (val && typeof val.nodeType === 'number') continue;
if (typeof val !== 'object') continue;
// Quick check: does this object have any player-like keys?
let hasPlayerKey = false;
try {
const subKeys = Object.keys(val).slice(0, 50); // limit scan depth for performance
hasPlayerKey = subKeys.some(k => {
const lk = k.toLowerCase();
return lk.includes('hls') || lk.includes('player') || lk.includes('video') ||
lk.includes('manifest') || lk.includes('source') || lk.includes('level') ||
lk.includes('playlist') || lk.includes('stream') || lk === 'url' ||
lk === 'config' || lk === 'media' || lk === 'levels' ||
lk === 'width' || lk === 'height' || lk === 'volume';
});
} catch { hasPlayerKey = false; }
if (!hasPlayerKey) continue;
probeLog('Strategy H: window global', key, 'has player-like keys');
// Deep-walk this object for HLS instances and manifest URLs
walk(val, `window[H].${key}`, 0);
if (typeof val.url === 'string' && isManifestUrl(val.url)) {
const resolved = resolveUrl(val.url);
if (resolved && !seen.has(resolved)) {
seen.add(resolved);
manifests.push({ url: resolved, player: `window[H].${key}.url` });
strategyHCount++;
}
}
}
} catch(e) { /* ignore */ }
probeLog('Strategy H (full window scan): found', strategyHCount, 'new manifests');
probeLog('Deep probe complete. Total unique manifests found:', manifests.length);
if (manifests.length > 0) {
probeLog('Manifest summary:', JSON.stringify(manifests.map(m => ({ url: m.url.substring(0, 100), player: m.player }))));
}
return manifests;
}
/* ======================================================================== */
/* Video scraper - runs in target page context via executeScript */
/* ======================================================================== */
/**
* Scrape visible video elements from the current frame.
* Returns at most one video entry (the largest visible one).
* @param {Array} hlsManifests - manifests found by the deep probe
* @returns {{ videos: Array, videoCount: number, hlsManifest: string|null }}
*/
function scrapeFn(hlsManifests) {
const allVideos = Array.from(document.querySelectorAll('video'))
.filter(v => {
const style = window.getComputedStyle(v);
return style.display !== 'none' && v.clientWidth > 0 && v.clientHeight > 0;
})
.map(v => {
const area = v.clientWidth * v.clientHeight;
const rawSrc = v.currentSrc || v.src || '';
const poster = v.poster || v.getAttribute('data-poster') || null;
let hlsManifest = null;
let mediaType = 'video';
let src = rawSrc;
// Priority 1: Direct .m3u8 source (rare but possible)
if (rawSrc.includes('.m3u8') && rawSrc.startsWith('http')) {
hlsManifest = rawSrc;
mediaType = 'hls';
}
// Priority 2: Interceptor annotation — Hls.attachMedia() patched by interceptors
else if (v._goon_hls_manifest) {
hlsManifest = v._goon_hls_manifest;
mediaType = 'hls';
src = hlsManifest;
}
// Priority 3: Scan video element custom properties for HLS instance references.
// Only check non-standard (custom) properties — skip built-in <video> attrs.
if (!hlsManifest) {
const stdAttrs = new Set([
'src', 'srcObject', 'crossOrigin', 'preload', 'autoplay', 'muted',
'defaultMuted', 'loop', 'controls', 'controlsList', 'disableRemotePlayback',
'volume', 'playbackRate', 'defaultPlaybackRate', 'playbackState',
'currentTime', 'startTime', 'duration', 'buffered', 'seekable',
'played', 'seeking', 'ended', 'readyState', 'networkState',
'videoWidth', 'videoHeight', 'error', 'textTracks', 'videoTracks',
'audioTracks', 'loadingBeganAt', 'label', 'id', 'className',
'classList', 'style', 'attributes', 'dataset', 'innerHTML', 'textContent',
'outerHTML', 'outerText', 'slot', 'accessKey', 'draggable', 'hidden',
'lang', 'spellcheck', 'tabIndex', 'title', 'translate',
'width', 'height', 'poster', 'referrerPolicy',
]);
for (const key of Object.keys(v)) {
if (stdAttrs.has(key)) continue;
const obj = v[key];
if (obj && typeof obj === 'object' && typeof obj.url === 'string') {
const u = obj.url.toLowerCase();
if (u.includes('.m3u8') || u.includes('.hlsvod') || u.includes('/hls/') || u.includes('/playlist')) {
hlsManifest = obj.url;
mediaType = 'hls';
src = hlsManifest;
break;
}
}
}
}
// Priority 4: Direct HTTP source (non-blob)
if (!hlsManifest && rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) {
mediaType = 'video';
}
// Priority 5: Deep probe fallback — use manifests found by scanning page globals/scripts
// and interceptor-collected manifests. This is the most reliable fallback because
// interceptors catch HLS activity at the network layer regardless of player type.
if (!hlsManifest && hlsManifests && hlsManifests.length > 0) {
// Prefer interceptor-collected manifests over script-scrapped ones
const interceptManifest = hlsManifests.find(m => m.player === '__goon_plugin_data') ||
hlsManifests.find(m => m.player === 'content-script-intercept') ||
hlsManifests.find(m => m.player !== 'inline-script') ||
hlsManifests[0];
hlsManifest = interceptManifest.url || interceptManifest;
mediaType = 'hls';
src = hlsManifest;
}
return { src, poster, area, width: v.clientWidth, height: v.clientHeight, mediaType, hlsManifest };
});
allVideos.sort((a, b) => b.area - a.area);
// Filter: only keep entries with a real playable source
const playable = allVideos.filter(v => {
const srcOk = v.src && (v.src.startsWith('http://') || v.src.startsWith('https://'));
const hlsOk = v.hlsManifest && (v.hlsManifest.startsWith('http://') || v.hlsManifest.startsWith('https://'));
return srcOk || hlsOk;
});
// Dedup: if multiple videos share the same HLS manifest, keep only the largest
const seen = new Map();
const unique = [];
for (const v of playable) {
const key = (v.hlsManifest || v.src || '') + '||' + (v.poster || '');
if (!seen.has(key)) {
seen.set(key, v);
unique.push(v);
}
}
const largest = unique.length > 0 ? unique[0] : null;
return {
videos: largest ? [largest] : [],
videoCount: largest ? 1 : 0,
hlsManifest: largest?.hlsManifest || null,
};
}
/* ======================================================================== */
/* Scan pipeline */
/* ======================================================================== */
/** URLs that should not be scanned. */
const SYSTEM_PREFIXES = ['chrome://', 'chrome-extension://', 'edge://', 'about:', 'view-source:'];
/** Filter out system/browser pages from the tab list. */
function filterValidTabs(tabs) {
return tabs.filter(t =>
t.url && !SYSTEM_PREFIXES.some(prefix => t.url.startsWith(prefix))
);
}
/**
* Probe all frames in a tab for HLS manifests.
* Runs in the MAIN world so strategies that depend on page globals work:
* - window.__goon_plugin_data.manifests (set by main-world interceptors)
* - window.Hls / Hls.cast (page's hls.js global)
* - player globals (PHPlayer, flowplayer, etc.)
* - config globals (__NEXT_DATA__, appData, etc.)
*
* The default isolated world cannot see these — only DOM properties like
* video._goon_hls_manifest cross the boundary.
* @param {number} tabId
* @returns {Promise<Array>} array of { url, player } or empty on error
*/
async function probeHlsManifests(tabId) {
try {
log('probeHlsManifests: executing deep probe on tab', tabId);
const raw = await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
world: 'MAIN',
func: m3u8DeepProbeFn,
});
// When allFrames is true, destroyed / sandboxed frames may return null entries.
// Guard: ensure raw is an array, then filter to objects with a defined .result.
const manifests = Array.isArray(raw)
? raw.filter(r => r != null && typeof r === 'object' && 'result' in r)
.flatMap(r => Array.isArray(r.result) ? r.result : [])
: [];
log('probeHlsManifests: tab', tabId, 'returned', manifests.length, 'manifests from', Array.isArray(raw) ? raw.length : '?', 'frame results');
return manifests;
} catch(e) {
warn('probeHlsManifests: tab', tabId, 'error:', e && e.message ? e.message : e);
return [];
}
}
/**
* Aggregate and deduplicate HLS manifests across frames for one tab.
* Prefers manifests from known players/interceptors over inline-script scraping.
* @param {Array} allManifests
* @returns {Array}
*/
function dedupManifests(allManifests) {
const seenUrls = new Set();
const unique = allManifests.filter(m => {
if (seenUrls.has(m.url)) return false;
seenUrls.add(m.url);
return true;
});
// Sort: prefer interceptor-collected > player globals > config > inline-script
const playerRank = {
'__goon_plugin_data': 0,
'content-script-intercept': 0,
'webRequest': 1,
'hls_reconstructed': 2,
'video._goon_hls_manifest': 3,
'inline-script': 99,
};
unique.sort((a, b) => {
const aRank = playerRank[a.player] ?? 50;
const bRank = playerRank[b.player] ?? 50;
return aRank - bRank;
});
return unique;
}
/**
* Query the content script for poster URLs, intercepted manifests, and video metadata.
* @param {number} tabId
* @returns {Promise<Object|null>} content script response or null on error/timeout
*/
async function queryContentScript(tabId) {
try {
return await chrome.tabs.sendMessage(tabId, { type: 'GOON_GET_VIDEO_DATA' });
} catch {
return null;
}
}
/**
* Query the service worker for webRequest-captured HLS manifests.
* The service worker listens to chrome.webRequest.onBeforeRequest and
* records all .m3u8 / HLS manifest URLs per tab. This works at the
* network level — no world or timing issues.
* @param {number} tabId
* @returns {Promise<Array>} array of manifest URLs
*/
async function queryWebRequestManifests(tabId) {
try {
const resp = await chrome.runtime.sendMessage({
type: 'GOON_GET_WEBREQUEST_MANIFESTS',
tabId: tabId,
});
return resp?.manifests || [];
} catch {
return [];
}
}
/**
* Query the service worker for all video requests captured by Strategy 8,
* including reconstructed manifest URLs derived from segment patterns.
* @param {number} tabId
* @returns {Promise<{segments: Array, reconstructedManifests: Array, totalRequests: number}>}
*/
async function queryVideoRequests(tabId) {
try {
const resp = await chrome.runtime.sendMessage({
type: 'GOON_GET_VIDEO_REQUESTS',
tabId: tabId,
});
return resp || { segments: [], reconstructedManifests: [], totalRequests: 0 };
} catch {
return { segments: [], reconstructedManifests: [], totalRequests: 0 };
}
}
/**
* Scrape video elements from ALL frames in a tab.
* On many video sites the player lives inside an iframe — main-frame-only
* scraping sees zero <video> elements. Running allFrames catches them.
*
* Runs in the MAIN world so it can see page globals:
* - Hls.cast(video) — the official hls.js API to get bound instances
* - window.__goon_plugin_data (interceptor-collected manifests)
*
* The default isolated world would miss all of these, causing the same
* "does not detect any videos" failure kfj001 reported.
* @param {number} tabId
* @param {Array} manifests - HLS manifests found by probing
* @returns {Promise<Object|null>} scrape result or null on error
*/
/**
* Reduce a per-frame executeScript result array to the single largest-video
* result (or a "no videos" result / null). Shared by the MAIN- and
* isolated-world scrape attempts in scrapeTab.
*/
function reduceScrapeFrames(raw) {
// When allFrames is true, destroyed / sandboxed frames may return null entries.
// Guard: ensure raw is an array, then filter to objects with a defined .result.
const frameResults = Array.isArray(raw)
? raw.filter(r => r != null && typeof r === 'object' && 'result' in r)
.map(r => r.result)
: [];
const valid = frameResults.filter(r => r && r.videos && r.videos.length > 0);
if (valid.length === 0) {
const firstWithResult = frameResults.find(r => r != null);
return firstWithResult || null;
}
valid.sort((a, b) => {
const aArea = a.videos[0]?.area || 0;
const bArea = b.videos[0]?.area || 0;
return bArea - aArea;
});
return valid[0];
}
async function scrapeTab(tabId, manifests) {
// Primary: MAIN world — scrapeFn can read page globals the main frame
// exposes (interceptor annotations, Hls instances, custom <video> props).
try {
const mainRaw = await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
world: 'MAIN',
func: scrapeFn,
args: [manifests],
});
let result = reduceScrapeFrames(mainRaw);
// Isolated-world fallback: some pages embed sandboxed / opaque-origin ad
// iframes that make the MAIN-world injection reject, so the primary run can
// return no videos even when a <video> is visible. scrapeFn only needs the
// shared DOM plus the passed-in manifest list, so an isolated-world run (no
// `world` key — always permitted) still sees the same <video> elements.
if (!result || !(result.videos && result.videos.length > 0)) {
const isoRaw = await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
func: scrapeFn,
args: [manifests],
});
const iso = reduceScrapeFrames(isoRaw);
if (iso && iso.videos && iso.videos.length > 0) result = iso;
else if (!result) result = iso;
}
return result;
} catch(e) {
warn('scrapeTab: tab', tabId, 'error:', e && e.message ? e.message : e);
return null;
}
}
/**
* Filter intercepted URLs to prefer actual HLS manifests over segment files.
* Interceptors capture both .m3u8 manifests and .ts segment URLs (any URL
* matching /hls/ is intercepted). When building video entries from intercept
* data alone (no visible <video> elements), we need to filter out segments
* and reconstruct manifest URLs from segment paths when needed.
*
* @param {Array} manifests - array of { url, player } from probing/interception
* @returns {Array} filtered { url } entries with only manifest URLs
*/
function filterManifests(manifests) {
if (!manifests || manifests.length === 0) return [];
// Separate manifests (.m3u8, .hlsvod) from segments (.ts)
const actualManifests = manifests.filter(m => {
const u = m.url.toLowerCase();
return u.includes('.m3u8') || u.includes('.hlsvod') ||
(u.includes('/master') && u.includes('.m3u')) ||
(u.includes('/playlist') && !u.endsWith('.ts'));
});
// Reject clearly-fake manifest paths (see isValidManifestUrl).
const valid = actualManifests.filter(m => isValidManifestUrl(m.url));
if (valid.length > 0) {
return valid.map(m => ({ url: m.url }));
}
// No manifest found — interceptors only captured segment files. Reconstruct a
// manifest URL from each segment's directory, carrying over its auth query
// string intact (CDNs reject a path-only URL with 403). CDN-agnostic: the
// segment filename is swapped for a manifest filename in the same directory,
// e.g. .../variant/seg-1.ts?h=... -> .../variant/master.m3u8?h=...
const seen = new Set();
const reconstructed = [];
// A segment filename: numbered segment, or an .ts/.m4s/.aac media extension.
const SEG_RE = /(seg-\d+|\.(?:ts|m4s|aac)(?:$|\?))/i;
for (const m of manifests) {
if (!SEG_RE.test(m.url)) continue;
try {
const urlObj = new URL(m.url);
const pathParts = urlObj.pathname.split('/');
// Need a directory to attach the manifest to (skip flat single-file paths).
if (pathParts.length < 3) continue;
pathParts.pop(); // drop the segment filename, keep its directory
const dir = pathParts.join('/');
// Carry the raw query (auth token) over unchanged — never re-encode it.
const q = m.url.indexOf('?');
const rawQuery = q === -1 ? '' : m.url.slice(q);
const manifestUrl = `${urlObj.protocol}//${urlObj.host}/${dir}/master.m3u8${rawQuery}`;
if (!seen.has(manifestUrl)) {
seen.add(manifestUrl);
reconstructed.push({ url: manifestUrl });
}
} catch { /* skip malformed URLs */ }
}
return reconstructed;
}
/**
* Validate that a manifest URL is well-formed and generic (CDN-agnostic).
* Rejects malformed paths; a manifest-like URL must live in a directory (have a
* path separator), which filters out obviously-broken single-segment paths.
*/
function isValidManifestUrl(url) {
if (!url || typeof url !== 'string') return false;
let urlObj;
try {
urlObj = new URL(url);
} catch {
return false;
}
const path = urlObj.pathname.toLowerCase();
// 1) Concrete media/manifest signals win outright: a real file extension or
// /hls/ path is fetchable, even with a trailing slash
// or a token-bearing query — so check these before the
// template heuristics and never let a coincidental '%'/'/' sink a source.
if (path.includes('.m3u8') || path.includes('.hlsvod') || path.includes('/hls/') ||
path.includes('/manifest') || path.includes('.mp4') || path.includes('.webm') ||
path.includes('.ogv') || path.includes('.mpd') || path.includes('.m4s')) return true;
// 2) Dynamic route templates / nav links: a `%` that isn't a valid %-encoded
// hex pair (real %2B etc. are kept) or a {name} brace token.
if (/%(?!([0-9A-Fa-f]{2}))/i.test(url) || /\{[a-z0-9_]+\}/i.test(url)) return false;
// 3) Plural collection paths and trailing-slash directory listings are nav
// menus, not manifest files (e.g. /playlists/, /playlists/liked/).
if (path.includes('/playlists') || path.includes('/manifests') || path.endsWith('/')) return false;
// 4) A singular /playlist endpoint may be a real (extensionless) manifest;
// allow it when it sits in a directory.
if (path.includes('/playlist')) return path.includes('/');
// 5) Otherwise treat as a direct media source — allow it.
return true;
}
/**
* Build a normalized video entry from a scraped video object.
* Validates manifest URLs to prevent storing fake paths like /videos/{id}/playlist.m3u8.
* @param {{ src: string, poster: string|null, mediaType: string, hlsManifest: string|null }} video
* @param {string} tabTitle
* @param {string|null} posterFromContent - poster URL from content script (OG image, etc.)
* @returns {{ src: string, poster: string|null, tab: string, mediaType: string, hlsManifest?: string }}
*/
function makeVideoEntry(video, tabTitle, posterFromContent) {
const entry = {
src: video.hlsManifest || video.src || '',
poster: video.poster || posterFromContent || null,
tab: tabTitle,
mediaType: video.mediaType || 'video',
};
if (video.hlsManifest) {
// Reject fake/invalid manifest paths before storing
if (!isValidManifestUrl(video.hlsManifest)) {
console.warn('[vidplex] Rejecting fake manifest URL:', video.hlsManifest.substring(0, 120));
} else {
entry.hlsManifest = video.hlsManifest;
}
}
// Also validate src when it looks like a manifest URL (no hlsManifest set but src has .m3u8)
if (!entry.hlsManifest && entry.src?.includes('.m3u8') && !isValidManifestUrl(entry.src)) {
console.warn('[vidplex] Rejecting fake manifest URL from src:', entry.src.substring(0, 120));
// Don't treat it as HLS — leave mediaType as-is (likely 'video' or unset)
}
return entry;
}
/**
* Run the full scan: probe HLS manifests, scrape video elements, query content scripts, dedup, store.
* @param {Array} tabsToScan - tabs returned by chrome.tabs.query
* @returns {Promise<{ items: Array, scanned: number, skipped: number }>}
*/
async function scanVideos(tabsToScan) {
log('scanVideos: starting scan of', tabsToScan.length, 'tabs');
await logToPage(`scanVideos: starting scan of ${tabsToScan.length} tabs`);
// Step 1: Probe for HLS manifests in all frames
const manifestPromises = tabsToScan.map(t => probeHlsManifests(t.id));
const allManifestResults = await Promise.all(manifestPromises);
// Build per-tab manifest map (deduped)
const tabManifestMap = new Map();
let totalManifests = 0;
for (let i = 0; i < tabsToScan.length; i++) {
const unique = dedupManifests(allManifestResults[i]);
const tabUrl = tabsToScan[i].url ? tabsToScan[i].url.substring(0, 80) : '(unknown)';
log('scanVideos step1: tab', tabsToScan[i].id, '(' + tabUrl + ') has', unique.length, 'unique manifests');
totalManifests += unique.length;
tabManifestMap.set(tabsToScan[i].id, unique);
}
await logToPage(`step1 (probe): found ${totalManifests} manifests across ${tabsToScan.length} tabs`);
// Step 1.5: Query service worker for webRequest-captured HLS manifests.
// This catches manifests at the network level — no world or timing issues.
// Works even when the player loads before our interceptors are active.
const webRequestPromises = tabsToScan.map(t => queryWebRequestManifests(t.id));
const webRequestResults = await Promise.all(webRequestPromises);
let totalWebRequestManifests = 0;
for (let i = 0; i < tabsToScan.length; i++) {
const urls = webRequestResults[i];
const existing = tabManifestMap.get(tabsToScan[i].id) || [];
for (const url of urls) {
if (!existing.some(m => m.url === url)) {
existing.push({ url, player: 'webRequest' });
}
}
tabManifestMap.set(tabsToScan[i].id, existing);
const newCount = urls.length;
totalWebRequestManifests += newCount;
if (newCount > 0) {
log('scanVideos webRequest: tab', tabsToScan[i].id, 'found', newCount, 'manifests via network interception');
}
}
await logToPage(`step1.5 (webRequest): found ${totalWebRequestManifests} manifests via network interception`);
// Step 1.6: Strategy 8 — Query captured video requests and reconstruct manifest URLs.
// When the player uses in-memory dynamic HLS (zero .m3u8 network requests), we can
// still detect it by recognizing segment URL patterns and deriving the parent
// manifest URL from the segment's directory.
const videoRequestPromises = tabsToScan.map(t => queryVideoRequests(t.id));
const videoRequestResults = await Promise.all(videoRequestPromises);
let totalReconstructedManifests = 0;
for (let i = 0; i < tabsToScan.length; i++) {
const reconstructed = videoRequestResults[i]?.reconstructedManifests || [];
if (reconstructed.length > 0) {
log('scanVideos strategy8: tab', tabsToScan[i].id, 'found', reconstructed.length, 'reconstructed manifests from segment URLs');
}
totalReconstructedManifests += reconstructed.length;
const existing = tabManifestMap.get(tabsToScan[i].id) || [];
for (const url of reconstructed) {
if (!existing.some(m => m.url === url)) {
existing.push({ url, player: 'hls_reconstructed' });
}
}
tabManifestMap.set(tabsToScan[i].id, existing);
}
await logToPage(`step1.6 (strategy8): found ${totalReconstructedManifests} reconstructed manifests from segment URLs`);
// Step 2: Scrape video elements (all frames) AND query content scripts (in parallel)
const scrapePromises = tabsToScan.map(t =>
scrapeTab(t.id, tabManifestMap.get(t.id) || [])
);
const contentPromises = tabsToScan.map(t => queryContentScript(t.id));
const [scrapeResults, contentResults] = await Promise.all([
Promise.all(scrapePromises),
Promise.all(contentPromises),
]);
// Log scrape + content script results for debugging
let totalScrapedVideos = 0;
let totalInterceptedManifests = 0;
for (let i = 0; i < tabsToScan.length; i++) {
const tabUrl = tabsToScan[i].url ? tabsToScan[i].url.substring(0, 80) : '(unknown)';
const scrape = scrapeResults[i];
const content = contentResults[i];
const scrapeVideos = scrape ? (scrape.videos ? scrape.videos.length : 0) : 0;
const intercepts = content ? (content.interceptedManifests ? content.interceptedManifests.length : 0) : 0;
totalScrapedVideos += scrapeVideos;
totalInterceptedManifests += intercepts;
log('scanVideos step2: tab', tabsToScan[i].id, '(' + tabUrl + '):',
'scrape=' + (scrape ? (scrape.videos ? scrape.videos.length + ' videos' : 'no videos') : 'null'),
'content=' + (content ? ('poster=' + !!content.posterUrl + ', intercepts=' + intercepts + ', videoEls=' + (content.videoElements ? content.videoElements.length : 0)) : 'null'),
);
}
await logToPage(`step2 (scrape+content): ${totalScrapedVideos} scraped videos, ${totalInterceptedManifests} intercepted manifests`);
// Step 3: Merge content-script data into tab results
// - Enrich video entries with poster URLs from the content script
// - Merge content-script intercepted manifests into the deep probe results
// - FALLBACK: When scrapeFn found no visible <video> elements (sandboxed
// iframe) but interceptors captured manifests, create video entries from
// manifest data. The tab is actively playing content even if the DOM is
// inaccessible.
const tabResults = tabsToScan.map((tab, i) => {
const result = scrapeResults[i];
const contentData = contentResults[i];
const tabManifests = tabManifestMap.get(tab.id) || [];
// Enrich videos with poster from content script (og:image, twitter:image, etc.)
if (result && result.videos?.length > 0 && contentData?.posterUrl) {
for (const v of result.videos) {
if (!v.poster) v.poster = contentData.posterUrl;
}
}
// Merge content-script intercepted manifests into the tab's manifest list
if (contentData && contentData.interceptedManifests) {
for (const m of contentData.interceptedManifests) {
if (m.url && !tabManifests.some(tm => tm.url === m.url)) {
tabManifests.push({ url: m.url, player: 'content-script-intercept' });
}
}
tabManifestMap.set(tab.id, tabManifests);
}
// FALLBACK: If scrapeFn returned no videos but we have manifests,
// create video entries from manifest data. This handles sandboxed
// iframes where the video player is invisible to content scripts.
// Filter: prefer .m3u8 manifest URLs over .ts segment URLs.
// Reconstruct manifest URL from segment URLs when needed.
if (!result || !result.videos || result.videos.length === 0) {
const poster = contentData?.posterUrl || null;
const filteredManifests = filterManifests(tabManifests);
if (filteredManifests.length > 0) {
log('scanVideos fallback: tab', tab.id, 'no visible videos but found', filteredManifests.length, 'manifests — creating fallback entries');
const fallbackVideos = filteredManifests.map(m => ({
src: m.url,
poster: poster,
mediaType: 'hls',
hlsManifest: m.url,
}));
return {
tabTitle: tab.title || 'Unknown',
result: { videos: fallbackVideos },
};
}
log('scanVideos: tab', tab.id, 'has no videos and no manifests — skipping');
return null;
}
return {
tabTitle: tab.title || 'Unknown',
result,
};
}).filter(g => g != null && g.result && g.result.videos?.length > 0);
// Step 4: Dedup across tabs
const seen = new Map();
for (const group of tabResults) {
for (const video of group.result.videos) {
const primarySrc = video.hlsManifest || video.src || '(empty)';
const key = primarySrc + '||' + (video.poster || '(none)');
if (!seen.has(key)) {
seen.set(key, makeVideoEntry(video, group.tabTitle));
}
}
}
const items = [...seen.values()];
await attachReferers(items);
const skipped = scrapeResults.filter(r => r === null).length;
log('scanVideos complete: scanned=', tabResults.length, 'skipped=', skipped, 'unique items=', items.length);
if (items.length > 0) {
log('Final items:', JSON.stringify(items.map(v => ({ src: (v.src || '').substring(0, 80), mediaType: v.mediaType, tab: v.tab }))));
}
await logToPage(`scan complete: ${items.length} unique items from ${tabResults.length} tabs (skipped: ${skipped})`);
return { items, scanned: tabResults.length, skipped };
}
/**
* Bind the provider referer the SW captured (domain-aware) to each item so it
* survives the watch-later flow and covers the sibling edge subdomains a segment
* may resolve to. Best-effort: playback still falls back to the SW's live store.
*/
async function attachReferers(items) {
const hostOf = (url) => { try { return new URL(url).host.toLowerCase(); } catch { return ''; } };
const hosts = [];
for (const it of items) {
const host = hostOf(it.hlsManifest || it.src || '');
if (host) hosts.push(host);
}
if (hosts.length === 0) return;
let referers = {};
try {
const resp = await chrome.runtime.sendMessage({ type: 'GOON_GET_REFERERS', hosts });
if (resp && resp.referers) referers = resp.referers;
} catch { /* referer is best-effort; the SW still resolves it at play time */ }
if (!Object.keys(referers).length) return;
for (const it of items) {
const host = hostOf(it.hlsManifest || it.src || '');
if (host && referers[host]) it.referer = referers[host];
}
}
/** Store video entries in chrome.storage.local. */
async function storeVideos(items) {
const stored = items.map(v => ({
src: v.src,
poster: v.poster,
tab: v.tab,
mediaType: v.mediaType || 'video',
hlsManifest: v.hlsManifest || undefined,
referer: v.referer || undefined,
}));
await chrome.storage.local.set({
[STORAGE_KEY]: stored,
[TAGGED_KEY]: items.map(v => v.tab),
});
}
/* ======================================================================== */
/* Event handlers */
/* ======================================================================== */
findBtn.addEventListener('click', async () => {
setStatus('Scanning tabs...');
const allTabs = await chrome.tabs.query({ currentWindow: true });
const validTabs = filterValidTabs(allTabs);
if (validTabs.length === 0) {
setStatus('No browsable tabs found.');
return;
}
setStatus(`Scanning ${validTabs.length} tab(s)...`);
try {
const { items, scanned, skipped } = await scanVideos(validTabs);
await storeVideos(items);
const msg = `Found ${items.length} video(s) across ${scanned} tab(s).`;
setStatus(skipped > 0 ? `${msg} ${skipped} tab(s) skipped (idle).` : msg);
btnRow.style.display = 'flex';
// If no videos found, run diagnostics in the active tab
if (items.length === 0) {
const activeTab = validTabs.find(t => t.active) || validTabs[0];
if (activeTab) {
try {
await chrome.scripting.executeScript({
target: { tabId: activeTab.id },
world: 'MAIN',
func: () => {
const diag = [];
// Check interceptor data
if (window.__goon_plugin_data) {
diag.push(`__goon_plugin_data.manifests: ${window.__goon_plugin_data.manifests.length} URLs`);
if (window.__goon_plugin_data.manifests.length > 0) {
diag.push(` First: ${window.__goon_plugin_data.manifests[0].substring(0, 120)}`);
}
} else {
diag.push('__goon_plugin_data: NOT FOUND — interceptors may not have loaded');
}
// Check Hls global
if (typeof Hls !== 'undefined') {
diag.push(`Hls global: defined`);
if (typeof Hls.cast === 'function') {
diag.push(`Hls.cast: available`);
}
} else {
diag.push(`Hls global: NOT DEFINED`);
}
// Check video elements
const videos = document.querySelectorAll('video');
diag.push(`Video elements: ${videos.length}`);
for (let vi = 0; vi < videos.length; vi++) {
const v = videos[vi];
const style = window.getComputedStyle(v);
const isVisible = style.display !== 'none' && v.clientWidth > 0 && v.clientHeight > 0;
const src = (v.currentSrc || v.src || '').substring(0, 120);
diag.push(` Video[${vi}]: src="${src}", visible=${isVisible}, _goon_hls_manifest=${v._goon_hls_manifest || 'null'}`);
// Check custom properties
const customProps = [];
for (const key of Object.keys(v)) {
if (typeof v[key] === 'object' && v[key] && typeof v[key].url === 'string') {
customProps.push(`${key}.url="${v[key].url.substring(0, 80)}"`);
}
}
if (customProps.length > 0) {
diag.push(` Custom props with .url: ${customProps.join(', ')}`);
}
}
// Check known player globals
const playerNames = ['hls', 'Hls', 'player', 'videoPlayer', 'mainPlayer', 'PHPlayer', '__phPlayer', '__ph', 'fhplayer', '__pj', 'bitgravity', 'bgPlayer', 'FG', 'Fhls'];
for (const name of playerNames) {
if (window[name] && typeof window[name] !== 'function') {
const obj = window[name];
const urlVal = obj.url || obj._url || obj.src || obj.manifestUrl || obj.hlsUrl;
if (typeof urlVal === 'string' && urlVal) {
diag.push(`Global ${name}.url-like: "${urlVal.substring(0, 120)}"`);
} else {
diag.push(`Global ${name}: exists but no obvious URL property. Keys: ${Object.keys(obj).slice(0, 20).join(', ')}`);
}
}
}
console.group('[vidplex-diag] HLS diagnostics');
for (const line of diag) {
console.log(line);
}
console.groupEnd();
},
});
} catch (e) {
console.warn('[vidplex-diag] Diagnostic failed:', e.message);
}
}
}
} catch (e) {
setStatus(`Scan failed: ${e.message}`);
warn('executeScript failed:', e);
}
});
/* === "Open Viewer" - launch the NxN grid === */
openBtn.addEventListener('click', async () => {
const { videos, tags } = await loadVideos();
if (!videos || videos.length === 0) {
setStatus('No videos found. Click "Find Videos" first.');
return;
}
const N = Math.ceil(Math.sqrt(videos.length));
const PANEL_MIN_W = 420;
const PANEL_MIN_H = 340;
const GAP = 2;
const winW = Math.max(PANEL_MIN_W * N + GAP * (N - 1), 400);
const winH = Math.max(PANEL_MIN_H * N + GAP * (N - 1) + 40, 300);
const id = 'goonViewer_' + Date.now();
await chrome.storage.local.set({ [id]: videos });
const tagsParam = tags.length > 0 ? '&tags=' + encodeURIComponent(JSON.stringify(tags)) : '';
const viewerUrl = chrome.runtime.getURL('viewer/viewer.html') + '?id=' + id + '&grid=' + N + tagsParam;
chrome.windows.create({ url: viewerUrl, width: winW, height: winH });
});
/* === "Clear" - reset the stored list === */
clearBtn.addEventListener('click', async () => {
await chrome.storage.local.remove([STORAGE_KEY, TAGGED_KEY]);
listEl.style.display = 'none';
listEl.innerHTML = '';
setStatus('');
});
/* === Init - restore saved list === */
showActions();