// ==UserScript== // @name YTKit v4.88.5 // @namespace https://github.com/SysAdminDoc/Astra-Deck // @version 4.88.5 // @updateURL https://raw.githubusercontent.com/SysAdminDoc/Astra-Deck/main/YTKit.user.js // @downloadURL https://raw.githubusercontent.com/SysAdminDoc/Astra-Deck/main/YTKit.user.js // @description YouTube customization with filtering, playback, accessibility, and research tools; requires the Astra Deck YTKit Core Library and optionally uses the Astra Downloader companion // @author Matthew Parker // @match https://www.youtube.com/* // @match https://youtube.com/* // @match https://youtu.be/* // @exclude https://m.youtube.com/* // @exclude https://studio.youtube.com/* // @run-at document-start // @inject-into content // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM.xmlHttpRequest // @connect sponsor.ajay.app // @connect sponsorblock.kavin.rocks // @connect api.openai.com // @connect api.anthropic.com // @connect generativelanguage.googleapis.com // @connect 127.0.0.1 // @require https://raw.githubusercontent.com/SysAdminDoc/Astra-Deck/refs/tags/v4.88.5/YTKit-core.user.js // @homepageURL https://github.com/SysAdminDoc/Astra-Deck // @supportURL https://github.com/SysAdminDoc/Astra-Deck/issues // @license MIT // @icon https://raw.githubusercontent.com/SysAdminDoc/Astra-Deck/main/extension/icons/128.png // ==/UserScript== // NOTE: localhost is deliberately NOT granted. The companion is always // reached by literal IP, and Firefox still resolves localhost through DNS, // so a hostile resolver could rebind it to an internal address and use this // grant to probe the LAN. The extension refuses it for the same reason. (function() { 'use strict'; // In userscript context, GM_* APIs are native — no shim needed // window.ytInitialPlayerResponse and window.__ytab are directly accessible (same page context) const GM = { xmlHttpRequest: GM_xmlhttpRequest }; // A userscript cannot guarantee pre-request interception: the browser may // start page requests before this sandbox executes. It can still suppress // known ad shells at document-start and keep them suppressed across SPA // reinsertion. Install the extension for browser-level request blocking. const ZERO_AD_CSS = ` #masthead-ad, #player-ads, ytd-in-feed-ad-layout-renderer, ytd-ad-slot-renderer, ytd-page-top-ad-layout-renderer, ytd-promoted-video-renderer, ytd-display-ad-renderer, ytd-promoted-sparkles-web-renderer, ytm-promoted-sparkles-web-renderer, ytd-ad-feedback-renderer, ytd-action-companion-ad-renderer, ytd-companion-slot-renderer, ytd-player-legacy-desktop-watch-ads-renderer, [data-ytkit-zero-ad-semantic], .video-ads, .ytp-ad-module, .ytp-ad-overlay-container, .ytp-ad-player-overlay, .ytp-ad-text-overlay, .ytp-ad-skip-button-container { display: none !important; visibility: hidden !important; pointer-events: none !important; block-size: 0 !important; min-block-size: 0 !important; max-block-size: 0 !important; margin: 0 !important; padding: 0 !important; overflow: hidden !important; } `; GM_addStyle(ZERO_AD_CSS); // Keep the userscript's narrower network contract machine-readable for // manager-specific diagnostics. This is deliberately not named // "zero-ad": only the extension can enforce browser-level pre-request // blocking, while userscript managers provide document-start shell // suppression. const publishUserscriptAdContract = () => { document.documentElement?.setAttribute( 'data-ytkit-userscript-ad-contract', 'document-start-shells-only' ); }; publishUserscriptAdContract(); if (!document.documentElement) { document.addEventListener('readystatechange', publishUserscriptAdContract, { once: true }); } // The userscript is intentionally English-only today, but extracted // feature factories share the extension's t(key, fallback) contract. // Keep that contract available so the generated @require modules can // initialize instead of aborting the entire userscript at document-start. function t(key, fallback = '') { return String(fallback || key || ''); } // GM_cookie not available in userscripts — provide no-op const GM_cookie = { list(filter, cb) { cb(null, 'GM_cookie not available in userscript mode'); } }; // triggerDownload — open URL directly in userscript mode (no chrome.downloads API) function triggerDownload(url, filename) { return new Promise((resolve) => { const a = document.createElement('a'); a.href = url; if (filename) a.download = filename; a.style.display = 'none'; const parent = document.body || document.documentElement; if (parent) parent.appendChild(a); a.click(); setTimeout(() => { try { a.remove(); } catch(_) {} resolve({ ok: true }); }, 200); }); } // ── BEGIN v5.0.0 bundled core modules ── // The v5.0.0 modules are delivered by the configured @require dependency. // This manifest keeps the dependency order visible in the main artifact; // the generated YTKit-core.user.js contains the executable module bodies. // ── bundled module: extension/core/regex-safety.js ── // ── bundled module: extension/core/styles.js ── // ── bundled module: extension/core/trusted-html.js ── // ── bundled module: extension/core/settings-visual-system.js ── // ── bundled module: extension/core/settings-schema.js ── // ── bundled module: extension/core/injection-guard.js ── // ── bundled module: extension/core/feature-lifecycle.js ── // ── bundled module: extension/core/policy-profile.js ── // ── bundled module: extension/core/settings-controller.js ── // ── bundled module: extension/core/settings-import-transaction.js ── // ── bundled module: extension/core/cookie-handoff.js ── // ── bundled module: extension/core/transcript-service.js ── // ── bundled module: extension/core/transcript-index.js ── // ── bundled module: extension/core/ai-summary-artifacts.js ── // ── bundled module: extension/core/credential-vault.js ── // ── bundled module: extension/core/local-ai.js ── // ── bundled module: extension/core/userscript-ai-summary.js ── // ── bundled module: extension/core/external-api-health.js ── // ── bundled module: extension/core/selector-health.js ── // ── bundled module: extension/core/feature-health.js ── // ── bundled module: extension/core/chapters.js ── // ── bundled module: extension/core/csv.js ── // ── bundled module: extension/core/dialog-guard.js ── // ── bundled module: extension/core/zero-ad-dom.js ── // ── bundled module: extension/core/element-zapper.js ── // ── bundled module: extension/features/element-zapper/index.js ── // ── bundled module: extension/core/hide-attribution.js ── // ── bundled module: extension/core/heatmap.js ── // ── bundled module: extension/core/youtube-thumbnails.js ── // ── bundled module: extension/core/feature-schedule.js ── // ── bundled module: extension/core/feed-prefilter.js ── // ── bundled module: extension/core/companion-ports.js ── // ── bundled module: extension/core/data-flow.js ── // ── bundled module: extension/core/toast.js ── // ── bundled module: extension/core/toast-dom.js ── // ── bundled module: extension/core/navigation.js ── // ── bundled module: extension/core/player.js ── // ── bundled module: extension/core/resource-unlock.js ── // ── bundled module: extension/core/text-metrics.js ── // ── bundled module: extension/core/date-time.js ── // ── bundled module: extension/core/failure-copy.js ── // ── bundled module: extension/core/runtime-flags.js ── // ── bundled module: extension/core/capability-probe.js ── // ── bundled module: extension/features/subtitles/index.js ── // ── bundled module: extension/features/video-filters/index.js ── // ── bundled module: extension/features/blue-light-filter/index.js ── // ── bundled module: extension/features/theme-css/index.js ── // ── bundled module: extension/features/wave-8-css/index.js ── // ── bundled module: extension/features/home-subs-css/index.js ── // ── bundled module: extension/features/chat-style-comments/index.js ── // ── bundled module: extension/features/sticky-video/index.js ── // ── bundled module: extension/features/sticky-chat/index.js ── // ── bundled module: extension/features/video-hider/index.js ── // ── bundled module: extension/features/video-notes/index.js ── // ── bundled module: extension/features/subscription-groups/index.js ── // ── bundled module: extension/features/digital-wellbeing/index.js ── // ── bundled module: extension/features/settings-panel/index.js ── // ── bundled module: extension/features/player-dock/index.js ── // ── bundled module: extension/features/youtube-music-compat/index.js ── // ── bundled module: extension/features/return-dislike/index.js ── // ── bundled module: extension/features/sponsorblock/index.js ── // ── bundled module: extension/features/dearrow/index.js ── // ── bundled module: extension/core/lifecycle-route-bridge.js ── // ── END v5.0.0 bundled core modules ── // Shared logical-volume state used by the optional logarithmic curve and // Remember Volume. The controller only changes native media gain; // extension-only Web Audio boost stages are never persisted as volume. const VolumeCurveController = globalThis.YTKitCore?.volumeCurveController || null; const getVolumeCurveVideo = () => globalThis.YTKitCore?.getMainVideoElement?.() || document.querySelector('video.html5-main-video, #movie_player video, video'); const getVolumeCurvePlayer = () => globalThis.YTKitCore?.getMoviePlayerElement?.() || document.querySelector('#movie_player'); function setSafeBlankTarget(anchor) { if (!(anchor instanceof HTMLAnchorElement)) return anchor; anchor.target = '_blank'; anchor.rel = 'noopener noreferrer'; return anchor; } function openExternalWindow(url) { return window.open(url, '_blank', 'noopener,noreferrer'); } // In userscript context, window.ytInitialPlayerResponse and window.__ytab // are directly accessible (same page context, no ISOLATED/MAIN world split) // SECTION 0A: CORE UTILITIES & UNIFIED STORAGE // Settings version for migrations // Page type detection for lazy-loading features const PageTypes = { HOME: 'home', WATCH: 'watch', SEARCH: 'search', CHANNEL: 'channel', SUBSCRIPTIONS: 'subscriptions', PLAYLIST: 'playlist', SHORTS: 'shorts', HISTORY: 'history', LIBRARY: 'library', OTHER: 'other' }; const WATCH_PAGE_VIDEO_ID_PATTERN = /^[a-zA-Z0-9_-]{11}$/; function isYoutuBeHost(host = window.location.hostname) { const normalizedHost = typeof host === 'string' ? host.toLowerCase() : ''; return normalizedHost === 'youtu.be' || normalizedHost === 'www.youtu.be'; } function isYouTubeHostname(host = window.location.hostname) { const normalizedHost = typeof host === 'string' ? host.toLowerCase() : ''; return isYoutuBeHost(normalizedHost) || normalizedHost === 'youtube.com' || normalizedHost === 'youtube-nocookie.com' || normalizedHost.endsWith('.youtube.com') || normalizedHost.endsWith('.youtube-nocookie.com'); } function isWatchPagePath(path = window.location.pathname, host = window.location.hostname) { if (typeof path === 'string' && path.startsWith('/watch')) return true; if (!isYoutuBeHost(host)) return false; const candidate = String(path || '').replace(/^\/+/, '').split(/[/?#]/, 1)[0]; return WATCH_PAGE_VIDEO_ID_PATTERN.test(candidate); } function getCurrentPage() { const path = window.location.pathname; if (isWatchPagePath(path, window.location.hostname)) return PageTypes.WATCH; if (path === '/' || path === '/feed/trending') return PageTypes.HOME; if (path.startsWith('/results')) return PageTypes.SEARCH; if (path.startsWith('/shorts')) return PageTypes.SHORTS; if (path.startsWith('/feed/subscriptions')) return PageTypes.SUBSCRIPTIONS; if (path.startsWith('/feed/history')) return PageTypes.HISTORY; if (path.startsWith('/feed/library') || path.startsWith('/feed/you')) return PageTypes.LIBRARY; if (path.startsWith('/playlist')) return PageTypes.PLAYLIST; if (path.startsWith('/@') || path.startsWith('/channel') || path.startsWith('/c/') || path.startsWith('/user/')) return PageTypes.CHANNEL; return PageTypes.OTHER; } // ── Version ── const YTKIT_VERSION = '4.88.5'; // ── Z-Index Hierarchy ── const Z = { HIDE_BTN: 1000, // Video hide button overlay BUTTONS: 9999, // Download/action buttons EMBED_WRAPPER: 9999, // Embed player wrapper BANNER: 50000, // Floating banners CONTEXT_MENU: 60000, // Right-click context menu TOAST: 70000, // Toast notifications SETTINGS_OVERLAY: 80000, // Settings backdrop SETTINGS_PANEL: 80001, // Settings panel PANEL_TOAST: 90000, // Settings panel toasts }; // ── Settings Panel Cleanup Registry ── let _panelCleanups = []; // ── Timing Constants ── const TIMING = { NAV_DEBOUNCE: 50, // Navigation detection debounce (ms) SAVE_DEBOUNCE: 500, // Settings save debounce (ms) ELEMENT_TIMEOUT: 3000, // waitForElement timeout (ms) }; const UNSAFE_OBJECT_KEYS = new Set(['__proto__', 'prototype', 'constructor']); const IMPORT_LIMITS = Object.freeze({ hiddenVideos: 5000, blockedChannels: 2000, bookmarkVideos: 400, bookmarksPerVideo: 100, bookmarkNoteChars: 500, totalBytes: 4.5 * 1024 * 1024 }); const STORAGE_CAPS = Object.freeze({ watchProgressVideos: 2000, watchProgressMaxAgeMs: 30 * 24 * 60 * 60 * 1000, watchTimeDays: 90, watchTimeImportedEntries: 5000 }); // Seconds credited per imported Takeout watch entry. Takeout records that a // video was opened, never for how long, so this is a flat nominal figure — // matches the extension. const TAKEOUT_WATCH_SECONDS = 60; function isPlainObject(value) { return !!value && typeof value === 'object' && !Array.isArray(value); } function isSafeObjectKey(key) { return typeof key === 'string' && !UNSAFE_OBJECT_KEYS.has(key); } function sanitizeSettingsObject(settings, knownKeys = null) { if (!isPlainObject(settings)) return {}; const sanitized = {}; for (const [key, value] of Object.entries(settings)) { if (!isSafeObjectKey(key)) continue; if (knownKeys && key !== '_settingsVersion' && !knownKeys.has(key)) continue; sanitized[key] = value; } return sanitized; } function sanitizeImportedHiddenVideos(value) { if (!Array.isArray(value)) return []; const seen = new Set(); const sanitized = []; for (const entry of value) { if (typeof entry !== 'string') continue; const videoId = entry.trim(); if (!VIDEO_ID_PATTERN.test(videoId) || seen.has(videoId)) continue; seen.add(videoId); sanitized.push(videoId); if (sanitized.length >= IMPORT_LIMITS.hiddenVideos) break; } return sanitized; } function sanitizeImportedBlockedChannels(value) { if (!Array.isArray(value)) return []; const seen = new Set(); const sanitized = []; for (const entry of value) { if (!isPlainObject(entry)) continue; const id = typeof entry.id === 'string' ? entry.id.trim().slice(0, 128) : ''; if (!id || seen.has(id)) continue; seen.add(id); const name = typeof entry.name === 'string' ? entry.name.trim().slice(0, 200) : id; sanitized.push({ id, name: name || id }); if (sanitized.length >= IMPORT_LIMITS.blockedChannels) break; } return sanitized; } function sanitizeTimestampBookmarks(value, limits = IMPORT_LIMITS) { if (!isPlainObject(value)) return {}; const videos = []; for (const [videoId, entries] of Object.entries(value)) { if (!isSafeObjectKey(videoId) || !VIDEO_ID_PATTERN.test(videoId) || !Array.isArray(entries)) continue; const seenTimes = new Set(); const sanitizedEntries = []; for (const entry of entries) { if (!isPlainObject(entry)) continue; const rawTime = Number(entry.t); if (!Number.isFinite(rawTime) || rawTime < 0) continue; const time = Math.floor(rawTime); if (seenTimes.has(time)) continue; seenTimes.add(time); const note = typeof entry.n === 'string' ? entry.n.slice(0, limits.bookmarkNoteChars) : ''; const createdAt = Number.isFinite(Number(entry.d)) && Number(entry.d) > 0 ? Math.floor(Number(entry.d)) : 0; sanitizedEntries.push({ t: time, n: note, d: createdAt }); } if (sanitizedEntries.length === 0) continue; sanitizedEntries.sort((left, right) => ((Number(right.d) || 0) - (Number(left.d) || 0)) || (left.t - right.t)); const cappedEntries = sanitizedEntries.slice(0, limits.bookmarksPerVideo).sort((left, right) => left.t - right.t); const newest = cappedEntries.reduce((max, entry) => Math.max(max, Number(entry.d) || 0), 0); videos.push([videoId, cappedEntries, newest]); } videos.sort((left, right) => (right[2] - left[2]) || left[0].localeCompare(right[0])); return Object.fromEntries(videos.slice(0, limits.bookmarkVideos).map(([videoId, entries]) => [videoId, entries])); } function sanitizeImportedBookmarks(value) { return sanitizeTimestampBookmarks(value); } function sanitizeWatchProgressStore(value, nowMs = Date.now()) { if (!isPlainObject(value)) return {}; const cutoff = nowMs - STORAGE_CAPS.watchProgressMaxAgeMs; const entries = []; for (const [videoId, raw] of Object.entries(value)) { if (!isSafeObjectKey(videoId) || !VIDEO_ID_PATTERN.test(videoId) || !isPlainObject(raw)) continue; const percent = Number(raw.p); const updatedAt = Number(raw.t); if (!Number.isFinite(percent) || !Number.isFinite(updatedAt) || updatedAt < cutoff) continue; entries.push([videoId, { p: Math.max(0, Math.min(100, Math.round(percent))), t: Math.floor(updatedAt) }]); } entries.sort((left, right) => ((Number(right[1]?.t) || 0) - (Number(left[1]?.t) || 0)) || left[0].localeCompare(right[0])); return Object.fromEntries(entries.slice(0, STORAGE_CAPS.watchProgressVideos)); } function formatLocalDateKey(date) { return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`; } function normalizeTakeoutWatchTitle(value) { const raw = String(value || '').replace(/\s+/g, ' ').trim(); const title = raw.replace(/^Watched\s+/i, '').trim(); return (title || 'Untitled YouTube video').slice(0, 180); } function extractTakeoutVideoId(value) { const raw = String(value || '').trim(); if (!raw) return ''; if (VIDEO_ID_PATTERN.test(raw)) return raw; try { const url = new URL(raw, 'https://www.youtube.com'); const fromQuery = url.searchParams.get('v'); if (VIDEO_ID_PATTERN.test(fromQuery || '')) return fromQuery; const pathParts = url.pathname.split('/').filter(Boolean); const youtuBeId = url.hostname === 'youtu.be' ? pathParts[0] : ''; if (VIDEO_ID_PATTERN.test(youtuBeId || '')) return youtuBeId; for (const marker of ['shorts', 'embed', 'live']) { const idx = pathParts.indexOf(marker); if (idx > -1 && VIDEO_ID_PATTERN.test(pathParts[idx + 1] || '')) return pathParts[idx + 1]; } } catch (_) { // reason: fallback regex below covers raw Takeout-ish strings } const match = raw.match(/[?&]v=([a-zA-Z0-9_-]{11})\b|youtu\.be\/([a-zA-Z0-9_-]{11})\b|\/(?:shorts|embed|live)\/([a-zA-Z0-9_-]{11})\b/); return match ? (match[1] || match[2] || match[3] || '') : ''; } function getTakeoutWatchEntriesPayload(value) { if (Array.isArray(value)) return value; if (!isPlainObject(value)) return []; for (const key of ['watchHistory', 'watch_history', 'history', 'items', 'entries']) { if (Array.isArray(value[key])) return value[key]; } return []; } function normalizeTakeoutWatchEntry(entry) { if (!isPlainObject(entry)) return null; const videoId = extractTakeoutVideoId(entry.titleUrl || entry.url || entry.href || entry.videoId || entry.id); const timeMs = Date.parse(entry.time || entry.timestamp || entry.watchedAt || entry.date || ''); if (!VIDEO_ID_PATTERN.test(videoId) || !Number.isFinite(timeMs)) return null; const watchedAtDate = new Date(timeMs); return { videoId, watchedAt: watchedAtDate.toISOString(), dayKey: formatLocalDateKey(watchedAtDate), title: normalizeTakeoutWatchTitle(entry.title || entry.name || entry.videoTitle || ''), seconds: TAKEOUT_WATCH_SECONDS }; } function sanitizeWatchTimeImportedEntries(value, nowDate = new Date()) { if (!isPlainObject(value)) return {}; const cutoff = new Date(nowDate); cutoff.setDate(cutoff.getDate() - STORAGE_CAPS.watchTimeDays); const cutoffKey = formatLocalDateKey(cutoff); const todayKey = formatLocalDateKey(nowDate); const entries = []; for (const [rawKey, raw] of Object.entries(value)) { if (!isSafeObjectKey(rawKey) || !isPlainObject(raw)) continue; const videoId = extractTakeoutVideoId(raw.videoId || raw.id || rawKey); const watchedMs = Date.parse(raw.watchedAt || raw.time || raw.timestamp || ''); if (!VIDEO_ID_PATTERN.test(videoId) || !Number.isFinite(watchedMs)) continue; const watchedAt = new Date(watchedMs).toISOString(); const dayKey = /^\d{4}-\d{2}-\d{2}$/.test(raw.dayKey || '') ? raw.dayKey : formatLocalDateKey(new Date(watchedMs)); // Reject days outside [cutoff, today]: future-dated entries (clock // skew / malformed exports) would sort to the top and evict genuine // recent days from the newest-90 slice below. if (dayKey <= cutoffKey || dayKey > todayKey) continue; const seconds = Math.max(1, Math.min(24 * 60 * 60, Math.floor(Number(raw.seconds) || TAKEOUT_WATCH_SECONDS))); entries.push([`${videoId}@${watchedAt}`, { videoId, watchedAt, dayKey, title: normalizeTakeoutWatchTitle(raw.title), seconds }]); } entries.sort((left, right) => right[1].watchedAt.localeCompare(left[1].watchedAt) || left[0].localeCompare(right[0])); return Object.fromEntries(entries.slice(0, STORAGE_CAPS.watchTimeImportedEntries)); } function sanitizeWatchTimeStats(value, nowDate = new Date()) { const stats = isPlainObject(value) ? value : {}; const rawDays = isPlainObject(stats.days) ? stats.days : {}; const cutoff = new Date(nowDate); cutoff.setDate(cutoff.getDate() - STORAGE_CAPS.watchTimeDays); const cutoffKey = formatLocalDateKey(cutoff); const todayKey = formatLocalDateKey(nowDate); const days = []; for (const [dayKey, rawSeconds] of Object.entries(rawDays)) { if (!/^\d{4}-\d{2}-\d{2}$/.test(dayKey) || dayKey <= cutoffKey || dayKey > todayKey) continue; const seconds = Number(rawSeconds); if (!Number.isFinite(seconds) || seconds <= 0) continue; days.push([dayKey, seconds]); } days.sort((left, right) => right[0].localeCompare(left[0])); const total = Number(stats.total); return { days: Object.fromEntries(days.slice(0, STORAGE_CAPS.watchTimeDays)), total: Number.isFinite(total) && total > 0 ? total : 0, imported: sanitizeWatchTimeImportedEntries(stats.imported, nowDate) }; } function mergeTakeoutWatchHistoryIntoStats(currentStats, takeoutPayload, nowDate = new Date()) { const stats = sanitizeWatchTimeStats(currentStats, nowDate); const entries = getTakeoutWatchEntriesPayload(takeoutPayload).map(normalizeTakeoutWatchEntry).filter(Boolean); const cutoff = new Date(nowDate); cutoff.setDate(cutoff.getDate() - STORAGE_CAPS.watchTimeDays); const cutoffKey = formatLocalDateKey(cutoff); const todayKey = formatLocalDateKey(nowDate); const importedLedger = { ...(stats.imported || {}) }; let imported = 0; let duplicates = 0; let skipped = 0; for (const entry of entries) { if (entry.dayKey <= cutoffKey || entry.dayKey > todayKey) { skipped++; continue; } const key = `${entry.videoId}@${entry.watchedAt}`; if (importedLedger[key]) { duplicates++; continue; } importedLedger[key] = entry; imported++; } // Sanitize the ledger BEFORE recomputing totals — this caps it at // STORAGE_CAPS.watchTimeImportedEntries. Deriving days/total from the // surviving entries stops a re-import double-counting seconds from // entries that fell off the cap. const sanitizedResult = sanitizeWatchTimeStats({ ...stats, imported: importedLedger }, nowDate); // The stored day map is NOT purely organic after the first import — the // merged result is what gets persisted. Re-adding the whole surviving // ledger on top of it would count every previously imported second // again, compounding per import. Recover the organic baseline by // removing what the PREVIOUS ledger contributed before adding the // current one back. const organicDays = { ...sanitizeWatchTimeStats(currentStats, nowDate).days }; for (const entry of Object.values(stats.imported || {})) { if (!entry || !entry.dayKey || !entry.seconds) continue; const remaining = (Number(organicDays[entry.dayKey]) || 0) - entry.seconds; if (remaining > 0) organicDays[entry.dayKey] = remaining; else delete organicDays[entry.dayKey]; } const mergedDays = { ...organicDays }; for (const entry of Object.values(sanitizedResult.imported)) { if (!entry || !entry.dayKey || !entry.seconds) continue; mergedDays[entry.dayKey] = (Number(mergedDays[entry.dayKey]) || 0) + entry.seconds; } let mergedTotal = 0; for (const v of Object.values(mergedDays)) mergedTotal += Number(v) || 0; sanitizedResult.days = mergedDays; sanitizedResult.total = mergedTotal; return { stats: sanitizedResult, imported, duplicates, skipped, parsed: entries.length }; } function estimateSerializedBytes(value) { try { return new Blob([JSON.stringify(value)]).size; } catch { return Infinity; } } // DOM fragment helpers function applyElementAttributes(element, attributes = {}) { for (const [name, value] of Object.entries(attributes)) { if (value === null || value === undefined) continue; element.setAttribute(name, String(value)); } return element; } function appendTextSpan(parent, text) { const span = document.createElement('span'); span.textContent = text; parent.appendChild(span); return span; } function createFilledPathIcon(viewBox, d, attributes = {}) { const svg = createSVG(viewBox, [{ type: 'path', d }], { fill: 'currentColor', stroke: false }); return applyElementAttributes(svg, attributes); } function createStrokeIcon(viewBox, paths, attributes = {}) { const svg = createSVG(viewBox, paths, { strokeWidth: '1.5', strokeLinecap: 'round', strokeLinejoin: 'round' }); return applyElementAttributes(svg, attributes); } function createYouTubeLogoSvg(attributes = {}) { const svg = createSVG('0 0 248 174', [ { type: 'path', fill: '#ff0000', d: 'M 242.88,27.11 A 31.07,31.07 0 0 0 220.95,5.18 C 201.6,0 124,0 124,0 124,0 46.46,0 27.11,5.18 A 31.07,31.07 0 0 0 5.18,27.11 C 0,46.46 0,86.82 0,86.82 c 0,0 0,40.36 5.18,59.71 a 31.07,31.07 0 0 0 21.93,21.93 c 19.35,5.18 96.92,5.18 96.92,5.18 0,0 77.57,0 96.92,-5.18 a 31.07,31.07 0 0 0 21.93,-21.93 c 5.18,-19.35 5.18,-59.71 5.18,-59.71 0,0 0,-40.36 -5.18,-59.71 z' }, { type: 'path', fill: '#ffffff', d: 'M 99.22,124.03 163.67,86.82 99.22,49.61 Z' } ], { stroke: false }); return applyElementAttributes(svg, attributes); } // Unified Storage Manager const StorageManager = { _cache: Object.create(null), _dirty: new Set(), _saveTimeout: null, get(key, defaultVal = null) { if (Object.prototype.hasOwnProperty.call(this._cache, key)) { return this._cache[key]; } try { const val = GM_getValue(key, defaultVal); this._cache[key] = val; return val; } catch (e) { console.warn('[YTKit Storage] Failed to get:', key, e); return defaultVal; } }, set(key, value) { this._cache[key] = value; this._dirty.add(key); this._scheduleSave(); }, _scheduleSave() { if (this._saveTimeout) return; this._saveTimeout = setTimeout(() => this._flush(), TIMING.SAVE_DEBOUNCE); }, _flush() { this._saveTimeout = null; const toSave = [...this._dirty]; for (const key of toSave) { try { GM_setValue(key, this._cache[key]); this._dirty.delete(key); } catch (e) { console.error('[YTKit Storage] Failed to save:', key, e); } } }, setSync(key, value) { this._cache[key] = value; try { GM_setValue(key, value); } catch (e) { console.error('[YTKit Storage] Sync save failed:', key, e); } }, // Ensure pending writes are flushed before page unload _initUnloadFlush() { window.addEventListener('beforeunload', () => { if (this._saveTimeout) { clearTimeout(this._saveTimeout); this._saveTimeout = null; } if (this._dirty.size > 0) this._flush(); }); // Also flush on YouTube SPA navigations document.addEventListener('yt-navigate-start', () => { if (this._dirty.size > 0) this._flush(); }); } }; StorageManager._initUnloadFlush(); // Extracted feature factories use the shared async storage facade. The // userscript keeps its synchronous GM-backed StorageManager, so bridge the // two contracts explicitly instead of leaving bare identifiers that abort // startup before any feature or settings UI can initialize. const storageReadJSON = globalThis.YTKitCore?.storageReadJSON || ((key, fallback = null) => StorageManager.get(key, fallback)); const storageWriteJSON = globalThis.YTKitCore?.storageWriteJSON || ((key, value) => { StorageManager.set(key, value); return Promise.resolve({ ok: true }); }); // TRANSCRIPT SERVICE - Multi-Method Extraction with Failover const LegacyTranscriptService = { config: { preferredLanguages: ['en', 'en-US', 'en-GB'], preferManualCaptions: true, includeTimestamps: true, debug: false }, // Main entry point - downloads transcript with automatic failover async downloadTranscript(options = {}) { const videoId = getVideoId(); if (!videoId) { showToast('No video ID found', '#ef4444'); return { success: false, error: 'No video ID' }; } showToast('Fetching transcript...', '#3b82f6'); this._log('Starting transcript fetch for:', videoId); try { const trackData = await this._getCaptionTracks(videoId); if (!trackData || !trackData.tracks || trackData.tracks.length === 0) { showToast('No transcript available for this video', '#ef4444'); return { success: false, error: 'No captions available' }; } const selectedTrack = this._selectBestTrack(trackData.tracks); this._log('Selected track:', selectedTrack.languageCode, selectedTrack.kind); const segments = await this._fetchTranscriptContent(selectedTrack.baseUrl); if (!segments || segments.length === 0) { showToast('Failed to parse transcript content', '#ef4444'); return { success: false, error: 'Parse failed' }; } const videoTitle = this._sanitizeFilename(trackData.videoTitle || videoId); const content = this._formatTranscript(segments); this._downloadFile(content, `${videoTitle}_transcript.txt`); showToast(`Transcript downloaded! (${segments.length} segments)`, '#22c55e'); return { success: true, segments: segments.length, language: selectedTrack.languageCode }; } catch (error) { console.error('[YTKit TranscriptService] Error:', error); showToast('Failed to download transcript', '#ef4444'); return { success: false, error: error.message }; } }, // Multi-method caption track retrieval with automatic failover async _getCaptionTracks(videoId) { const methods = [ { name: 'ytInitialPlayerResponse', fn: () => this._method1_WindowVariable(videoId) }, { name: 'Innertube API', fn: () => this._method2_InnertubeAPI(videoId) }, { name: 'HTML Page Fetch', fn: () => this._method3_HTMLPageFetch(videoId) }, { name: 'captionTracks Regex', fn: () => this._method4_CaptionTracksRegex(videoId) }, { name: 'DOM Panel Scrape', fn: () => this._method5_DOMPanelScrape(videoId) } ]; for (const method of methods) { try { this._log(`Trying method: ${method.name}`); const result = await method.fn(); if (result && result.tracks && result.tracks.length > 0) { this._log(`Success with method: ${method.name}`, result.tracks.length, 'tracks found'); return result; } } catch (error) { this._log(`Method ${method.name} failed:`, error.message); } } return null; }, // Method 1: window.ytInitialPlayerResponse (fastest for fresh page loads) _method1_WindowVariable(videoId) { const playerResponse = window.ytInitialPlayerResponse; if (!playerResponse?.videoDetails?.videoId) { throw new Error('ytInitialPlayerResponse not available'); } if (playerResponse.videoDetails.videoId !== videoId) { throw new Error('ytInitialPlayerResponse is stale (different video)'); } return this._extractFromPlayerResponse(playerResponse); }, // Method 2: Innertube API (most reliable for SPA navigation) async _method2_InnertubeAPI(videoId) { const apiKey = this._getInnertubeApiKey(); if (!apiKey) { // No page-derived key — never send a placeholder (guaranteed 400). // Throwing lets _getCaptionTracks fail over to the next method. throw new Error('Innertube API key unavailable'); } const clientVersion = this._getClientVersion() || '2.20250120.00.00'; const response = await fetch(`https://www.youtube.com/youtubei/v1/player?key=${apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ context: { client: { clientName: 'WEB', clientVersion: clientVersion } }, videoId: videoId }) }); if (!response.ok) throw new Error(`Innertube API returned ${response.status}`); const data = await response.json(); return this._extractFromPlayerResponse(data); }, // Method 3: Fetch HTML and extract ytInitialPlayerResponse async _method3_HTMLPageFetch(videoId) { const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`); if (!response.ok) throw new Error(`Page fetch returned ${response.status}`); const html = await response.text(); const patterns = [ /ytInitialPlayerResponse\s*=\s*({.+?});\s*(?:var\s|const\s|let\s|<\/script>)/s, /ytInitialPlayerResponse\s*=\s*({.+?});/s, /var\s+ytInitialPlayerResponse\s*=\s*({.+?});/s ]; for (const pattern of patterns) { const match = html.match(pattern); if (match && match[1]) { try { const playerResponse = JSON.parse(match[1]); return this._extractFromPlayerResponse(playerResponse); } catch (parseError) { this._log('JSON parse failed for pattern, trying next'); } } } throw new Error('Could not extract ytInitialPlayerResponse from HTML'); }, // Method 4: Direct captionTracks regex extraction async _method4_CaptionTracksRegex(videoId) { const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`); if (!response.ok) throw new Error(`Page fetch returned ${response.status}`); const html = await response.text(); const captionMatch = html.match(/"captionTracks":(\[.*?\])(?:,|\})/); if (!captionMatch || !captionMatch[1]) { throw new Error('captionTracks not found in page'); } const captionJson = captionMatch[1].replace(/\\u0026/g, '&'); const tracks = JSON.parse(captionJson); let videoTitle = videoId; const titleMatch = html.match(/"title":"([^"]+)"/); if (titleMatch && titleMatch[1]) { videoTitle = titleMatch[1].replace(/\\u0026/g, '&').replace(/\\"/g, '"'); } return { tracks: tracks.map(t => ({ baseUrl: t.baseUrl?.replace(/\\u0026/g, '&'), languageCode: t.languageCode, name: t.name?.simpleText || t.name?.runs?.[0]?.text || t.languageCode, kind: t.kind || (t.vssId?.startsWith('a.') ? 'asr' : 'manual'), vssId: t.vssId })), videoTitle: videoTitle }; }, // Method 5: DOM panel scraping (final fallback) async _method5_DOMPanelScrape(videoId) { const transcriptRenderer = document.querySelector('ytd-transcript-renderer'); if (!transcriptRenderer) throw new Error('Transcript panel not found in DOM'); const data = transcriptRenderer.__data?.data || transcriptRenderer.data; if (!data) throw new Error('No data in transcript renderer'); const footer = data.content?.transcriptSearchPanelRenderer?.footer?.transcriptFooterRenderer; const languageMenu = footer?.languageMenu?.sortFilterSubMenuRenderer?.subMenuItems; if (!languageMenu || languageMenu.length === 0) { throw new Error('No language menu found in panel data'); } const tracks = languageMenu.map(item => ({ baseUrl: item.continuation?.reloadContinuationData?.continuation, languageCode: item.languageCode || 'unknown', name: item.title || 'Unknown', kind: item.title?.toLowerCase().includes('auto') ? 'asr' : 'manual' })); const videoTitle = document.querySelector('h1.ytd-watch-metadata yt-formatted-string')?.textContent || videoId; return { tracks, videoTitle }; }, // Extract track info from player response object _extractFromPlayerResponse(playerResponse) { if (!playerResponse?.captions?.playerCaptionsTracklistRenderer?.captionTracks) { throw new Error('No caption tracks in player response'); } const captionTracks = playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks; const videoTitle = playerResponse.videoDetails?.title || ''; return { tracks: captionTracks.map(t => ({ baseUrl: t.baseUrl, languageCode: t.languageCode, name: t.name?.simpleText || t.name?.runs?.[0]?.text || t.languageCode, kind: t.kind || (t.vssId?.startsWith('a.') ? 'asr' : 'manual'), vssId: t.vssId })), videoTitle: videoTitle }; }, // Select best track based on language and type preferences _selectBestTrack(tracks) { if (tracks.length === 1) return tracks[0]; const { preferredLanguages, preferManualCaptions } = this.config; const scored = tracks.map(track => { let score = 0; const langIndex = preferredLanguages.findIndex(lang => track.languageCode?.toLowerCase().startsWith(lang.toLowerCase()) ); if (langIndex !== -1) { score += (preferredLanguages.length - langIndex) * 10; } if (preferManualCaptions && track.kind !== 'asr') { score += 5; } else if (!preferManualCaptions && track.kind === 'asr') { score += 5; } return { track, score }; }); scored.sort((a, b) => b.score - a.score); return scored[0].track; }, // Fetch and parse transcript content from baseUrl async _fetchTranscriptContent(baseUrl) { if (!baseUrl) throw new Error('No baseUrl provided for transcript'); const formats = ['json3', 'xml']; for (const fmt of formats) { try { const url = fmt === 'xml' ? baseUrl : `${baseUrl}&fmt=${fmt}`; const response = await fetch(url); if (!response.ok) continue; const content = await response.text(); if (fmt === 'json3') { return this._parseJSON3(content); } else { return this._parseXML(content); } } catch (e) { this._log(`Format ${fmt} failed:`, e.message); } } throw new Error('Failed to fetch transcript in any format'); }, // Parse JSON3 format (word-level timing) _parseJSON3(content) { const data = JSON.parse(content); const segments = []; if (!data.events) throw new Error('No events in JSON3 response'); for (const event of data.events) { if (!event.segs) continue; const text = event.segs .map(seg => seg.utf8 || '') .join('') .replace(/\n/g, ' ') .trim(); if (text) { const seg = { startMs: event.tStartMs || 0, endMs: (event.tStartMs || 0) + (event.dDurationMs || 0), text: text }; // Preserve word-level timing from tOffsetMs if (event.segs.length > 1 && event.segs.some(s => s.tOffsetMs !== undefined)) { const evtStart = (event.tStartMs || 0) / 1000; const evtEnd = ((event.tStartMs || 0) + (event.dDurationMs || 0)) / 1000; seg.words = []; for (let i = 0; i < event.segs.length; i++) { const w = (event.segs[i].utf8 || '').replace(/\n/g, ' ').trim(); if (!w) continue; const wStart = evtStart + (event.segs[i].tOffsetMs || 0) / 1000; const nextOffset = (i < event.segs.length - 1 && event.segs[i+1].tOffsetMs !== undefined) ? evtStart + event.segs[i+1].tOffsetMs / 1000 : evtEnd; seg.words.push({ text: w, start: wStart, end: nextOffset }); } } segments.push(seg); } } return segments; }, // Parse XML format (fallback) _parseXML(content) { const segments = []; const textRegex = /]*start="([^"]*)"[^>]*(?:dur="([^"]*)")?[^>]*>([\s\S]*?)<\/text>/g; let match; while ((match = textRegex.exec(content)) !== null) { const startSeconds = parseFloat(match[1]) || 0; const duration = parseFloat(match[2]) || 0; const text = this._decodeHTMLEntities(this._stripXmlTags(match[3])) .trim(); if (text) { segments.push({ startMs: Math.round(startSeconds * 1000), endMs: Math.round((startSeconds + duration) * 1000), text: text }); } } return segments; }, // Format segments into transcript text _formatTranscript(segments) { return segments.map(s => { if (this.config.includeTimestamps) { const timestamp = this._formatTimestamp(s.startMs); return `[${timestamp}] ${s.text}`; } return s.text; }).join('\n'); }, _formatTimestamp(ms) { const totalSeconds = Math.floor(ms / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (hours > 0) { return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; }, _stripXmlTags(value) { let out = ''; let inTag = false; for (const ch of String(value || '')) { if (ch === '<') { inTag = true; continue; } if (inTag) { if (ch === '>') inTag = false; continue; } out += ch; } return out; }, _cachedApiKey: null, _getInnertubeApiKey() { if (this._cachedApiKey) return this._cachedApiKey; if (typeof window.ytcfg !== 'undefined' && window.ytcfg.get) { const key = window.ytcfg.get('INNERTUBE_API_KEY'); if (key) { this._cachedApiKey = key; return key; } } const scripts = document.querySelectorAll('script'); for (const s of scripts) { const m = s.textContent.match(/"INNERTUBE_API_KEY":"([^"]+)"/); if (m) { this._cachedApiKey = m[1]; return m[1]; } } return null; }, _getClientVersion() { if (typeof window.ytcfg !== 'undefined' && window.ytcfg.get) { return window.ytcfg.get('INNERTUBE_CLIENT_VERSION'); } return null; }, _decodeHTMLEntities(text) { return text .replace(/&#x([a-fA-F0-9]+);/g, (m, hex) => { const cp = parseInt(hex, 16); return Number.isInteger(cp) && cp >= 0 && cp <= 0x10FFFF ? String.fromCodePoint(cp) : m; }) .replace(/&#(\d+);/g, (m, num) => { const cp = Number(num); return Number.isInteger(cp) && cp >= 0 && cp <= 0x10FFFF ? String.fromCodePoint(cp) : m; }) .replace(/'/g, "'") .replace(/'/g, "'") .replace(/"/g, '"') .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&'); }, _sanitizeFilename(name) { return name .replace(/[<>:"/\\|?*]/g, '') .replace(/[^\x00-\x7F]/g, '') .replace(/\s+/g, '_') .toLowerCase() .substring(0, 50); }, _downloadFile(content, filename) { const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.style.display = 'none'; (document.body || document.documentElement).appendChild(a); a.click(); a.remove(); // Delay revoke to ensure download starts setTimeout(() => URL.revokeObjectURL(url), 1000); }, _log(...args) { if (this.config.debug) { console.log('[YTKit TranscriptService]', ...args); } } }; async function userscriptTranscriptFetchText(details = {}) { const response = await fetch(details.url, { method: details.method || 'GET', headers: details.headers || {}, body: details.data, credentials: 'include', signal: details.signal }); const responseMeta = { status: response.status }; if (!response.ok) { const error = new Error(`HTTP ${response.status}`); error.response = responseMeta; throw error; } return { response: responseMeta, text: await response.text() }; } async function userscriptTranscriptFetchJson(details = {}) { const result = await userscriptTranscriptFetchText(details); try { return { response: result.response, data: JSON.parse(result.text) }; } catch (cause) { const error = new Error('Invalid JSON response from YouTube'); error.response = result.response; error.cause = cause; throw error; } } const TranscriptService = typeof globalThis.YTKitCore?.createTranscriptService === 'function' ? globalThis.YTKitCore.createTranscriptService({ getVideoId, showToast, getPlayerResponseGlobal: () => window.ytInitialPlayerResponse || null, isDomTranscriptForVideo: (videoId, panel) => { const flexy = document.querySelector('ytd-watch-flexy'); const response = flexy?.__data?.playerResponse || flexy?.playerResponse; return response?.videoDetails?.videoId === videoId && (!panel || flexy.contains(panel)); }, extensionFetchJson: userscriptTranscriptFetchJson, extensionFetchText: userscriptTranscriptFetchText, t }) : LegacyTranscriptService; // Debug Mode Manager — gated behind a flag, no-op in production const DebugManager = { _enabled: GM_getValue('ytkit_debug', false), log(category, ...args) { if (!this._enabled) return; console.log(`%c[YTKit:${category}]`, 'color:#60a5fa;font-weight:bold', ...args); }, enable() { this._enabled = true; GM_setValue('ytkit_debug', true); }, disable() { this._enabled = false; GM_setValue('ytkit_debug', false); } }; // ── Shared Player Button Styles ── // All YTKit buttons injected into YouTube's player controls use this base class // for consistent sizing, spacing, opacity, and hover behavior. const _playerBtnCSS = document.createElement('style'); _playerBtnCSS.textContent = ` .ytkit-player-btn { display: inline-flex !important; align-items: center !important; justify-content: center !important; height: 100% !important; width: 36px !important; padding: 0 !important; margin: 0 !important; opacity: 0.8; transition: opacity 0.2s !important; color: #fff !important; border: none !important; background: transparent !important; cursor: pointer !important; box-sizing: border-box !important; vertical-align: top !important; line-height: 1 !important; } .ytkit-player-btn:hover { opacity: 1 !important; } .ytkit-player-btn svg { width: 20px; height: 20px; fill: currentColor; pointer-events: none; } .ytkit-player-btn svg[data-stroke] { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } .ytkit-player-btn--text { font-size: 12px !important; font-weight: 700 !important; letter-spacing: 0.3px !important; font-family: inherit !important; } .ytkit-player-btn--active { opacity: 1 !important; color: #22c55e !important; } .ytkit-player-btn--warn { opacity: 1 !important; color: #fbbf24 !important; } `; (document.head || document.documentElement).appendChild(_playerBtnCSS); // SECTION 0B: DYNAMIC CONTENT/STYLE ENGINE let mutationObserver = null; const mutationRules = new Map(); const navigateRules = new Map(); let isNavigateListenerAttached = false; function waitForElement(selector, callback, timeout = TIMING.ELEMENT_TIMEOUT) { if (!selector || typeof callback !== 'function') return () => {}; const el = document.querySelector(selector); if (el) { callback(el); return () => {}; } let _fired = false; let obs = new MutationObserver((mutations) => { if (_fired) return; // Fast-path: check added nodes directly before full querySelectorAll for (const m of mutations) { for (const node of m.addedNodes) { if (node.nodeType !== 1) continue; if (node.matches?.(selector)) { _fired = true; cleanup(); callback(node); return; } } } // Fallback: full query (handles deeply nested insertions) const el = document.querySelector(selector); if (el) { _fired = true; cleanup(); callback(el); } }); const cleanup = () => { if (obs) { obs.disconnect(); obs = null; } if (_timeoutId) { clearTimeout(_timeoutId); _timeoutId = null; } }; obs.observe(document.body || document.documentElement, { childList: true, subtree: true }); let _timeoutId = setTimeout(() => { if (!_fired) cleanup(); }, timeout); return cleanup; } // waitForPageContent — fires callback when YouTube's page content is actually rendered, // rather than using blind setTimeout delays. Uses yt-page-data-updated as the primary // signal (fires when YT pushes data to the page) and falls back to waitForElement // watching for the first rendered video/item. Much faster than fixed 1-2s timeouts. function waitForPageContent(callback, fallbackSelector = 'ytd-rich-item-renderer, ytd-video-renderer, ytd-compact-video-renderer') { let fired = false; let fallbackTimer = null; let cancelElementWait = null; const onPageUpdated = () => fire(); const fire = () => { if (fired) return; fired = true; if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null; } if (cancelElementWait) { cancelElementWait(); cancelElementWait = null; } document.removeEventListener('yt-page-data-updated', onPageUpdated); callback(); }; // yt-page-data-updated fires when YT renders page data — usually within ~200ms of nav document.addEventListener('yt-page-data-updated', onPageUpdated, { once: true }); // Fallback: watch for first content element to appear in DOM cancelElementWait = waitForElement(fallbackSelector, fire); // Hard fallback at 3s in case neither fires (e.g. cached page, rare edge cases) fallbackTimer = setTimeout(fire, 3000); } // PageControl System — dismissible injected buttons with ghost-pill restore const _pageControlDismissed = {}; // in-memory cache: id -> true/false function isPageControlDismissed(id) { if (id in _pageControlDismissed) return _pageControlDismissed[id]; const v = GM_getValue('ytkit_pc_' + id, false); _pageControlDismissed[id] = v; return v; } function setPageControlDismissed(id, dismissed) { _pageControlDismissed[id] = dismissed; GM_setValue('ytkit_pc_' + id, dismissed); } // Wraps an existing element with an X dismiss button and ghost-pill restore. // If currently dismissed, immediately replaces element with ghost pill. // options: { label, color, onRestore } function wrapPageControl(el, id, options = {}) { if (!el || !el.parentNode) return el; const label = options.label || id; const accentColor = options.color || 'rgba(255,255,255,0.15)'; // Ghost pill shown when dismissed const createGhost = () => { const ghost = document.createElement('button'); ghost.className = 'ytkit-pc-ghost'; ghost.dataset.pcId = id; ghost.title = 'Restore: ' + label; ghost.style.cssText = `display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:20px;border:1px dashed rgba(255,255,255,0.2);background:transparent;color:rgba(255,255,255,0.3);font-family:"Roboto",Arial,sans-serif;font-size:12px;cursor:pointer;transition:all 0.2s;white-space:nowrap;`; // Build restore icon via DOM API (avoids TrustedHTML issues in non-policy browsers) const _svgNS = 'http://www.w3.org/2000/svg'; const _ico = document.createElementNS(_svgNS, 'svg'); _ico.setAttribute('viewBox', '0 0 24 24'); _ico.setAttribute('width', '12'); _ico.setAttribute('height', '12'); _ico.setAttribute('fill', 'none'); _ico.setAttribute('stroke', 'currentColor'); _ico.setAttribute('stroke-width', '2'); const _pl = document.createElementNS(_svgNS, 'polyline'); _pl.setAttribute('points', '1 4 1 10 7 10'); _ico.appendChild(_pl); const _pa = document.createElementNS(_svgNS, 'path'); _pa.setAttribute('d', 'M3.51 15a9 9 0 1 0 .49-3.5'); _ico.appendChild(_pa); ghost.appendChild(_ico); const _lbl = document.createElement('span'); _lbl.textContent = label; ghost.appendChild(_lbl); ghost.onmouseenter = () => { ghost.style.color = 'rgba(255,255,255,0.7)'; ghost.style.borderColor = 'rgba(255,255,255,0.5)'; }; ghost.onmouseleave = () => { ghost.style.color = 'rgba(255,255,255,0.3)'; ghost.style.borderColor = 'rgba(255,255,255,0.2)'; }; ghost.addEventListener('click', (e) => { e.stopPropagation(); setPageControlDismissed(id, false); ghost.replaceWith(wrap); if (options.onRestore) options.onRestore(); }); return ghost; }; // Wrap the element const wrap = document.createElement('span'); wrap.className = 'ytkit-pc-wrap'; wrap.style.cssText = 'position:relative;display:inline-flex;align-items:center;'; el.parentNode.insertBefore(wrap, el); wrap.appendChild(el); // Dismiss X button const xBtn = document.createElement('button'); xBtn.className = 'ytkit-pc-x'; xBtn.title = 'Dismiss ' + label; xBtn.style.cssText = `position:absolute;top:-6px;right:-6px;width:16px;height:16px;border-radius:50%;border:none;background:rgba(0,0,0,0.7);color:rgba(255,255,255,0.6);font-size:10px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;opacity:0;transition:opacity 0.15s;z-index:10;`; xBtn.textContent = '×'; wrap.addEventListener('mouseenter', () => { xBtn.style.opacity = '1'; }); wrap.addEventListener('mouseleave', () => { xBtn.style.opacity = '0'; }); xBtn.addEventListener('click', (e) => { e.stopPropagation(); setPageControlDismissed(id, true); wrap.replaceWith(createGhost()); }); wrap.appendChild(xBtn); // If already dismissed, show ghost immediately if (isPageControlDismissed(id)) { wrap.replaceWith(createGhost()); } return wrap; } // Inject global PageControl CSS once GM_addStyle(` .ytkit-pc-wrap:hover .ytkit-pc-x { opacity: 1 !important; } `); // Global toast notification function with optional action button function showToast(message, color = '#22c55e', options = {}) { // Remove existing toast if present document.querySelector('.ytkit-global-toast')?.remove(); const toast = document.createElement('div'); toast.className = 'ytkit-global-toast'; toast.style.cssText = `position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:${color};color:white;padding:12px 24px;border-radius:8px;font-family:"Roboto",Arial,sans-serif;font-size:14px;font-weight:500;z-index:${Z.TOAST};box-shadow:0 4px 12px rgba(0,0,0,0.3);display:flex;align-items:center;gap:12px;animation:ytkit-toast-fade ${options.duration || 2.5}s ease-out forwards;`; const textSpan = document.createElement('span'); textSpan.textContent = message; toast.appendChild(textSpan); // Add action button if provided (e.g., for Undo) if (options.action) { const actionBtn = document.createElement('button'); actionBtn.textContent = options.action.text || 'Undo'; actionBtn.style.cssText = ` background: rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.3); color: white; padding: 4px 12px; border-radius: 4px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background 0.2s; `; actionBtn.onmouseenter = () => { actionBtn.style.background = 'rgba(255,255,255,0.3)'; }; actionBtn.onmouseleave = () => { actionBtn.style.background = 'rgba(255,255,255,0.2)'; }; actionBtn.onclick = (e) => { e.stopPropagation(); toast.remove(); options.action.onClick?.(); }; toast.appendChild(actionBtn); } // Add animation keyframes if not exists if (!document.getElementById('ytkit-toast-animation')) { const style = document.createElement('style'); style.id = 'ytkit-toast-animation'; style.textContent = ` @keyframes ytkit-toast-fade { 0% { opacity: 0; transform: translateX(-50%) translateY(20px); } 10% { opacity: 1; transform: translateX(-50%) translateY(0); } 80% { opacity: 1; transform: translateX(-50%) translateY(0); } 100% { opacity: 0; transform: translateX(-50%) translateY(-20px); } } `; document.head.appendChild(style); } document.body.appendChild(toast); const duration = (options.duration || 2.5) * 1000; setTimeout(() => toast.remove(), duration); return toast; } // Trigger a custom protocol URI (ytvlc://, ytmpv://, etc.) without navigating // away from YouTube. An anchor click bypasses YouTube's SPA router. function openProtocol(uri, errorMsg) { try { const a = document.createElement('a'); a.href = uri; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => { try { document.body.removeChild(a); } catch (_) {} }, 200); } catch (e) { if (errorMsg) showToast(errorMsg, '#ef4444', { duration: 5 }); } } // Show a persistent download progress bar anchored to the bottom of the page. function showDownloadProgress(id, token, audioOnly) { // Remove any existing progress panel for this download const panelId = 'ytkit-dl-progress-' + id; document.getElementById(panelId)?.remove(); const panel = document.createElement('div'); panel.id = panelId; panel.style.cssText = ` position:fixed;bottom:20px;right:20px;width:320px;background:#1a1a2e;border:1px solid #30363d; border-radius:12px;padding:14px 16px;z-index:2147483647;font-family:"Roboto",Arial,sans-serif; box-shadow:0 8px 32px rgba(0,0,0,0.5);color:#e6edf3;animation:ytkit-slide-in 0.3s ease-out; `; if (!document.getElementById('ytkit-dl-anim')) { const s = document.createElement('style'); s.id = 'ytkit-dl-anim'; s.textContent = ` @keyframes ytkit-slide-in{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}} #ytkit-dl-bar-fill{transition:width 0.4s ease} `; document.head.appendChild(s); } const header = document.createElement('div'); header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;'; const modeLabel = document.createElement('span'); modeLabel.style.cssText = 'font-size:12px;font-weight:600;color:#8b949e;letter-spacing:.05em;'; modeLabel.textContent = `${audioOnly ? 'AUDIO' : 'VIDEO'} DOWNLOAD`; header.appendChild(modeLabel); const closeBtn = document.createElement('button'); closeBtn.id = 'ytkit-dl-close-' + id; closeBtn.type = 'button'; closeBtn.style.cssText = 'background:none;border:none;color:#8b949e;cursor:pointer;font-size:16px;line-height:1;padding:0;'; closeBtn.setAttribute('aria-label', 'Close download progress'); closeBtn.textContent = 'x'; header.appendChild(closeBtn); const title = document.createElement('div'); title.id = 'ytkit-dl-title-' + id; title.style.cssText = 'font-size:13px;font-weight:500;margin-bottom:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#e6edf3;'; title.textContent = 'Starting...'; const barTrack = document.createElement('div'); barTrack.style.cssText = 'background:#30363d;border-radius:4px;height:6px;overflow:hidden;margin-bottom:8px;'; const barFill = document.createElement('div'); barFill.id = 'ytkit-dl-bar-fill'; barFill.style.cssText = 'height:100%;width:0%;background:linear-gradient(90deg,#22c55e,#16a34a);border-radius:4px;'; barTrack.appendChild(barFill); const stats = document.createElement('div'); stats.style.cssText = 'display:flex;justify-content:space-between;font-size:11px;color:#8b949e;'; const pct = document.createElement('span'); pct.id = 'ytkit-dl-pct-' + id; pct.textContent = '0%'; const speed = document.createElement('span'); speed.id = 'ytkit-dl-speed-' + id; const eta = document.createElement('span'); eta.id = 'ytkit-dl-eta-' + id; stats.appendChild(pct); stats.appendChild(speed); stats.appendChild(eta); panel.appendChild(header); panel.appendChild(title); panel.appendChild(barTrack); panel.appendChild(stats); document.body.appendChild(panel); document.getElementById('ytkit-dl-close-' + id)?.addEventListener('click', () => panel.remove()); let pollInterval = null; function poll() { GM_xmlhttpRequest({ method: 'GET', url: MediaDLManager.baseUrl() + '/status/' + id, headers: { 'X-Auth-Token': token }, timeout: 3000, onload: function(r) { let data; try { data = JSON.parse(r.responseText); } catch (_) { return; } const fill = document.getElementById('ytkit-dl-bar-fill'); const pct = document.getElementById('ytkit-dl-pct-' + id); const spd = document.getElementById('ytkit-dl-speed-' + id); const eta = document.getElementById('ytkit-dl-eta-' + id); const ttl = document.getElementById('ytkit-dl-title-' + id); if (!fill) { clearInterval(pollInterval); return; } if (data.title) ttl.textContent = data.title; const p = Math.min(data.progress || 0, 100); fill.style.width = p + '%'; pct.textContent = p.toFixed(1) + '%'; if (data.speed) spd.textContent = data.speed; if (data.eta) eta.textContent = 'ETA ' + data.eta; if (['pending', 'queued', 'paused', 'needs-auth'].includes(data.status)) { pct.textContent = data.status === 'needs-auth' ? 'Needs sign-in' : 'Waiting'; spd.textContent = ''; eta.textContent = data.error || 'Queued in Astra Downloader'; } if (data.status === 'done' || data.status === 'complete') { clearInterval(pollInterval); fill.style.width = '100%'; fill.style.background = 'linear-gradient(90deg,#22c55e,#16a34a)'; pct.textContent = '100%'; spd.textContent = ''; eta.textContent = 'Done!'; setTimeout(() => panel.remove(), 4000); } else if (data.status === 'skipped') { // Terminal since companion v1.8.0: yt-dlp exited // cleanly having written no file (every format past // the size limit, or no media on the page). Without // this branch the panel polled forever. clearInterval(pollInterval); fill.style.width = '0%'; fill.style.background = '#f59e0b'; pct.textContent = 'Skipped'; spd.textContent = ''; eta.textContent = data.error || 'Nothing was downloaded.'; setTimeout(() => panel.remove(), 8000); } else if (data.status === 'error' || data.status === 'failed' || data.status === 'cancelled') { clearInterval(pollInterval); fill.style.background = '#ef4444'; pct.textContent = data.status; spd.textContent = ''; eta.textContent = ''; setTimeout(() => panel.remove(), 5000); } }, onerror: function() { clearInterval(pollInterval); }, ontimeout: function() { clearInterval(pollInterval); } }); } pollInterval = setInterval(poll, 1000); poll(); } function _describeWebDownloaderUrl(value) { const raw = typeof value === 'string' ? value.trim() : ''; if (!raw) return { ok: false, error: 'missing' }; if (raw.length > 2048) return { ok: false, error: 'too-long' }; try { // Validate templates without allowing the placeholder to influence // URL parsing. It is replaced only with an encoded canonical URL. const parsed = new URL(raw.replaceAll('{url}', 'ytkit-video-url')); if (parsed.protocol !== 'https:') return { ok: false, error: 'https-required' }; if (parsed.username || parsed.password) return { ok: false, error: 'credentials-forbidden' }; return { ok: true, url: raw }; } catch (_) { return { ok: false, error: 'invalid-url' }; } } function _canonicalYouTubeWatchUrl(videoUrl) { const videoId = getVideoId(videoUrl); return videoId ? `https://www.youtube.com/watch?v=${videoId}` : null; } function _buildConfiguredWebDownloaderUrl(configuredUrl, videoUrl) { const described = _describeWebDownloaderUrl(configuredUrl); const canonicalUrl = _canonicalYouTubeWatchUrl(videoUrl); if (!described.ok || !canonicalUrl) return null; const encodedUrl = encodeURIComponent(canonicalUrl); const target = described.url.includes('{url}') ? described.url.replaceAll('{url}', encodedUrl) : `${described.url.replace(/#.*$/, '')}#${encodedUrl}`; try { const configuredOrigin = new URL(described.url.replaceAll('{url}', 'ytkit-video-url')).origin; const targetUrl = new URL(target); if (targetUrl.protocol !== 'https:' || targetUrl.username || targetUrl.password || targetUrl.origin !== configuredOrigin) return null; return targetUrl.href; } catch (_) { return null; } } // Optional userscript-only fallback. This is navigation to a page the // user configured, not a background API request. The canonical watch URL // is placed in the fragment unless the URL contains an explicit {url} // placeholder, so it is not sent to that page's server during navigation. function _webDownloadFallback(videoUrl) { const downloadUrl = _buildConfiguredWebDownloaderUrl(appState.settings?.cobaltUrl, videoUrl); if (!downloadUrl) { showToast('Configure an HTTPS web downloader URL in Settings to use this fallback.', '#f59e0b', { duration: 6 }); return false; } showToast('Opening your configured web downloader...', '#3b82f6', { duration: 4 }); openExternalWindow(downloadUrl); return true; } // ── MediaDL Server Manager ── // Caches server availability, provides install/status helpers, and auto-start logic. const USERSCRIPT_COMPANION_PORT_CATALOGUE = globalThis.YTKitCore?.companionPorts || null; // A cold start of the one-file companion exe takes ~12s; the old 4/5 // retry budget (~6-7.5s) timed out and told the user it was not // installed. Matches AUTO_START_RETRY_BUDGET in the extension. const AUTO_START_RETRY_BUDGET = 8; const MediaDLManager = { _status: null, // null = unknown, 'running', 'not-installed' _token: null, _lastCheck: 0, _serverVersion: null, _autoStartAttempted: false, _checkPromise: null, _CHECK_INTERVAL: 30000, // Re-check every 30s // Ports the companion may bind. The bundled catalogue is the source // of truth shared with the extension and Astra Downloader. // The single-port probe here previously meant downloads silently failed // whenever the server used a fallback port. _PORT_CANDIDATES: Object.freeze(USERSCRIPT_COMPANION_PORT_CATALOGUE?.ports?.slice() || []), _port: USERSCRIPT_COMPANION_PORT_CATALOGUE?.primaryPort || null, _SERVICE_ID: 'astra-downloader', // Last accepted /health payload. Only a response carrying the exact // service id authorizes an authenticated cookie handoff. _lastHealth: null, // Base URL for server calls — always reflects the discovered port. baseUrl() { return 'http://' + (USERSCRIPT_COMPANION_PORT_CATALOGUE?.host || '127.0.0.1') + ':' + this._port; }, // Identity gate: only trust a localhost response that proves it is the // Astra Downloader, not any random local service answering with a token. _isAstraDownloaderHealth(data) { if (!data || !data.token) return false; if (data.service === this._SERVICE_ID) return true; // Backward-compatible acceptance for hardened pre-service-id builds. return data.token_required === true && Number.isInteger(data.port); }, _probePort(port) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: 'http://' + (USERSCRIPT_COMPANION_PORT_CATALOGUE?.host || '127.0.0.1') + ':' + port + '/health', headers: { 'X-MDL-Client': 'MediaDL' }, timeout: 2000, onload: (res) => { try { const data = JSON.parse(res.responseText); if (this._isAstraDownloaderHealth(data)) { resolve(data); return; } // Something answered /health here but it is NOT // Astra Downloader — usually a stale/legacy // downloader squatting the port. Remember the // first one so the repair prompt can name it. if (data && (data.token || data.status === 'ok' || data.version) && !this._foreignServer) { this._foreignServer = { port, version: (data && data.version) || null }; DebugManager.log('MediaDL', `Port ${port} is occupied by a non-Astra downloader (v${this._foreignServer.version || '?'}); skipping`); } } catch (_) {} resolve(null); }, onerror: () => resolve(null), ontimeout: () => resolve(null) }); }); }, // GitHub Release URL for the compiled installer exe INSTALLER_URL: 'https://github.com/SysAdminDoc/AstraDownloader/releases/latest/download/AstraDownloader.exe', INSTALLER_FILE_NAME: 'AstraDownloader.exe', INSTALLER_RUN_HINT: 'Open Downloads and double-click the setup file to install.', // Quick health check — returns { ok, token, version, port } or { ok: false }. // Tries the cached port first, then probes the fallback list. async check(force) { const now = Date.now(); if (!force && this._status === 'running' && this._token && (now - this._lastCheck < this._CHECK_INTERVAL)) { return { ok: true, token: this._token, version: this._serverVersion, port: this._port }; } // Single-flight: concurrent callers share one in-flight probe sweep // instead of each launching their own 6-port storm. if (this._checkPromise) return this._checkPromise; this._checkPromise = this._checkImpl(force).finally(() => { this._checkPromise = null; }); return this._checkPromise; }, async _checkImpl(force) { const now = Date.now(); this._foreignServer = null; // fresh every sweep const order = [this._port, ...this._PORT_CANDIDATES.filter(p => p !== this._port)]; for (const port of order) { const data = await this._probePort(port); if (data) { this._port = port; this._status = 'running'; this._token = data.token; // Retained so the cookie handoff can require an exact // service id. A legacy {token_required, port} response is // good enough to download with, but it proves nothing // about who is listening, so it must not unlock cookies. this._lastHealth = data; this._serverVersion = data.version || null; this._lastCheck = now; DebugManager.log('MediaDL', `Server running on port ${port} (v${this._serverVersion || '?'}, ${data.downloads || 0} active)`); return { ok: true, token: data.token, version: this._serverVersion, port }; } } this._status = 'not-installed'; this._token = null; return { ok: false }; }, // Try to auto-start the server via mediadl:// protocol and wait for it. // Attempts the protocol launch once per page load, then polls health up to // `retries` times. If the protocol handler isn't registered, the browser // silently ignores it — no error dialog. async tryAutoStart(retries = AUTO_START_RETRY_BUDGET) { if (this._autoStartAttempted) { // Already tried this session — just do a single quick recheck return this.check(true); } this._autoStartAttempted = true; DebugManager.log('MediaDL', 'Attempting auto-start via mediadl:// protocol...'); showToast('Starting MediaDL server...', '#3b82f6', { duration: 4 }); openProtocol('mediadl://start'); // Poll for server readiness for (let i = 0; i < retries; i++) { await new Promise(r => setTimeout(r, 1500)); const result = await this.check(true); if (result.ok) { showToast('MediaDL server started!', '#22c55e', { duration: 2 }); return result; } } DebugManager.log('MediaDL', 'Auto-start failed — server did not respond'); return { ok: false }; }, // Reset auto-start flag so the next download re-attempts. // Called from the "Retry" button after user installs. resetAutoStart() { this._autoStartAttempted = false; this._status = null; this._foreignServer = null; }, // Deliberately always false. The extension copies a PowerShell fallback // command here; the userscript must NOT — its install flow is // download-the-release-exe. Pinned by the copy-paste install command // tests in tests/userscript-fixes.test.js, because piping a remote // script straight into a shell is an unsafe install path. // // Returning false is the whole contract, not a stub: every caller in the // bundled settings panel already branches to openExternalUrl(INSTALLER_URL) // when the copy fails, which is exactly the flow we want. Defining the // method at all is what stops the call throwing TypeError. async copyInstallCommand() { return false; }, async downloadInstaller() { try { // The monolith's triggerDownload takes (url, filename) only — // it has no showInFolder option, because a userscript cannot // reach chrome.downloads. The extension passes one; do not // copy that argument across. await triggerDownload(this.INSTALLER_URL, this.INSTALLER_FILE_NAME); return true; } catch (_) { // reason: anchor-click downloads fail silently on some // managers; runInstallAssist opens the URL instead. return false; } }, async runInstallAssist() { const copied = await this.copyInstallCommand(); const downloaded = await this.downloadInstaller(); if (!downloaded) { try { // openExternalWindow, not the extension's openExternalUrl — // the monolith's helper is synchronous and returns nothing. openExternalWindow(this.INSTALLER_URL); } catch (_) { // reason: popup blockers are an expected outcome here. } } // No "command was copied too" branch here — copyInstallCommand is // always false in the userscript by design (see above). showToast(`Setup file ready. ${this.INSTALLER_RUN_HINT}`, '#22c55e', { duration: 8 }); return { copied, downloaded }; }, get isRunning() { return this._status === 'running'; }, get token() { return this._token; }, // Show install / retry prompt panel. // Two modes: // 'install' — user has never installed MediaDL (default) // 'retry' — auto-start failed, might just need a kick showInstallPrompt(mode) { const existing = document.getElementById('ytkit-mediadl-install-prompt'); if (existing) existing.remove(); // replace with fresh state const isRetryMode = mode === 'retry'; const prompt = document.createElement('div'); prompt.id = 'ytkit-mediadl-install-prompt'; prompt.style.cssText = ` position:fixed;bottom:80px;right:20px;width:380px;background:#1a1a2e; border:1px solid #30363d;border-radius:12px;padding:18px;z-index:2147483647; font-family:"Roboto",Arial,sans-serif;box-shadow:0 8px 32px rgba(0,0,0,0.5); color:#e6edf3;animation:ytkit-slide-in 0.3s ease-out; `; // ── Header ── const header = document.createElement('div'); header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;'; const titleEl = document.createElement('span'); titleEl.style.cssText = 'font-size:14px;font-weight:600;color:#22c55e;'; titleEl.textContent = isRetryMode ? 'MediaDL Server Not Responding' : 'Upgrade Your Downloads'; const closeBtn = document.createElement('button'); closeBtn.style.cssText = 'background:none;border:none;color:#8b949e;cursor:pointer;font-size:16px;padding:0;line-height:1;'; closeBtn.textContent = '\u2715'; closeBtn.onclick = () => prompt.remove(); header.appendChild(titleEl); header.appendChild(closeBtn); // ── Description ── const desc = document.createElement('p'); desc.style.cssText = 'font-size:13px;color:#8b949e;margin:0 0 14px;line-height:1.5;'; desc.textContent = isRetryMode ? 'The server didn\'t start. It may not be installed yet, or the scheduled task stopped. Choose an option below:' : 'Install MediaDL for 1080p+ downloads with automatic video+audio merging, background downloads, and progress tracking.'; const foreign = this._foreignServer; if (isRetryMode && foreign && foreign.port) { desc.textContent = `Another program is answering on Astra Downloader's port ${foreign.port}` + (foreign.version ? ` (reporting version ${foreign.version})` : '') + `. It is usually a leftover downloader from an earlier install. Close it, ` + `remove it from Startup apps (look for "YTYT-Downloader" or ` + `"Astra Deck Downloader"), then start Astra Downloader and try again.`; } // ── Buttons ── const btnCol = document.createElement('div'); btnCol.style.cssText = 'display:flex;flex-direction:column;gap:8px;'; // Button helper const makeBtn = (text, bg, border, onClick) => { const b = document.createElement('button'); b.style.cssText = `width:100%;padding:9px 14px;border-radius:8px;border:${border || 'none'};background:${bg};color:${bg === 'transparent' ? '#8b949e' : 'white'};font-size:13px;font-weight:500;cursor:pointer;transition:background 0.2s;text-align:left;display:flex;align-items:center;gap:10px;`; const label = document.createElement('span'); label.textContent = text; b.appendChild(label); b.onclick = onClick; return b; }; // 1. Retry / Start Server if (isRetryMode) { const retryBtn = makeBtn('Try Starting Server Again', '#3b82f6', 'none', async () => { retryBtn.querySelector('span').textContent = 'Starting...'; retryBtn.style.opacity = '0.7'; retryBtn.style.pointerEvents = 'none'; this.resetAutoStart(); const result = await this.tryAutoStart(AUTO_START_RETRY_BUDGET); if (result.ok) { showToast('MediaDL server is running!', '#22c55e', { duration: 3 }); prompt.remove(); } else { retryBtn.querySelector('span').textContent = 'Still not responding — try installing below'; retryBtn.style.opacity = '1'; retryBtn.style.pointerEvents = 'auto'; retryBtn.style.background = '#ef4444'; } }); btnCol.appendChild(retryBtn); } // 2. Download Astra Downloader (.exe) - GitHub Releases install flow const dlBtn = makeBtn('Download Astra Downloader (.exe)', '#22c55e', 'none', () => { triggerDownload(this.INSTALLER_URL, this.INSTALLER_FILE_NAME).catch(() => { openExternalWindow(this.INSTALLER_URL); }); dlBtn.querySelector('span').textContent = 'Downloading\u2026 open the file to install'; dlBtn.style.background = '#16a34a'; showToast('Astra Downloader setup is downloading \u2014 open the file to install, then check again below.', '#22c55e', { duration: 8 }); }); btnCol.appendChild(dlBtn); // 3. "I just installed it" — re-check const recheckBtn = makeBtn('I just installed it \u2014 check again', 'transparent', '1px solid #30363d', async () => { recheckBtn.querySelector('span').textContent = 'Checking...'; this.resetAutoStart(); const result = await this.tryAutoStart(AUTO_START_RETRY_BUDGET); if (result.ok) { showToast('MediaDL is ready! Downloads will now use 1080p+ quality.', '#22c55e', { duration: 4 }); prompt.remove(); } else { recheckBtn.querySelector('span').textContent = 'Not detected \u2014 make sure the installer completed'; setTimeout(() => { recheckBtn.querySelector('span').textContent = 'I just installed it \u2014 check again'; }, 4000); } }); btnCol.appendChild(recheckBtn); // 4. Dismiss if (!isRetryMode) { const dismissBtn = makeBtn('Not now', 'transparent', 'none', () => { prompt.remove(); GM_setValue('ytkit_mediadl_prompt_dismissed', true); }); dismissBtn.style.cssText += 'padding:6px 14px;font-size:12px;color:#6b7280;justify-content:center;'; btnCol.appendChild(dismissBtn); } prompt.appendChild(header); prompt.appendChild(desc); prompt.appendChild(btnCol); document.body.appendChild(prompt); // Auto-dismiss after 30s (install mode only) if (!isRetryMode) { setTimeout(() => { if (prompt.parentNode) prompt.remove(); }, 30000); } } }; // Legacy wrapper — still used by autoStart retry logic function mediaDLDownload(videoUrl, audioOnly) { DebugManager.log('MediaDL', `Download requested (legacy): ${videoUrl} (audio=${audioOnly})`); ytKitDownload(videoUrl, audioOnly); } // v3.20.3: explicit cookie-jar wire contract. // Mirrors normalizeCookieExpiry() in extension/ytkit.js + extension/background.js. // Session cookie → 0 // Persistent cookie → positive Number, seconds since epoch // Anything else → 0 (treat null/NaN/negative/string/Infinity as session) function normalizeCookieExpiry(value) { const num = Number(value); return Number.isFinite(num) && num > 0 ? num : 0; } // Extract streaming URLs from YouTube's player response for direct download. // This bypasses cookie/auth issues entirely - the URLs contain embedded auth signatures. // Uses multi-method approach: inline script parsing (fast) -> Innertube API (SPA-safe). async function _extractStreamingData(audioOnly) { // Method 1: Parse from inline