CI / test (pull_request) Successful in 6m29s
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.
1386 lines
60 KiB
JavaScript
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();
|