diff --git a/.gitignore b/.gitignore index 54d3d0a..c5951ab 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,14 @@ package-lock.json .DS_Store Thumbs.db *~ + +# Local diagnostics (user-saved console copies) +log.txt +Log.txt +Error.txt + +# Local working docs / tooling — not part of the extension (kept out of PRs) +.agents/ +ReviewPrompt.txt +knowledge.md +Audit.md diff --git a/README.en.md b/README.en.md index c7fa92a..d1a7120 100644 --- a/README.en.md +++ b/README.en.md @@ -10,8 +10,9 @@ No `yt-dlp`, no third‑party sites or servers: everything runs locally in your ## Features -- **Video** — 720p / 1080p as `.mp4` (video + audio). -- **Audio** — `.mp3` (audio track only). +- **Video** — 720p / 1080p / 1440p / 2160p as `.mp4` (video + audio; 1440p and 2160p are + shown only when the video actually supports them). +- **Audio** — `.mp3` (audio track only; bitrate 192/320 kbps, selectable). - **Clip selection** — "start — end" fields in the menu (default `0:00:00` … full length). **Only the selected range is fetched**, not the whole video: e.g. 10 seconds out of an hour-long video download in a couple of seconds. @@ -26,6 +27,10 @@ No `yt-dlp`, no third‑party sites or servers: everything runs locally in your whatever it displays is what gets saved. - **Video format** — "Fast" (VP9 in mp4, no re‑encoding, seconds) or "H.264" (re‑encode for compatibility with older players, slow). +- **Download in parts** — long videos can be saved in parts (~15 minutes per file): the + "По частям" toggle in the menu, or an adaptive warning that offers splitting when the + estimated capture needs too much memory (resolution, duration and available RAM are + taken into account). - **Auto‑disables Autoplay** — the extension turns off YouTube's "Autoplay next" so the next video won't start on its own. @@ -50,10 +55,13 @@ No `yt-dlp`, no third‑party sites or servers: everything runs locally in your 1. Click the ▽ button in the player to open the menu. 2. Optionally set the **start** and **end** of a clip (defaults to the whole video). 3. Choose what to download: - - **Video** → `1080p` or `720p`; + - **Video** → `2160p`, `1440p`, `1080p` or `720p` (whichever are available); - **Audio** → `MP3`; - **Subtitles** → `.txt`. -4. For video you can switch the **Format**: "Fast" (default) or "H.264". +4. For video you can switch the **Format**: "Fast" (default) or "H.264", and enable + **"По частям"** — long videos are then saved in ~15-minute parts (files named + "… (part 1 of N)"). When a capture is estimated to need too much memory, the extension + itself offers downloading by parts. 5. Progress is shown in a toast; the finished file is saved via the browser's normal download. ## How it works @@ -78,6 +86,11 @@ extension hooks in where the data has already been decrypted and split into trac - Capture works by seeking through the buffer, so for very long videos it takes time proportional to the length. +- 1440p and 2160p are offered only when the video supports them. Files at those resolutions + are huge: capture and muxing need lots of memory and time, and long 4K videos may hit the + capture limits — prefer downloading fragments for 4K. +- For long videos use **"По частям"**: each part is captured separately, so neither the + capture time limit nor memory accumulation is hit. - "H.264" and `.mp3` re‑encode via `ffmpeg.wasm` (single‑threaded), which is noticeably slower than the fast remux — up to a few minutes on long videos. - In "Fast" mode the `.mp4` contains VP9/Opus codecs — it plays in Chrome, VLC and modern diff --git a/README.md b/README.md index 6c5755b..1e4ff5e 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ ## Возможности -- **Видео** — 720p / 1080p в `.mp4` (видео + звук). -- **Аудио** — `.mp3` (только звуковая дорожка). +- **Видео** — 720p / 1080p / 1440p / 2160p в `.mp4` (видео + звук; 1440p и 2160p + показываются, только если доступны у ролика). +- **Аудио** — `.mp3` (только звуковая дорожка; битрейт 192/320 kbps на выбор). - **Выбор фрагмента** — поля «начало — конец» в меню (по умолчанию `0:00:00` … полная длина ролика). Загружается **только выбранный отрезок**, а не всё видео целиком: например, 10 секунд из середины часового ролика скачиваются за пару секунд. @@ -28,6 +29,10 @@ сохраняется тот, который показан. - **Формат видео** — «Быстро» (VP9 в mp4 без перекодирования, секунды) или «H.264» (перекодирование для совместимости со старыми плеерами, медленно). +- **Скачивание по частям** — длинные ролики можно сохранять частями (~15 минут на файл): + переключатель «По частям» в меню, либо адаптивное предупреждение, которое само + предложит разбиение, если оценка захвата требует слишком много памяти (учитываются + разрешение, длительность и доступная RAM). - **Авто-отключение автовоспроизведения** — расширение само выключает «Автовоспроизведение» YouTube, чтобы следующий ролик не запускался сам. @@ -53,10 +58,13 @@ 1. Нажмите кнопку ▽ в плеере — откроется меню. 2. При необходимости задайте **начало** и **конец** фрагмента (по умолчанию — весь ролик). 3. Выберите, что скачать: - - **Видео** → `1080p` или `720p`; + - **Видео** → `2160p`, `1440p`, `1080p` или `720p` (доступные варианты); - **Аудио** → `MP3`; - **Субтитры** → `.txt`. -4. Для видео можно переключить **Формат**: «Быстро» (по умолчанию) или «H.264». +4. Для видео можно переключить **Формат**: «Быстро» (по умолчанию) или «H.264», а также + включить **«По частям»** — тогда длинный ролик сохранится частями по ~15 минут + (файлы «… (part 1 of N)»). Если оценка захвата требует много памяти, расширение само + предложит скачать по частям. 5. Прогресс отображается во всплывающем окне; готовый файл сохраняется через стандартную загрузку браузера. @@ -82,6 +90,11 @@ YouTube в вебе раздаёт HD не одним файлом, а по пр - Захват идёт через перемотку буфера, поэтому для очень длинных роликов занимает время, пропорциональное длине. +- Разрешения 1440p/2160p доступны, только если ролик их поддерживает. Файлы таких + разрешений очень велики: захват и муксинг требуют много памяти и времени, а длинные + 4K-ролики могут упираться в лимиты захвата — для 4K лучше скачивать фрагменты. +- Для длинных роликов используйте **«По частям»**: каждая часть захватывается отдельно, + поэтому не упирается ни в лимит времени захвата, ни в накопление памяти. - Режим «H.264» и `.mp3` перекодируют средствами `ffmpeg.wasm` (однопоточный) — это заметно медленнее быстрой склейки, вплоть до нескольких минут на длинных видео. - В режиме «Быстро» файл `.mp4` содержит кодеки VP9/Opus — он открывается в Chrome, VLC и diff --git a/extension/background.js b/extension/background.js index c9ec559..071218b 100644 --- a/extension/background.js +++ b/extension/background.js @@ -28,11 +28,54 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { return true; // async } + if (msg.t === 'ytdl-mem') { + // Adaptive capture-size warning needs real available RAM. chrome.system.* is not + // available in content scripts, so the UI asks here. Bytes → MB. + try { + chrome.system.memory.getInfo((info) => { + sendResponse({ + ok: true, + capacity: Math.round(info.capacity / 1048576), + free: Math.round(info.availableCapacity / 1048576), + }); + }); + return true; // async + } catch (e) { + sendResponse({ ok: false, error: String(e) }); + } + } + if (msg.t === 'ytdl-save') { - // Offscreen finished muxing and handed us a blob URL to save. + // Offscreen finished muxing and handed us a blob URL to save. downloads.download + // resolves when the download STARTS; the blob must stay alive until the browser + // process finishes reading it, so we revoke it on completion (or on failure, or a + // generous fallback) instead of on a fixed timer. + const isBlob = /^blob:/.test(msg.url); chrome.downloads.download({ url: msg.url, filename: msg.filename, saveAs: false }) - .then((id) => sendResponse({ ok: true, id })) - .catch((e) => sendResponse({ ok: false, error: String(e) })); + .then((id) => { + if (isBlob) { + let done = false; + function revoke() { + if (done) return; + done = true; + chrome.downloads.onChanged.removeListener(onChanged); + chrome.runtime.sendMessage({ t: 'ytdl-revoke', url: msg.url }).catch(() => {}); + } + function onChanged(delta) { + if (delta.id !== id) return; + if (delta.state && (delta.state.current === 'complete' || delta.state.current === 'interrupted')) { + revoke(); + } + } + chrome.downloads.onChanged.addListener(onChanged); + setTimeout(revoke, 10 * 60 * 1000); // belt-and-braces if onChanged never fires + } + sendResponse({ ok: true, id }); + }) + .catch((e) => { + if (isBlob) chrome.runtime.sendMessage({ t: 'ytdl-revoke', url: msg.url }).catch(() => {}); + sendResponse({ ok: false, error: String(e) }); + }); return true; // async } }); diff --git a/extension/content_hook.js b/extension/content_hook.js index f5c1a56..a180571 100644 --- a/extension/content_hook.js +++ b/extension/content_hook.js @@ -26,11 +26,20 @@ // does NOT re-init audio) — so we remember them and seed a track that starts // receiving media mid-capture without a fresh init of its own. lastInit: Object.create(null), // kind -> { bytes: Uint8Array, mime: string } + sb: Object.create(null), // kind -> latest SourceBuffer (for per-track buffered edges) + restarts: Object.create(null), // kind -> mid-capture re-init count (track was CUT) }; function vidId() { try { return new URLSearchParams(location.search).get('v'); } catch (e) { return null; } } function resetTracks() { store.tracks = Object.create(null); } + // Verbose diagnostics are OFF by default. Flip DEBUG to true when debugging + // quality/capture issues — the logs show the actual served resolution, mid-capture + // re-inits, and the menu selection result on a live player (they were the only way + // to diagnose the external "YouTube Auto HD + FPS" conflict, see knowledge.md). + const DEBUG = false; + const dbg = (...a) => { if (DEBUG) console.log('[YTDL]', ...a); }; + // ---- steer the player away from AV1 ------------------------------------- // The bundled ffmpeg core can decode VP9/Opus but NOT AV1. YouTube only picks // AV1 when the page reports it as decodable, so — before the player probes — @@ -64,6 +73,14 @@ if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); return null; } + // Byte-identical init segments mean the player re-appended the SAME stream (e.g. a + // buffer-eviction recovery) — the media before and after is contiguous and the same + // codec, so the track can be glued. A different init means a real quality switch. + function sameBytes(a, b) { + if (!a || !b || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; + } // Does this appended chunk begin a fresh track file? A valid concatenation must // start at the init segment, so we only begin recording a track from the chunk @@ -81,6 +98,11 @@ try { sb.__ytdlMime = mime; sb.__ytdlKind = /audio/i.test(mime) ? 'audio' : (/video/i.test(mime) ? 'video' : null); + // Remember the LATEST SourceBuffer per kind so the capture loop can read the + // track's OWN buffered edge. The element's v.buffered is the UNION across tracks, + // which lies when audio buffers ahead of video (high bitrates) — the union edge + // would then "complete" a capture whose video track is still short. + if (sb.__ytdlKind) store.sb[sb.__ytdlKind] = sb; } catch (e) {} return sb; }; @@ -96,17 +118,50 @@ // Always remember the latest init (ungated) — it usually only arrives at load. if (init) store.lastInit[kind] = { bytes: u8.slice(), mime: this.__ytdlMime || '' }; if (store.capturing) { - let t = store.tracks[kind]; - if (!t) { - if (init) { - t = store.tracks[kind] = { mime: this.__ytdlMime || '', parts: [u8.slice()] }; + if (init) { + const t = store.tracks[kind]; + if (t && t.parts.length) { + // A fresh init mid-capture means the player cleared its buffer and + // restarted (remove() + new init). This is usually a buffer-eviction + // recovery at the SAME quality (the quality is never re-applied during + // capture), NOT a quality switch. When the new init is byte-identical + // to the track's own init, the stream before and after the reset is the + // same codec and contiguous in time — so we DROP the redundant init and + // keep appending, and the captured file stays whole (a seam, not a cut). + // A DIFFERENT init means a real quality switch: the streams can't be + // glued, so the track starts over (CUT) and it is counted so the result + // is honestly reported as incomplete. + if (sameBytes(u8, t.initBytes)) { + t.seams = (t.seams || 0) + 1; + dbg('capture re-init', kind, 'same-stream — glued (seam)', t.seams, 'bytes', totalCaptured()); + } else { + // A DIFFERENT-stream re-init (real quality switch). At the very START + // of the capture it is usually the tail of OUR OWN target-quality + // switch still settling — the replacement track re-covers the whole + // range, so it must NOT be counted as a cut (it produced a false + // "файл может быть обрезан" message on complete files). Only a + // re-init well into the capture actually shortens the file. + const nearStart = Math.abs((store.cursor || 0) - (store.capStart || 0)) < 2; + if (!nearStart) store.restarts[kind] = (store.restarts[kind] || 0) + 1; + store.tracks[kind] = { mime: this.__ytdlMime || '', parts: [u8.slice()], initBytes: u8.slice() }; + dbg('capture re-init', kind, nearStart ? 'start-of-capture — replaced, not counted' : ('DIFFERENT stream — track cut (restart) ' + (store.restarts[kind] || 1))); + } + } else { + store.tracks[kind] = { mime: this.__ytdlMime || '', parts: [u8.slice()], initBytes: u8.slice() }; + } + } else { + const t = store.tracks[kind]; + if (t) { + t.parts.push(u8.slice()); } else if (store.lastInit[kind]) { // media arrived without a fresh init → seed the track with the stored init - t = store.tracks[kind] = { mime: store.lastInit[kind].mime, parts: [store.lastInit[kind].bytes, u8.slice()] }; + store.tracks[kind] = { + mime: store.lastInit[kind].mime, + parts: [store.lastInit[kind].bytes, u8.slice()], + initBytes: store.lastInit[kind].bytes, + }; } // else: no init available yet — skip until one appears - } else { - t.parts.push(u8.slice()); } } } @@ -124,16 +179,33 @@ return { bytes: out, mime: t.mime }; } + // Total captured bytes across both tracks — a memory safety valve so a forged or + // pathological capture can't make the page (and its offscreen copy) accumulate + // unbounded data. Breaking on this leaves `complete` false, which the UI surfaces. + const BYTE_CAP = 4 * 1024 * 1024 * 1024; // ~4 GB + function totalCaptured() { + let n = 0; + for (const kind of ['video', 'audio']) { + const t = store.tracks[kind]; + if (t) for (const p of t.parts) n += p.length; + } + return n; + } + // ---- player helpers ------------------------------------------------------ function player() { return document.getElementById('movie_player'); } function video() { return document.querySelector('video'); } - const Q = { 1080: 'hd1080', 720: 'hd720' }; + // quality name per capture height — YouTube's setPlaybackQuality keys + const Q = { 2160: 'hd2160', 1440: 'hd1440', 1080: 'hd1080', 720: 'hd720' }; const sleep = (ms) => new Promise(r => setTimeout(r, ms)); - function setQualityRaw(q) { - const p = player(); - try { p.setPlaybackQualityRange && p.setPlaybackQualityRange(q, q); } catch (e) {} - try { p.setPlaybackQuality && p.setPlaybackQuality(q); } catch (e) {} + // The LOWEST quality available in the player — the pre-capture flush target. (The JS + // quality API — both setPlaybackQualityRange and setPlaybackQuality — is IGNORED by + // the SABR player for switching quality, which is why downloads used to require setting + // the resolution by hand; the native settings menu is the ONLY reliable switch.) + function lowestAvailableHeight() { + const hs = availableHeights(); + return hs.length ? Math.min.apply(null, hs) : 0; } function availableHeights() { try { @@ -141,6 +213,139 @@ return (player().getAvailableQualityLevels() || []).map(l => map[l]).filter(Boolean); } catch (e) { return []; } } + + // Expected minimum decoded height per requested quality — the lower bound of the + // settled band used by forceQuality (the upper bound is RES_NAME + 150). The ABR + // player can silently serve a LOWER resolution than requested, so we verify the + // actually decoded videoHeight and re-apply until it sticks (see forceQuality). + const RES_H = { hd2160: 2000, hd1440: 1300, hd1080: 1000, hd720: 700, medium: 300, small: 200, tiny: 100 }; + // Actual resolution height per quality key — what the native menu labels items with + // ("1440p"/"2160p"). menuSetQuality matches menu text against THIS, not RES_H (a + // verification threshold). + const RES_NAME = { hd2160: 2160, hd1440: 1440, hd1080: 1080, hd720: 720, large: 480, medium: 360, small: 240, tiny: 144 }; + function servedHeight() { try { return video().videoHeight || 0; } catch (e) { return 0; } } + + // Force the quality the way the user does it: through the native settings menu — the + // ONLY reliable quality switcher in the SABR player (the JS API, fixed or range, is + // ignored for switching: users had to set the resolution by hand for downloads to + // work). `sel` is a numeric height (e.g. 1440) or the string 'auto' (re-selects + // "Автоматически"). Best-effort: returns true when the target was selected, false when + // the menu wasn't reachable. NO state toggles here — we only ever open the menu, pick a + // quality, and close it (the user's layout is left exactly as it was). + function menuItemLabel(it) { + try { + const l = it.querySelector('.ytp-menuitem-label') || it.querySelector('.ytp-menuitem-title') || it; + return (l.textContent || '').trim(); + } catch (e) { return ''; } + } + async function menuSetQuality(sel) { + const gear = document.querySelector('.ytp-settings-button'); + if (!gear) { dbg('menuSetQuality: no gear button'); return false; } + const isOpen = () => { + try { + const m = document.querySelector('.ytp-settings-menu'); + return !!(m && (m.offsetParent !== null || m.getClientRects().length)); + } catch (e) { return false; } + }; + // Only VISIBLE items: hidden submenu panels stay in the DOM, and clicking a hidden + // item is a no-op. getClientRects() returns nothing for display:none/hidden elements. + const items = () => [...document.querySelectorAll('.ytp-settings-menu .ytp-menuitem')] + .filter(it => { try { return it.getClientRects().length > 0; } catch (e) { return false; } }); + // The user's player UI must be left exactly as it was, so EVERY exit path — including + // failures — closes the settings menu instead of leaving it open over the player. + const closeIfOpen = () => { try { if (isOpen()) gear.click(); } catch (e) {} }; + try { + if (isOpen()) { gear.click(); await sleep(250); } + gear.click(); // open the settings menu + for (let i = 0; i < 20 && !items().length; i++) await sleep(150); + const qItem = items().find(it => /качеств|quality/i.test(menuItemLabel(it))); + if (!qItem) { closeIfOpen(); dbg('menuSetQuality: no quality entry'); return false; } + qItem.click(); + let qItems = []; + for (let i = 0; i < 25; i++) { + qItems = items().filter(it => /^\d{3,4}p/i.test(menuItemLabel(it)) || /автоматически|auto/i.test(menuItemLabel(it))); + if (qItems.length) break; + await sleep(150); + } + if (!qItems.length) { closeIfOpen(); dbg('menuSetQuality: no visible quality items'); return false; } + if (sel === 'auto') { + const autoItem = qItems.find(it => /автоматически|auto/i.test(menuItemLabel(it))); + if (!autoItem) { closeIfOpen(); dbg('menuSetQuality: no auto entry'); return false; } + autoItem.click(); + await sleep(250); + closeIfOpen(); + dbg('menuSetQuality: selected auto'); + return true; + } + // Prefer the PLAIN entry ("1080p") over "1080p Premium" — the Premium variant is + // subscription-gated and selecting it when unavailable fails — and over + // "1080p60". Accept any variant that starts with the target height (labels + // normalize to digits: "1080p60" → "108060"). Premium is only a last resort + // (subscribers with no plain entry at that height). + const norm = (t) => String(t).replace(/[^0-9]/g, ''); + const label = (it) => menuItemLabel(it); + const plain = qItems.filter(it => !/premium|премиум/i.test(label(it))); + const exact = plain.filter(it => norm(label(it)) === String(sel))[0]; + const target = exact + || plain.filter(it => norm(label(it)).startsWith(String(sel)))[0] + || qItems.filter(it => norm(label(it)) === String(sel))[0] + || qItems.filter(it => norm(label(it)).startsWith(String(sel)))[0]; + if (!target) { closeIfOpen(); dbg('menuSetQuality: no entry for', sel, qItems.map(menuItemLabel)); return false; } + target.click(); + await sleep(250); + closeIfOpen(); // the menu may auto-close on selection; close it if it didn't + dbg('menuSetQuality: selected', sel); + return true; + } catch (e) { closeIfOpen(); dbg('menuSetQuality exception:', e); return false; } + } + // Select the requested quality via the NATIVE settings menu — the only reliable quality + // switcher in the SABR player (the JS API, fixed or range, is ignored for switching: + // that is why downloads used to require setting the resolution by hand) — and confirm + // the player actually serves it, ALL before recording starts (any switch during capture + // re-inits the SourceBuffer and cuts the track; same-stream re-inits are glued as seams). + // "Settled" means the decoded frame height is inside the band [wantH, wantRes + 150]: + // waiting on BOTH sides — the height must RISE for an upgrade AND FALL for a downgrade — + // so the switch has fully completed before recording. Video targets that can't be + // confirmed within ~12 s abort with a clear error (an honest failure beats a mislabelled + // file); mp3 is best-effort (only audio is used — the video is just capped to save RAM). + async function forceQuality(wantRes, wantH, needVideo) { + const qBefore = (() => { try { return player().getPlaybackQuality(); } catch (e) { return '?'; } })(); + const settled = () => { + const h = servedHeight(); + return h >= wantH && h <= wantRes + 150; + }; + if (!await menuSetQuality(wantRes)) { + await sleep(400); + if (!await menuSetQuality(wantRes)) { + dbg('quality: menu unreachable — cannot switch'); + if (needVideo && !settled()) { + throw new Error('не удалось переключить плеер на ' + wantRes + 'p — меню качества недоступно, установите ' + wantRes + 'p вручную и повторите'); + } + return; + } + } + const iter = needVideo ? 60 : 25; + for (let i = 0; i < iter && !settled(); i++) { + if (i === Math.floor(iter / 2)) await menuSetQuality(wantRes); // re-select mid-way, still pre-recording + await sleep(200); + } + dbg('quality', { before: qBefore, after: (() => { try { return player().getPlaybackQuality(); } catch (e) { return '?'; } })(), served: servedHeight() }); + if (needVideo && !settled()) { + throw new Error('не удалось переключить плеер на ' + wantRes + 'p (плеер отдаёт ' + + (servedHeight() || '?') + 'p) — установите ' + wantRes + 'p вручную в плеере и повторите'); + } + } + // Restore the user's pre-download quality after the capture (the capture left the + // player at the requested resolution). Best-effort through the native menu — the only + // reliable switch; 'auto' re-selects "Автоматически". Never throws. + async function restoreQuality(prevKey) { + if (!prevKey || prevKey === '?' || prevKey === 'null') return; + try { + if (/^auto/i.test(prevKey)) { await menuSetQuality('auto'); return; } + const h = RES_NAME[prevKey]; + if (h) await menuSetQuality(h); + } catch (e) { dbg('restoreQuality:', e); } + } // Seek via the player API, which also updates YouTube's app-level streaming // position — plain v.currentTime only moves the element, so the player would // keep feeding segments from wherever the user left the scrubber. @@ -175,8 +380,7 @@ // (never triggering the end / autoplay-next) and just wait for the buffer to // cover the whole duration. Capture aborts if the page navigates to another video. async function playthrough(opts, onProgress) { - const targetQ = opts.targetQ; // e.g. 'hd1080' / 'small' - const preQ = opts.preQ; // a DIFFERENT low quality, to force a fresh init + const targetQ = opts.targetQ; // e.g. 'hd1080' / 'medium' (mp3) const needVideo = opts.needVideo !== false; // mp3 only needs audio const v = video(); const dur = v.duration; @@ -184,33 +388,90 @@ const capEnd = Math.min(opts.end && opts.end > 0 ? opts.end : dur, dur); const capStart = Math.max(0, Math.min(opts.start || 0, Math.max(0, capEnd - 1))); const capId = vidId(); + // Verification band for the served height: [wantH, wantRes + 150]. ALL qualities go + // through the native menu (the JS API cannot switch quality in the SABR player). + const wantH = RES_H[targetQ] || 0; // lower bound of the served-height band + const wantRes = RES_NAME[targetQ] || 0; // requested height — the band centre / menu label const prev = { paused: v.paused, rate: v.playbackRate, time: v.currentTime, muted: v.muted }; + // Remember the user's current quality so it can be restored when the capture ends + // (the capture switches the player to the requested resolution). + const prevKey = (() => { try { return player().getPlaybackQuality(); } catch (e) { return null; } })(); keepAutoplayOff(); try { v.muted = true; } catch (e) {} try { v.pause(); } catch (e) {} - // Order matters: - // 1) switch to a low quality and seek to a position clearly DIFFERENT from - // capStart, so that seeking to capStart afterwards is a real jump. That jump - // forces BOTH tracks to re-fetch — important because the audio itag is the - // same Opus at every quality, so a quality switch alone won't re-init audio. - // 2) start recording, switch to the target quality, then seek to capStart. - // Capture begins at the requested fragment — not at the start of the video. - const preSeek = capStart > 10 ? 0 : Math.min(35, Math.max(1, dur - 5)); - setQualityRaw(preQ); - await sleep(500); - seekVia(preSeek); - await sleep(700); + // Order matters — every quality change happens through the native settings menu, the + // only reliable switcher in the SABR player (the JS API, fixed or range, is ignored): + // 1) FLUSH: switch to the LOWEST available quality. That forces the player to + // re-fetch fresh segments at a DIFFERENT itag, evicting any stale buffer that + // could cover capStart — without it, a video already playing at the requested + // quality would keep its buffer, the later seek to capStart would not be a real + // jump, and the capture would never see a fresh init ("не удалось захватить + // аудио"). Best-effort: if the menu can't be driven we continue anyway. + // 2) seek to a position clearly DIFFERENT from capStart (a real jump also re-inits + // AUDIO, whose itag is the same Opus at every quality — only a position change + // re-fetches it), then select the target quality and VERIFY the player actually + // serves it — all while recording is still OFF — and only then start recording + // and seek to capStart. Capture begins at the requested fragment, not the video's + // start. + const preSeek = capStart > 10 ? (dur - 5 > 10 ? dur - 5 : 0) : Math.min(35, Math.max(1, dur - 5)); + try { + // The flush (switch to the lowest quality) is only needed when the player is ALREADY + // at the requested quality — then the target switch below would be a no-op and the + // stale buffer would survive. When the current quality differs, the target switch + // itself re-fetches at a different itag and evicts the old buffer, so the flush + // dance is skipped (it visibly delayed every download). + const flushNeeded = !prevKey || prevKey === '?' || prevKey === targetQ; + const lowH = flushNeeded ? lowestAvailableHeight() : 0; + if (lowH) { + try { await menuSetQuality(lowH); } catch (e) {} + await sleep(500); // let the flush switch start (recording is still OFF) + } + seekVia(preSeek); + await sleep(700); + + // Select the requested quality BEFORE recording anything and verify the player + // actually serves it — via the native settings menu (the same path the user clicks + // manually). From here on the quality is NEVER touched again — any switch + // mid-recording re-inits the SourceBuffer and CUTS the recorded track (same-stream + // re-inits are glued as seams; different-stream ones are counted as restarts and + // reported as partial). forceQuality throws a clear error when the requested + // quality can't be confirmed — a capture that starts at the wrong resolution would + // produce a mislabelled file, which is worse than an honest failure. + await forceQuality(wantRes, wantH, needVideo); + } catch (e) { + // Restore the player state on a PRE-recording failure (the capture loop below has + // its own finally): an unconfirmed quality must not leave the player muted or stuck + // at a different position. The error propagates to the bridge as a clear message. + try { v.playbackRate = prev.rate; } catch (err) {} + seekVia(prev.time); + try { v.muted = prev.muted; } catch (err) {} + await restoreQuality(prevKey); // the flush already changed the quality + if (!prev.paused) { try { v.play(); } catch (err) {} } + throw e; + } + resetTracks(); + // NOTE: store.sb is NOT reset here — the SourceBuffers were created when the player + // loaded and the addSourceBuffer patch already registered the current ones. Wiping + // them would blind trackEdge() and the per-track capture loop would fall back to the + // union edge (the very freeze bug we're fixing). + store.restarts = Object.create(null); + // Capture context for the appendBuffer patch: a re-init arriving within ~2 s of the + // start is our own quality switch settling (harmless), later ones are real cuts. + store.capStart = capStart; + store.capEnd = capEnd; + store.cursor = capStart; store.capturing = true; - setQualityRaw(targetQ); seekVia(capStart); await sleep(500); - // wait until the tracks we need have their init before entering the capture loop + // Wait until both tracks we need start appending (their init arrives). From here on + // the quality is NEVER touched again — a switch mid-recording would re-init and cut + // the track short, which is why any such restart is tracked and reported as partial. const haveInits = () => store.tracks.audio && (!needVideo || store.tracks.video); - for (let i = 0; i < 40 && !haveInits(); i++) await sleep(150); + for (let i = 0; i < 40 && !haveInits(); i++) await sleep(200); // Seek-driven capture — NO fast playback. The player buffers a window ahead // while paused, then plateaus; we hop the scrubber to the buffered edge to pull @@ -234,8 +495,28 @@ } return t; }; + // Per-track buffered edge. v.buffered is the UNION across SourceBuffers: at high + // bitrates the audio buffer can extend far beyond the video one, so the union edge + // would "complete" the capture while the VIDEO track is still a few seconds long — + // producing a file that freezes on the last decoded frame (video ends, audio runs on). + // Driving hops and completion off the real per-track edges keeps them advancing + // together. Falls back to the union edge only when a SourceBuffer reference is stale. + const trackEdge = (kind, t) => { + try { + const sb = store.sb[kind]; + if (!sb) return 0; + const b = sb.buffered; + for (let i = 0; i < b.length; i++) { + if (b.start(i) <= t + 0.5 && b.end(i) >= t) return b.end(i); + } + return 0; + } catch (e) { return 0; } + }; let capturedFrom = capStart; let cursor = capStart, stall = 0; + let complete = false; + let actualH = 0; + let seamCount = 0; // same-stream re-inits glued into the track (not cuts) const span = Math.max(0.1, capEnd - capStart); const started = Date.now(); try { @@ -246,12 +527,22 @@ if (vidId() !== capId) throw new Error('видео переключилось во время захвата'); try { if (!v.paused) v.pause(); } catch (e) {} // keep it paused; buffering runs anyway - const edge = bufferedEndAt(cursor); + const unionEdge = bufferedEndAt(cursor); + const vRaw = needVideo ? trackEdge('video', cursor) : capEnd; + const aRaw = trackEdge('audio', cursor); + const vE = vRaw || unionEdge; + const aE = aRaw || unionEdge; + const edge = Math.min(vE, aE, capEnd); onProgress(Math.min(0.99, Math.max(0, edge - capStart) / span)); - if (edge >= capEnd - 0.6) break; // range fully buffered → captured + // Complete ONLY when the RAW per-track edges reached the end. A fallback union + // edge must never count — audio's far-ahead buffer would declare a short video + // track done and we'd ship the frozen-frame file again. + if (vRaw >= capEnd - 0.6 && aRaw >= capEnd - 0.6) { complete = true; break; } + if (totalCaptured() > BYTE_CAP) break; // memory safety valve → incomplete if (edge > cursor + 0.3) { // window extended → hop to the edge cursor = edge; + store.cursor = cursor; // keep the patch aware of the playhead for restart classification seekVia(Math.min(cursor, capEnd - 0.1)); stall = 0; } else { // plateaued → nudge to re-trigger fetch @@ -262,6 +553,29 @@ if (Date.now() - started > 20 * 60 * 1000) break; // hard cap } capturedFrom = Math.min(capturedFrom, bufferedStartAt(capStart)); + actualH = servedHeight(); // resolution the player actually served during capture + seamCount = ((store.tracks.video && store.tracks.video.seams) || 0) + + ((store.tracks.audio && store.tracks.audio.seams) || 0); + // Buffer-eviction guard: only meaningful when the capture had NO mid-capture + // re-inits. If the browser evicted the START of the buffered range and the player + // re-fetched WITHOUT re-initing, the re-fetched bytes duplicate already-captured + // data and a full-video download (no trimming) would silently ship a corrupt file — + // so complete only when the video buffer still covers the capture start. A + // same-stream re-init (seam) is glued and keeps the bytes whole; a different-stream + // re-init is counted as a restart below and already forces incomplete. + if (complete && needVideo && seamCount === 0) { + try { + const sb = store.sb.video; + if (sb) { + const b = sb.buffered; + let covers = false; + for (let i = 0; i < b.length; i++) { + if (b.start(i) <= capStart + 0.5 && b.end(i) >= capStart) { covers = true; break; } + } + if (!covers) complete = false; + } + } catch (e) {} + } } finally { store.capturing = false; // restore player state @@ -269,10 +583,21 @@ seekVia(prev.time); try { v.muted = prev.muted; } catch (e) {} keepAutoplayOff(); // leave autoplay disabled — don't turn it back on + // restore the user's pre-download quality (the capture left it at the target) + await restoreQuality(prevKey); if (!prev.paused) { try { v.play(); } catch (e) {} } } + // A mid-capture re-init (quality switch / buffer flush) REPLACED a track, so part of + // the range is missing from the file — report honestly as incomplete. Same-stream + // re-inits (seams) were glued and do NOT cut the file. + const restartCount = (store.restarts.video || 0) + (store.restarts.audio || 0); + if (restartCount > 0) complete = false; + // NOTE: restarts/bytes are logged as SEPARATE arguments because Chrome's console + // collapses an object into "{...}" when copied, hiding the values. + dbg('capture', { targetQ, requestedH: wantRes, servedH: actualH, complete, seams: seamCount }, 'restarts:', restartCount, 'bytes:', totalCaptured()); + onProgress(1); - return { capturedFrom: Math.max(0, capturedFrom) }; + return { capturedFrom: Math.max(0, capturedFrom), complete, actualH: actualH || 0, restarts: restartCount, seams: seamCount }; } // ---- subtitles (read from the built-in transcript panel) ----------------- @@ -494,20 +819,29 @@ heights: availableHeights(), }); } else if (cmd === 'download') { + // Any page script can forge bridge messages, so keep malformed input out of + // the seek math and refuse nested captures (which would fight over the same + // player and tracks). playthrough itself clamps to the video's duration; + // here we only ensure the numbers are real. + if (store.capturing) throw new Error('уже идёт захват — дождитесь завершения'); const isMp3 = format === 'mp3'; + const s = Number(start), e = Number(end); + if (!Number.isFinite(s) || !Number.isFinite(e)) throw new Error('неверный диапазон'); // mp3 only needs audio → capture at a low but still-adaptive video quality // (360p) to save bandwidth while keeping video/audio as separate tracks. const targetQ = isMp3 ? 'medium' : (Q[height] || 'hd720'); - const preQ = (targetQ === 'small' || targetQ === 'tiny' || targetQ === 'medium') ? 'tiny' : 'medium'; const cap = await playthrough( - { targetQ, preQ, start, end, needVideo: !isMp3 }, + { targetQ, start: s, end: e, needVideo: !isMp3 }, (pct) => reply({ progress: pct, phase: 'buffering' })); const aud = assemble('audio'); if (!aud) throw new Error('не удалось захватить аудио'); const payload = { ok: true, done: true, + complete: !!cap.complete, // false when capture broke (stall/cap/restart) — file may be cut + restarts: cap.restarts || 0, // mid-capture re-inits that CUT a track capturedFrom: cap.capturedFrom, // where the captured file actually begins + height: cap.actualH || 0, // resolution the player actually served audio: { mime: aud.mime, size: aud.bytes.byteLength }, }; const transfers = [aud.bytes.buffer]; @@ -534,6 +868,8 @@ store.videoId = vidId(); resetTracks(); store.lastInit = Object.create(null); // inits from the previous video are stale + store.sb = Object.create(null); + store.restarts = Object.create(null); store.capturing = false; } scheduleAutoplayOff(); @@ -551,5 +887,5 @@ scheduleAutoplayOff(); store.videoId = vidId(); - console.log('[YTDL] MSE capture hook installed'); + dbg('MSE capture hook installed'); })(); diff --git a/extension/content_ui.css b/extension/content_ui.css index b134c30..661bf53 100644 --- a/extension/content_ui.css +++ b/extension/content_ui.css @@ -119,3 +119,43 @@ background: #ff4e45; transition: width .2s; } + +/* adaptive large-capture modal (parts / whole / cancel) */ +.ytdl-modal { + position: fixed; + inset: 0; + z-index: 2147483647; + background: rgba(0,0,0,.55); + display: flex; + align-items: center; + justify-content: center; +} +.ytdl-modal-box { + width: min(360px, calc(100vw - 48px)); + background: #1c1c1c; + border: 1px solid rgba(255,255,255,.12); + border-radius: 14px; + padding: 18px 18px 14px; + box-shadow: 0 12px 40px rgba(0,0,0,.6); + font-family: "YouTube Sans", Roboto, Arial, sans-serif; + color: #fff; +} +.ytdl-modal-txt { font-size: 13.5px; line-height: 1.5; margin-bottom: 14px; } +.ytdl-modal-btns { display: flex; flex-direction: column; gap: 8px; } +.ytdl-modal-btn { + border: none; + border-radius: 8px; + padding: 10px 14px; + font-size: 14px; + font-family: inherit; + cursor: pointer; + background: rgba(255,255,255,.1); + color: #fff; + text-align: left; + transition: background .15s; +} +.ytdl-modal-btn:hover { background: rgba(255,255,255,.18); } +.ytdl-modal-btn.primary { background: #ff4e45; color: #fff; } +.ytdl-modal-btn.primary:hover { background: #ff6b63; } +.ytdl-modal-btn.cancel { background: transparent; color: #9aa0a6; text-align: center; } +.ytdl-modal-btn.cancel:hover { background: rgba(255,255,255,.06); color: #fff; } diff --git a/extension/content_ui.js b/extension/content_ui.js index 675c666..e1443ee 100644 --- a/extension/content_ui.js +++ b/extension/content_ui.js @@ -2,11 +2,21 @@ // the YouTube player, drives the MAIN-world capture hook over window.postMessage, // then streams the captured tracks to the offscreen ffmpeg worker for muxing. (function () { + // Shared pure helpers (time / trim / base64 / filenames) — provided by lib/format.js, + // which the manifest injects BEFORE this script in the same isolated world. + const L = window.YTDL_LIB; const BTN_ID = 'ytdl-btn'; // Clips up to this length get an exact (re-encoded) cut; longer ones are copied // instantly and start at the keyframe before the requested point. Re-encoding costs // roughly the clip's own length at 1080p, so ~1 minute is a comfortable ceiling. const EXACT_CUT_MAX_SEC = 60; + // Long ranges are saved as sequential parts when the «По частям» toggle is on or the + // adaptive warning suggests it. Each part is a full independent capture+mux, so memory + // stays bounded to one part and the 20-minute capture hard cap is never hit. + const PART_MAX_SEC = 15 * 60; // ~15 min per part + const PEAK_MULT = 4; // offscreen keeps ~4 copies of the source in RAM + const WARN_FRACTION = 0.25; // warn when estimated peak > 25% of available RAM + const MIN_EST_MB = 300; // never warn for small downloads let reqSeq = 1; const pending = new Map(); @@ -22,6 +32,14 @@ window.postMessage(Object.assign({ __ytdl_to_hook: true, cmd, reqId }, extra || {}), '*'); }); } + // Reject if the hook never answers (e.g. it failed to install) instead of leaving + // the menu hanging forever with no feedback. + function withTimeout(p, ms, message) { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(message)), ms); + p.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); }); + }); + } // download drives streaming progress + a final result function download(params, onProgress) { return new Promise((resolve, reject) => { @@ -38,20 +56,6 @@ }); } - // ---- time helpers -------------------------------------------------------- - function fmtTime(sec) { - sec = Math.max(0, Math.floor(sec || 0)); - const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; - const pad = (n) => String(n).padStart(2, '0'); - return h + ':' + pad(m) + ':' + pad(s); - } - function parseTime(str) { - const parts = String(str).trim().split(':').map((p) => Number(p)); - if (!parts.length || parts.some((n) => Number.isNaN(n))) return null; - let s = 0; for (const p of parts) s = s * 60 + p; - return s; - } - // ---- dom helpers (no innerHTML — the page enforces Trusted Types) --------- function el(tag, cls, text) { const e = document.createElement(tag); @@ -101,16 +105,101 @@ function head(text) { const d = document.createElement('div'); d.className = 'ytdl-menu-head'; d.textContent = text; return d; } + // Rough VP9 bitrate estimates (Mbps) used ONLY to estimate capture size. Real bitrate + // varies, so the estimate is conservative (high side). The offscreen document holds the + // whole track in RAM several times over (PEAK_MULT) — that peak is what the adaptive + // warning compares against the machine's actually available memory. + const EST_MBPS = { 2160: 25, 1440: 12, 1080: 6, 720: 4 }; + const estimatedMB = (height, seconds) => ((EST_MBPS[height] || 6) * Math.max(0, seconds)) / 8; + + // Available RAM, cached for the session. Prefers the real value from the background + // (chrome.system.memory, includes free capacity); falls back to navigator.deviceMemory, + // which caps at 8 GB — the worst case is assumed. + let memInfo = null; // { capacityMB, freeMB } + async function getMemInfo() { + if (memInfo) return memInfo; + try { + // withTimeout so a non-responding background can't hang the download click. + const r = await withTimeout(chrome.runtime.sendMessage({ t: 'ytdl-mem' }), 2000, 'memory timeout'); + if (r && r.ok && r.free > 0) { memInfo = { capacityMB: r.capacity, freeMB: r.free }; return memInfo; } + } catch (e) { /* background not reachable — fall through */ } + const gb = navigator.deviceMemory || 8; + memInfo = { capacityMB: gb * 1024, freeMB: gb * 1024 }; + return memInfo; + } + + // Adaptive large-capture warning: returns 'parts' | 'single' | 'cancel' — or null when + // the estimated peak RAM stays safely under WARN_FRACTION of the available memory. + async function adaptiveWarning(height, start, end) { + const estMB = estimatedMB(height, end - start); + const peakMB = estMB * PEAK_MULT; + if (estMB < MIN_EST_MB) return null; + const mem = await getMemInfo(); + if (peakMB <= mem.freeMB * WARN_FRACTION) return null; + const partCount = Math.ceil((end - start) / PART_MAX_SEC); + const txt = 'Ролик ≈ ' + Math.round(estMB) + ' МБ, при муксинге понадобится до ~' + + (peakMB / 1024).toFixed(1) + ' ГБ памяти.'; + if (partCount > 1) { + return partsModal(txt + ' Рекомендую скачать по частям (' + partCount + ' × ~' + + Math.round(PART_MAX_SEC / 60) + ' мин).'); + } + return window.confirm(txt + ' Продолжить?') ? 'single' : 'cancel'; + } + + // Three-choice modal (parts / whole / cancel). Resolves with the chosen action. + function partsModal(text) { + return new Promise((resolve) => { + const overlay = el('div', 'ytdl-modal'); + const box = el('div', 'ytdl-modal-box'); + box.appendChild(el('div', 'ytdl-modal-txt', text)); + const btns = el('div', 'ytdl-modal-btns'); + const cleanup = () => { + document.removeEventListener('keydown', onKey, true); + overlay.remove(); + }; + const mk = (label, cls, val) => { + const b = el('button', 'ytdl-modal-btn' + (cls ? ' ' + cls : ''), label); + b.addEventListener('click', () => { cleanup(); resolve(val); }); + btns.appendChild(b); + }; + const onKey = (ev) => { if (ev.key === 'Escape') { cleanup(); resolve('cancel'); } }; + mk('Скачать по частям', 'primary', 'parts'); + mk('Целиком', '', 'single'); + mk('Отмена', 'cancel', 'cancel'); + box.appendChild(btns); + overlay.appendChild(box); + overlay.addEventListener('click', (ev) => { if (ev.target === overlay) { cleanup(); resolve('cancel'); } }); + document.addEventListener('keydown', onKey, true); + document.body.appendChild(overlay); + }); + } + async function onClick(e) { e.stopPropagation(); if (menuEl) { closeMenu(); return; } - const info = await callHook('info'); + let info; + try { + info = await withTimeout(callHook('info'), 4000, 'не удалось связаться с плеером'); + } catch (err) { + const t = toast(); + t.set('Ошибка: ' + (err.message || err), 1); + t.hide(5000); + return; + } const duration = Math.floor(info.duration || 0); - const heights = (info.heights || []).filter((h) => h === 1080 || h === 720); - if (!heights.includes(1080)) heights.unshift(1080); - if (!heights.includes(720)) heights.push(720); + // Show only the qualities the player actually reports as available — a missing + // option means the video can't be captured at it, and a falsely-labelled file + // (e.g. "[720p]" containing 360p) is worse than no option at all. + const heights = (info.heights || []).filter((h) => h === 2160 || h === 1440 || h === 1080 || h === 720); const uniq = [...new Set(heights)].sort((a, b) => b - a); - const { transcode = false } = await chrome.storage.local.get('transcode'); + const { transcode = false, parts = false, mp3Bitrate = 192 } = + await chrome.storage.local.get(['transcode', 'parts', 'mp3Bitrate']); + // Radio/toggle state lives here (onClick scope) so the video/mp3 click handlers read + // the CURRENT selection — passing the initial storage value would ignore a change made + // in this menu session. + let current = !!transcode; + let partsOn = !!parts; // «По частям» toggle — read at click time + let mp3Bit = Number(mp3Bitrate) || 192; // kbps — only affects MP3 downloads menuEl = document.createElement('div'); menuEl.className = 'ytdl-menu'; @@ -123,16 +212,16 @@ const inStart = document.createElement('input'); const inEnd = document.createElement('input'); inStart.className = inEnd.className = 'ytdl-time'; - inStart.value = fmtTime(0); - inEnd.value = fmtTime(duration); + inStart.value = L.fmtTime(0); + inEnd.value = L.fmtTime(duration); [inStart, inEnd].forEach((i) => i.addEventListener('click', (ev) => ev.stopPropagation())); const dash = document.createElement('span'); dash.className = 'ytdl-frag-dash'; dash.textContent = '—'; frag.appendChild(inStart); frag.appendChild(dash); frag.appendChild(inEnd); menuEl.appendChild(frag); function fragment() { - let start = parseTime(inStart.value); - let end = parseTime(inEnd.value); + let start = L.parseTime(inStart.value); + let end = L.parseTime(inEnd.value); if (start == null) start = 0; if (end == null || end <= 0) end = duration; start = Math.max(0, Math.min(start, duration)); @@ -140,25 +229,44 @@ return { start, end }; } - // --- video --- - menuEl.appendChild(head('Видео')); - uniq.forEach((h) => { - const item = el('div', 'ytdl-menu-item'); - itemLabel(item, h + 'p', 'mp4'); - item.addEventListener('click', () => { - const f = fragment(); closeMenu(); - startDownload({ format: 'mp4', height: h, start: f.start, end: f.end }, info); + // --- video (only when at least one quality is available) --- + if (uniq.length) { + menuEl.appendChild(head('Видео')); + uniq.forEach((h) => { + const item = el('div', 'ytdl-menu-item'); + itemLabel(item, h + 'p', 'mp4'); + item.addEventListener('click', async () => { + const f = fragment(); closeMenu(); + const range = f.end - f.start; + // Toggle on → always split long ranges; otherwise the adaptive warning may + // suggest parts for a large capture. + const parts = partsOn && range > PART_MAX_SEC ? L.splitRange(f.start, f.end, PART_MAX_SEC) : null; + if (parts) { startParts({ format: 'mp4', height: h }, info, current, parts); return; } + const decision = await adaptiveWarning(h, f.start, f.end); + if (decision === 'cancel') return; + if (decision === 'parts') { + startParts({ format: 'mp4', height: h }, info, current, L.splitRange(f.start, f.end, PART_MAX_SEC)); + return; + } + startDownload({ format: 'mp4', height: h, start: f.start, end: f.end }, info, current); + }); + menuEl.appendChild(item); }); - menuEl.appendChild(item); - }); + } // --- audio --- menuEl.appendChild(head('Аудио')); const mp3 = el('div', 'ytdl-menu-item'); itemLabel(mp3, 'MP3', 'аудио'); - mp3.addEventListener('click', () => { + mp3.addEventListener('click', async () => { const f = fragment(); closeMenu(); - startDownload({ format: 'mp3', height: null, start: f.start, end: f.end }, info); + const range = f.end - f.start; + // mp3 is tiny memory-wise; the toggle only matters to stay under the capture time cap. + if (partsOn && range > PART_MAX_SEC) { + startParts({ format: 'mp3', height: null, mp3Bitrate: mp3Bit }, info, current, L.splitRange(f.start, f.end, PART_MAX_SEC)); + return; + } + startDownload({ format: 'mp3', height: null, start: f.start, end: f.end, mp3Bitrate: mp3Bit }, info, current); }); menuEl.appendChild(mp3); @@ -169,28 +277,67 @@ subs.addEventListener('click', () => { closeMenu(); downloadSubtitles(info); }); menuEl.appendChild(subs); - // --- video format toggle --- - menuEl.appendChild(head('Формат видео')); - const formats = [ - { key: false, title: 'Быстро', sub: 'VP9 в mp4, без перекодирования' }, - { key: true, title: 'H.264 (совместимо)', sub: 'перекодирование, медленно' }, + // --- video format toggle (only meaningful when video options exist) --- + if (uniq.length) { + menuEl.appendChild(head('Формат видео')); + const formats = [ + { key: false, title: 'Быстро', sub: 'VP9 в mp4, без перекодирования' }, + { key: true, title: 'H.264 (совместимо)', sub: 'перекодирование, медленно' }, + ]; + const rows = []; + formats.forEach((f) => { + const row = el('div', 'ytdl-menu-radio' + (current === f.key ? ' sel' : '')); + row.appendChild(el('span', 'ytdl-dot')); + const txt = el('span', 'ytdl-radio-txt'); + txt.appendChild(el('b', null, f.title)); + txt.appendChild(el('i', null, f.sub)); + row.appendChild(txt); + row.addEventListener('click', (ev) => { + ev.stopPropagation(); + current = f.key; + chrome.storage.local.set({ transcode: f.key }); + rows.forEach((r, i) => r.classList.toggle('sel', formats[i].key === current)); + }); + rows.push(row); + menuEl.appendChild(row); + }); + // --- «По частям» toggle: long ranges become sequential ~15-min files --- + const partsRow = el('div', 'ytdl-menu-radio' + (partsOn ? ' sel' : '')); + partsRow.appendChild(el('span', 'ytdl-dot')); + const partsTxt = el('span', 'ytdl-radio-txt'); + partsTxt.appendChild(el('b', null, 'По частям')); + partsTxt.appendChild(el('i', null, 'длинные ролики — по ~' + Math.round(PART_MAX_SEC / 60) + ' мин')); + partsRow.appendChild(partsTxt); + partsRow.addEventListener('click', (ev) => { + ev.stopPropagation(); + partsOn = !partsOn; + chrome.storage.local.set({ parts: partsOn }); + partsRow.classList.toggle('sel', partsOn); + }); + menuEl.appendChild(partsRow); + } + + // --- MP3 bitrate: only affects MP3 downloads; default 192k matches the original --- + menuEl.appendChild(head('MP3 битрейт')); + const bitrates = [ + { key: 192, title: '192 kbps', sub: 'как в оригинале' }, + { key: 320, title: '320 kbps', sub: 'максимальное качество, файл больше' }, ]; - let current = !!transcode; - const rows = []; - formats.forEach((f) => { - const row = el('div', 'ytdl-menu-radio' + (current === f.key ? ' sel' : '')); + const bitRows = []; + bitrates.forEach((b) => { + const row = el('div', 'ytdl-menu-radio' + (mp3Bit === b.key ? ' sel' : '')); row.appendChild(el('span', 'ytdl-dot')); const txt = el('span', 'ytdl-radio-txt'); - txt.appendChild(el('b', null, f.title)); - txt.appendChild(el('i', null, f.sub)); + txt.appendChild(el('b', null, b.title)); + txt.appendChild(el('i', null, b.sub)); row.appendChild(txt); row.addEventListener('click', (ev) => { ev.stopPropagation(); - current = f.key; - chrome.storage.local.set({ transcode: f.key }); - rows.forEach((r, i) => r.classList.toggle('sel', formats[i].key === current)); + mp3Bit = b.key; + chrome.storage.local.set({ mp3Bitrate: b.key }); + bitRows.forEach((r, i) => r.classList.toggle('sel', bitrates[i].key === mp3Bit)); }); - rows.push(row); + bitRows.push(row); menuEl.appendChild(row); }); @@ -204,6 +351,7 @@ // ---- progress toast ------------------------------------------------------ function toast() { let box = document.getElementById('ytdl-toast'); + let hideTimer = null; if (!box) { box = el('div'); box.id = 'ytdl-toast'; const bar = el('div', 'ytdl-toast-bar'); bar.appendChild(el('i')); @@ -213,29 +361,27 @@ } return { set(txt, pct) { + // A new message cancels any pending hide so a stale timer (e.g. from the + // previous part of a split download) can't hide the toast mid-part. + if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } box.querySelector('.ytdl-toast-txt').textContent = txt; box.querySelector('.ytdl-toast-bar i').style.width = Math.round((pct || 0) * 100) + '%'; box.classList.add('show'); }, - hide(delay) { setTimeout(() => box.classList.remove('show'), delay || 0); }, + hide(delay) { + if (hideTimer) clearTimeout(hideTimer); + hideTimer = setTimeout(() => { hideTimer = null; box.classList.remove('show'); }, delay || 0); + }, }; } - function safeName(s) { - return (s || 'video').replace(/[\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120); - } - function fragSuffix(start, end, duration) { - if (start <= 0 && end >= duration - 0.5) return ''; - return ' (' + fmtTime(start).replace(/:/g, '.') + '-' + fmtTime(end).replace(/:/g, '.') + ')'; - } - async function downloadSubtitles(info) { const t = toast(); t.set('Открываю расшифровку…', 0.3); try { const res = await callHook('subtitles'); if (!res || !res.ok) throw new Error((res && res.error) || 'нет субтитров'); - const filename = safeName(info.title) + ' [' + (res.lang || 'txt') + '].txt'; + const filename = L.safeName(info.title) + ' [' + (res.lang || 'txt') + '].txt'; // small text → a data URL is enough; BOM keeps Cyrillic correct on Windows const url = 'data:text/plain;charset=utf-8,' + encodeURIComponent('' + res.text); const save = await chrome.runtime.sendMessage({ t: 'ytdl-save', url, filename }); @@ -249,54 +395,48 @@ } } - async function startDownload(opts, info) { - const { format, height, start, end } = opts; + // Download one concrete range (used for single downloads AND for one part of a split). + // Shows per-step progress in `t` prefixed with `prefix` (e.g. "Часть 2 из 4: ") and + // returns { ok } / { ok: false, error } instead of raising. + async function downloadOne(opts, info, transcode, t, prefix) { + const { format, height, start, end, mp3Bitrate } = opts; const duration = Math.floor(info.duration || 0); const isMp3 = format === 'mp3'; const label = isMp3 ? 'MP3' : height + 'p'; - const t = toast(); - t.set('Готовлю ' + label + ' — загрузка сегментов…', 0.02); - - const { transcode = false } = await chrome.storage.local.get('transcode'); const onProg = (msg) => { if (msg && msg.t === 'ytdl-progress') { - t.set((isMp3 ? 'Кодирование MP3… ' : 'Точная обрезка (перекодирование)… ') + + t.set(prefix + (isMp3 ? 'Кодирование MP3… ' : 'Точная обрезка (перекодирование)… ') + Math.round(msg.value * 100) + '%', 0.55 + msg.value * 0.45); } }; chrome.runtime.onMessage.addListener(onProg); try { const result = await download({ height, format, start, end }, (d) => { - t.set('Загрузка сегментов ' + label + '… ' + Math.round(d.progress * 100) + '%', d.progress * 0.5); + t.set(prefix + 'Загрузка сегментов ' + label + '… ' + Math.round(d.progress * 100) + '%', d.progress * 0.5); }); const ext = isMp3 ? '.mp3' : '.mp4'; - const filename = safeName(info.title) + (isMp3 ? '' : ' [' + height + 'p]') + - fragSuffix(start, end, duration) + ext; + // The player may serve a lower resolution than requested (ABR/viewport cap) — + // name the file after what we actually got so it isn't misleading, and note it + // in the toast below. + const actualH = result.height || 0; + const downgraded = !isMp3 && actualH >= 100 && actualH < height; + const effH = downgraded ? actualH : height; + const filename = L.safeName(info.title) + (isMp3 ? '' : ' [' + effH + 'p]') + + (opts.partLabel || L.fragSuffix(start, end, duration)) + ext; // Capture starts at a segment boundary at or before `start`, so trimming must be // RELATIVE to the captured file — ffmpeg's -ss counts from the file's own start, // not from the video's absolute timeline. const capturedFrom = typeof result.capturedFrom === 'number' ? result.capturedFrom : start; - const trimStart = Math.max(0, start - capturedFrom); - const trimDuration = Math.max(0, end - start); - const isFragment = start > 0 || end < duration - 0.5; - - // A copied stream can only start on a keyframe, so an exact start needs - // re-encoding. That costs roughly the clip's own length, so we only do it - // automatically for short clips; longer ones stay instant and start at the - // keyframe just before the requested point. - const needsExactCut = isFragment && trimStart > 0.3; - const shortEnough = trimDuration > 0 && trimDuration <= EXACT_CUT_MAX_SEC; - const exactCut = !isMp3 && needsExactCut && shortEnough; - const doTranscode = isMp3 ? true : (!!transcode || exactCut); - const alignedStart = !isMp3 && needsExactCut && !doTranscode; - - t.set(isMp3 ? 'Кодирование MP3…' + const job = L.computeJob({ start, end, duration, capturedFrom, isMp3, transcode, exactCutMaxSec: EXACT_CUT_MAX_SEC }); + const { trimStart, trimDuration, isFragment, exactCut, doTranscode, alignedStart, quickEncode } = job; + + t.set(prefix + (isMp3 ? 'Кодирование MP3…' : (exactCut ? 'Точная обрезка фрагмента (перекодирование)…' : (transcode ? 'Перекодирование в H.264 (может занять дольше ролика)…' - : 'Склейка дорожек…')), 0.55); + : 'Склейка дорожек…'))), 0.55); const res = await muxViaOffscreen({ format, @@ -304,35 +444,71 @@ audio: result._a, videoMime: result.video && result.video.mime, audioMime: result.audio && result.audio.mime, - filename, transcode: doTranscode, quickEncode: exactCut && !transcode, + filename, transcode: doTranscode, quickEncode, trimStart, // only limit duration when a real fragment was requested trimDuration: isFragment ? trimDuration : 0, + mp3Bitrate, }); if (!res || !res.ok) throw new Error(res && res.error || 'mux failed'); - t.set('Готово: ' + (res.filename || filename) + - (alignedStart ? ' — начало выровнено по опорному кадру' : ''), 1); - t.hide(alignedStart ? 7000 : 4000); + const partialNote = result.complete === false + ? (result.restarts > 0 + ? ' — во время захвата переключилось качество, файл может быть обрезан' + : ' — захват неполный, файл может быть обрезан') + : ''; + const resNote = downgraded ? ' — плеер отдал ' + actualH + 'p вместо ' + height + 'p' : ''; + t.set(prefix + 'Готово: ' + (res.filename || filename) + + (alignedStart ? ' — начало выровнено по опорному кадру' : '') + partialNote + resNote, 1); + t.hide(alignedStart || partialNote || resNote ? 7000 : 4000); + return { ok: true }; } catch (err) { - t.set('Ошибка: ' + (err.message || err), 1); - t.hide(6000); + t.set(prefix + 'Ошибка: ' + (err.message || err), 1); + t.hide(7000); console.error('[Triangle]', err); + return { ok: false, error: (err && err.message) || String(err) }; } finally { chrome.runtime.onMessage.removeListener(onProg); } } - // ---- transfer to offscreen ffmpeg --------------------------------------- - function b64encode(u8) { - let s = ''; - const STEP = 0x8000; - for (let i = 0; i < u8.length; i += STEP) { - s += String.fromCharCode.apply(null, u8.subarray(i, Math.min(i + STEP, u8.length))); + // Save a long range as sequential parts — one independent file per part. + async function startParts(base, info, transcode, parts) { + const { format, height, mp3Bitrate } = base; + const label = format === 'mp3' ? 'MP3' : height + 'p'; + const t = toast(); + t.set('Скачивание по частям: 0 из ' + parts.length + '…', 0.02); + let failed = null; + for (let i = 0; i < parts.length; i++) { + const p = parts[i]; + // Emit an immediate per-part message so the toast never goes blank between + // parts (the first capture progress callback is seconds away). + t.set('Часть ' + (i + 1) + ' из ' + parts.length + ': готовлю ' + label + '…', 0.02); + const r = await downloadOne({ + format, height, start: p.start, end: p.end, + mp3Bitrate, + partLabel: ' (part ' + (i + 1) + ' of ' + parts.length + ')', + }, info, transcode, t, 'Часть ' + (i + 1) + ' из ' + parts.length + ': '); + if (!r.ok) { failed = { index: i + 1, error: r.error }; break; } } - return btoa(s); + if (failed) { + t.set('Ошибка в части ' + failed.index + ': ' + failed.error, 1); + t.hide(8000); + } else { + t.set('Готово: ' + parts.length + ' частей (' + label + ')', 1); + t.hide(6000); + } + } + + // Single-download entry point (parts are handled by startParts / the click handlers). + async function startDownload(opts, info, transcode) { + const { format, height } = opts; + const t = toast(); + t.set('Готовлю ' + (format === 'mp3' ? 'MP3' : height + 'p') + ' — загрузка сегментов…', 0.02); + await downloadOne(opts, info, transcode, t, ''); } + // ---- transfer to offscreen ffmpeg --------------------------------------- const wait = (ms) => new Promise((r) => setTimeout(r, ms)); // The ffmpeg side lives in an offscreen document that the service worker creates on @@ -377,6 +553,7 @@ videoMime: job.videoMime, audioMime: job.audioMime, transcode: !!job.transcode, quickEncode: !!job.quickEncode, trimStart: job.trimStart || 0, trimDuration: job.trimDuration || 0, + mp3Bitrate: job.mp3Bitrate, // default (192) is owned by the offscreen side }); let seq = 0; // lets the receiver drop a repeated chunk instead of doubling the data @@ -385,7 +562,7 @@ const view = new Uint8Array(buf); for (let off = 0; off < view.length; off += CHUNK) { const slice = view.subarray(off, Math.min(off + CHUNK, view.length)); - const r = await sendToOffscreen({ t: 'ytdl-chunk', track: name, seq, b64: b64encode(slice) }); + const r = await sendToOffscreen({ t: 'ytdl-chunk', track: name, seq, b64: L.b64encode(slice) }); if (!r || !r.ok) { throw new Error('передача данных прервалась (' + name + ')' + (r && r.error ? ': ' + r.error : '')); } diff --git a/extension/lib/format.js b/extension/lib/format.js new file mode 100644 index 0000000..f1efe66 --- /dev/null +++ b/extension/lib/format.js @@ -0,0 +1,171 @@ +// extension/lib/format.js — shared PURE helpers (no extension APIs, no DOM). +// +// Loaded as a classic script BEFORE content_ui.js (ISOLATED-world content script) and +// BEFORE offscreen.js (offscreen.html), where it registers globalThis.YTDL_LIB; the same +// file is require()d by the node:test suite in tests/ (see Audit F9). Keeping the pure +// logic here means the tests exercise the exact code the extension runs. +(function (root, factory) { + const api = factory(); + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else root.YTDL_LIB = api; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + // ---- time ---------------------------------------------------------------- + function fmtTime(sec) { + sec = Math.max(0, Math.floor(sec || 0)); + const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; + const pad = (n) => String(n).padStart(2, '0'); + return h + ':' + pad(m) + ':' + pad(s); + } + function parseTime(str) { + const parts = String(str).trim().split(':').map((p) => Number(p)); + if (!parts.length || parts.some((n) => Number.isNaN(n))) return null; + let s = 0; for (const p of parts) s = s * 60 + p; + return s; + } + + // ---- trim / re-encode decision matrix ------------------------------------ + // Capture starts at a segment boundary at or before `start`, so trimming is RELATIVE + // to the captured file (ffmpeg's -ss counts from the file's own start). A copied + // stream can only start on a keyframe, so an exact start needs re-encoding — done + // automatically only for short clips (exactCutMaxSec); longer fragments stay instant + // and start at the keyframe before the requested point. + function computeJob({ start, end, duration, capturedFrom, isMp3, transcode, exactCutMaxSec }) { + const trimStart = Math.max(0, start - capturedFrom); + const trimDuration = Math.max(0, end - start); + const isFragment = start > 0 || end < duration - 0.5; + const needsExactCut = isFragment && trimStart > 0.3; + const shortEnough = trimDuration > 0 && trimDuration <= (exactCutMaxSec || 60); + const exactCut = !isMp3 && needsExactCut && shortEnough; + const doTranscode = isMp3 ? true : (!!transcode || exactCut); + const alignedStart = !isMp3 && needsExactCut && !doTranscode; + const quickEncode = exactCut && !transcode; + return { trimStart, trimDuration, isFragment, needsExactCut, shortEnough, exactCut, doTranscode, alignedStart, quickEncode }; + } + + // ---- base64 -------------------------------------------------------------- + function b64encode(u8) { + let s = ''; + const STEP = 0x8000; + for (let i = 0; i < u8.length; i += STEP) { + s += String.fromCharCode.apply(null, u8.subarray(i, Math.min(i + STEP, u8.length))); + } + return btoa(s); + } + function b64decode(s) { + const bin = atob(s); + const u8 = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); + return u8; + } + + // ---- filenames ----------------------------------------------------------- + function safeName(s) { + return (s || 'video') + // chrome.downloads rejects filenames containing control (Cc) or format (Cf) + // characters — e.g. U+2060 WORD JOINER / zero-width spaces that some YouTube + // titles embed so they can't be copied ("Invalid filename" from Chrome). + .replace(/[\u0000-\u001f\u007f-\u009f\u00ad\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]+/g, '') + .replace(/[\\/:*?"<>|]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + // Chrome/Windows also reject names ending in a dot or a space. + .replace(/[. ]+$/, '') + .slice(0, 120) || 'video'; + } + function fragSuffix(start, end, duration) { + if (start <= 0 && end >= duration - 0.5) return ''; + return ' (' + fmtTime(start).replace(/:/g, '.') + '-' + fmtTime(end).replace(/:/g, '.') + ')'; + } + + // ---- ranges -------------------------------------------------------------- + function splitRange(start, end, partSec) { + const parts = []; + for (let s = start; s < end; s += partSec) parts.push({ start: s, end: Math.min(s + partSec, end) }); + return parts; + } + + // ---- byte buffers -------------------------------------------------------- + function extFor(mime) { + if (/webm/i.test(mime)) return 'webm'; + if (/mp4/i.test(mime)) return 'mp4'; + return 'bin'; + } + function concat(parts) { + let n = 0; for (const p of parts) n += p.length; + const out = new Uint8Array(n); + let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } + return out; + } + + // ---- offscreen ffmpeg run cascade ---------------------------------------- + // The order matters: the first run that exits 0 AND produces non-empty output wins. + // Re-encoding cuts frame-accurately and therefore seeks (-ss); a stream copy cannot + // start mid-GOP, so it never seeks and just limits the length (both tracks start + // together at the keyframe before the request). MP4 gets an edit-list-free copy with + // normalized timestamps so players don't show a frozen tail. + function buildRuns({ isMp3, transcode, quickEncode, trimStart, trimDuration, vName, aName, mp3Bitrate }) { + const exact = !!transcode; + const seek = exact && trimStart > 0.05 ? ['-ss', trimStart.toFixed(3)] : []; + const limit = trimDuration > 0.05 + ? ['-t', (exact ? trimDuration : trimStart + trimDuration).toFixed(3)] + : []; + const inV = (s) => (vName ? [...s, '-i', vName] : []); + const inA = (s) => [...s, '-i', aName]; + const ZERO = ['-avoid_negative_ts', 'make_zero']; + + const runs = []; + if (isMp3) { + // CBR via -b:a. Defaults to the author's original 192k; the menu can pick 320k + // (max for libmp3lame) — a user preference, never a hardcoded change. + const bitrate = mp3Bitrate ? String(mp3Bitrate).replace(/k$/i, '') + 'k' : '192k'; + runs.push({ + name: 'mp3', out: 'out.mp3', type: 'audio/mpeg', ext: '.mp3', + args: [...inA(seek), ...limit, '-vn', '-c:a', 'libmp3lame', '-b:a', bitrate, 'out.mp3'], + }); + } else if (transcode) { + // Re-encode to H.264 + AAC. An automatic exact cut of a short clip favours speed + // (ultrafast is ~2× quicker at 1080p); the user-selected compatibility mode keeps + // the better-compressing preset. + const preset = quickEncode ? 'ultrafast' : 'veryfast'; + runs.push({ + name: 'h264', out: 'out.mp4', type: 'video/mp4', ext: '.mp4', + args: [...inV(seek), ...inA(seek), '-map', '0:v:0', '-map', '1:a:0', ...limit, + '-c:v', 'libx264', '-preset', preset, '-crf', '20', '-pix_fmt', 'yuv420p', + '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', 'out.mp4'], + }); + } else { + // Fast path: stream-copy the original tracks (VP9/Opus) into mp4 (seconds). + runs.push({ + name: 'mp4-copy', out: 'out.mp4', type: 'video/mp4', ext: '.mp4', + args: [...inV(seek), ...inA(seek), '-map', '0:v:0', '-map', '1:a:0', ...limit, + '-c', 'copy', '-strict', '-2', ...ZERO, '-movflags', '+faststart', 'out.mp4'], + }); + if (seek.length || limit.length) { + // If trimming upsets the copy path, keep the whole captured range rather than + // fail (it covers the fragment, just aligned to segment boundaries). + runs.push({ + name: 'mp4-copy-untrimmed', out: 'out.mp4', type: 'video/mp4', ext: '.mp4', + args: [...inV([]), ...inA([]), '-map', '0:v:0', '-map', '1:a:0', + '-c', 'copy', '-strict', '-2', '-avoid_negative_ts', 'make_zero', + '-movflags', '+faststart', 'out.mp4'], + }); + } + // Last resort if mp4 refuses these codecs. + runs.push({ + name: 'webm-copy', out: 'out.webm', type: 'video/webm', ext: '.webm', + args: [...inV(seek), ...inA(seek), '-map', '0:v:0', '-map', '1:a:0', ...limit, + '-c', 'copy', ...ZERO, 'out.webm'], + }); + } + return runs; + } + + return { + fmtTime, parseTime, computeJob, + b64encode, b64decode, + safeName, fragSuffix, splitRange, + extFor, concat, buildRuns, + }; +}); diff --git a/extension/manifest.json b/extension/manifest.json index ff485ac..57316bc 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,9 +1,10 @@ { "manifest_version": 3, "name": "Triangle Downloader", - "version": "1.4.3", - "description": "Скачивает открытое видео YouTube (720p/1080p mp4, mp3, выбор фрагмента), перехватывая поток самого плеера. Без yt-dlp и внешних сервисов.", - "permissions": ["downloads", "offscreen", "storage"], + "version": "1.5.0", + "minimum_chrome_version": "116", + "description": "Скачивает открытое видео YouTube (720p–2160p mp4, mp3, выбор фрагмента), перехватывая поток самого плеера. Без yt-dlp и внешних сервисов.", + "permissions": ["downloads", "offscreen", "storage", "system.memory"], "host_permissions": ["*://www.youtube.com/*"], "background": { "service_worker": "background.js" }, "content_scripts": [ @@ -16,7 +17,7 @@ }, { "matches": ["*://www.youtube.com/*"], - "js": ["content_ui.js"], + "js": ["lib/format.js", "content_ui.js"], "css": ["content_ui.css"], "run_at": "document_idle", "world": "ISOLATED", diff --git a/extension/offscreen.html b/extension/offscreen.html index 15fe269..794e68a 100644 --- a/extension/offscreen.html +++ b/extension/offscreen.html @@ -4,4 +4,5 @@ + diff --git a/extension/offscreen.js b/extension/offscreen.js index 2d3ea64..48df5fa 100644 --- a/extension/offscreen.js +++ b/extension/offscreen.js @@ -5,6 +5,9 @@ // or VP9 video + Opus audio), so this re-encodes rather than remuxes. const { FFmpeg } = FFmpegWASM; +// Shared pure helpers (base64 / byte concat / MIME→ext / ffmpeg run cascade) from +// lib/format.js, loaded by offscreen.html BEFORE this script. +const L = window.YTDL_LIB; let ff = null; let ffLoading = null; @@ -28,41 +31,26 @@ async function getFF() { ff = inst; return inst; })(); + // On failure, forget the rejected promise so the NEXT download retries instead of + // being stuck with a permanently-rejected ffLoading (which would brick all muxing + // until the extension is reloaded). The current caller still receives the error. + ffLoading = ffLoading.catch((err) => { ffLoading = null; throw err; }); return ffLoading; } -function b64decode(s) { - const bin = atob(s); - const u8 = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); - return u8; -} - -function concat(parts) { - let n = 0; for (const p of parts) n += p.length; - const out = new Uint8Array(n); - let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } - return out; -} -function extFor(mime) { - if (/webm/i.test(mime)) return 'webm'; - if (/mp4/i.test(mime)) return 'mp4'; - return 'bin'; -} - async function finalize() { const inst = await getFF(); const isMp3 = acc.format === 'mp3'; - const aName = 'a.' + extFor(acc.audioMime); + const aName = 'a.' + L.extFor(acc.audioMime); - const aBytes = concat(acc.audio); + const aBytes = L.concat(acc.audio); if (!aBytes.length) throw new Error('пустые данные аудио'); await inst.writeFile(aName, aBytes); let vName = null; if (!isMp3) { - vName = 'v.' + extFor(acc.videoMime); - const vBytes = concat(acc.video); + vName = 'v.' + L.extFor(acc.videoMime); + const vBytes = L.concat(acc.video); if (!vBytes.length) throw new Error('пустые данные видео'); await inst.writeFile(vName, vBytes); } @@ -73,66 +61,12 @@ async function finalize() { // Passing an absolute position produced an empty file (0 bytes of output). const trimStart = Math.max(0, Number(acc.trimStart) || 0); const trimDuration = Math.max(0, Number(acc.trimDuration) || 0); - // Re-encoding cuts frame-accurately, so it seeks to the exact requested point. - // A stream copy cannot: video can only start on a keyframe while audio would be cut - // precisely, which leaves the lead-in silent. So the copy path seeks nothing and just - // limits the length — both tracks start together at the keyframe before the request. - const exact = !!acc.transcode; - const seek = exact && trimStart > 0.05 ? ['-ss', trimStart.toFixed(3)] : []; - const limit = trimDuration > 0.05 - ? ['-t', (exact ? trimDuration : trimStart + trimDuration).toFixed(3)] - : []; - const inV = (s) => (vName ? [...s, '-i', vName] : []); - const inA = (s) => [...s, '-i', aName]; - // Stream copy can only cut on keyframes, so a trimmed copy starts at the keyframe - // BEFORE the requested point. MP4 can hide that lead-in with an edit list, but the - // skipped frames stay inside the file and players that take the duration from the - // media track then show a frozen tail at the end. So the copy path always normalizes - // timestamps (lead-in becomes ordinary content) and exact cuts are produced by - // re-encoding instead — see the "exact cut" decision in content_ui.js. - const ZERO = ['-avoid_negative_ts', 'make_zero']; - - const runs = []; - if (isMp3) { - runs.push({ - name: 'mp3', out: 'out.mp3', type: 'audio/mpeg', ext: '.mp3', - args: [...inA(seek), ...limit, '-vn', '-c:a', 'libmp3lame', '-b:a', '192k', 'out.mp3'], - }); - } else if (acc.transcode) { - // Re-encode to H.264 + AAC. An automatic exact cut of a short clip favours speed - // (ultrafast is ~2× quicker at 1080p); the user-selected compatibility mode keeps - // the better-compressing preset. - const preset = acc.quickEncode ? 'ultrafast' : 'veryfast'; - runs.push({ - name: 'h264', out: 'out.mp4', type: 'video/mp4', ext: '.mp4', - args: [...inV(seek), ...inA(seek), '-map', '0:v:0', '-map', '1:a:0', ...limit, - '-c:v', 'libx264', '-preset', preset, '-crf', '20', '-pix_fmt', 'yuv420p', - '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', 'out.mp4'], - }); - } else { - // Fast path: stream-copy the original tracks (VP9/Opus) into mp4 (seconds). - runs.push({ - name: 'mp4-copy', out: 'out.mp4', type: 'video/mp4', ext: '.mp4', - args: [...inV(seek), ...inA(seek), '-map', '0:v:0', '-map', '1:a:0', ...limit, - '-c', 'copy', '-strict', '-2', ...ZERO, '-movflags', '+faststart', 'out.mp4'], - }); - if (seek.length || limit.length) { - // If trimming upsets the copy path, keep the whole captured range rather than fail - // (it covers the fragment, just aligned to segment boundaries). - runs.push({ - name: 'mp4-copy-untrimmed', out: 'out.mp4', type: 'video/mp4', ext: '.mp4', - args: [...inV([]), ...inA([]), '-map', '0:v:0', '-map', '1:a:0', - '-c', 'copy', '-strict', '-2', '-avoid_negative_ts', 'make_zero', - '-movflags', '+faststart', 'out.mp4'], - }); - } - // Last resort if mp4 refuses these codecs. - runs.push({ - name: 'webm-copy', out: 'out.webm', type: 'video/webm', ext: '.webm', - args: [...inV(seek), ...inA(seek), '-map', '0:v:0', '-map', '1:a:0', ...limit, - '-c', 'copy', ...ZERO, 'out.webm'], - }); - } + // The run cascade (mp3 / h264 / mp4-copy ± untrimmed / webm-copy) and the seek/limit + // math live in lib/format.js (buildRuns) — it is unit-tested and identical in prod. + const runs = L.buildRuns({ + isMp3, transcode: acc.transcode, quickEncode: acc.quickEncode, + trimStart, trimDuration, vName, aName, mp3Bitrate: acc.mp3Bitrate, + }); let data = null, chosen = null; const failures = []; @@ -144,7 +78,7 @@ async function finalize() { try { const out = await inst.readFile(run.out); // a non-empty result only — a "successful" run can still yield an empty file - if (out && out.length > 1024) { data = out; chosen = run; break; } + if (out && out.length > 0) { data = out; chosen = run; break; } failures.push(run.name + ': пустой результат'); } catch (e) { failures.push(run.name + ': файл не создан'); } } else { @@ -165,8 +99,12 @@ async function finalize() { const blob = new Blob([data.buffer], { type: chosen.type }); const url = URL.createObjectURL(blob); const res = await chrome.runtime.sendMessage({ t: 'ytdl-save', url, filename }); - // keep the blob alive briefly so chrome.downloads can read it, then release - setTimeout(() => { try { URL.revokeObjectURL(url); } catch (e) {} }, 60000); + // The blob is revoked by the background once the download actually completes + // (ytdl-revoke). Belt-and-braces: if the service worker is terminated before the + // download finishes, its listener and fallback timer are lost — this document timer + // always runs and guarantees the blob is eventually released. Double revocation is + // harmless (revoking an already-revoked URL is a no-op). + setTimeout(() => { try { URL.revokeObjectURL(url); } catch (e) {} }, 10 * 60 * 1000); return res && res.ok ? { ok: true, filename } : { ok: false, error: (res && res.error) || 'save failed' }; } @@ -178,6 +116,14 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { // is actually able to receive. if (msg.t === 'ytdl-ping') { sendResponse({ ok: true }); return; } + if (msg.t === 'ytdl-revoke') { + // background tells us the download finished (or failed) and the blob is no longer + // being read — safe to release the object URL now. + try { URL.revokeObjectURL(msg.url); } catch (e) {} + sendResponse({ ok: true }); + return; // sync + } + if (msg.t === 'ytdl-begin') { acc.video = []; acc.audio = []; acc.seq = 0; acc.videoMime = msg.videoMime || ''; @@ -186,6 +132,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { acc.transcode = !!msg.transcode; acc.format = msg.format || 'mp4'; acc.quickEncode = !!msg.quickEncode; + acc.mp3Bitrate = Number(msg.mp3Bitrate) || 192; // kbps, default matches the original acc.trimStart = msg.trimStart || 0; acc.trimDuration = msg.trimDuration || 0; // warm up ffmpeg while chunks stream in @@ -204,7 +151,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (seq < acc.seq) { sendResponse({ ok: true, duplicate: true }); return; } if (seq > acc.seq) { sendResponse({ ok: false, error: 'пропущен фрагмент данных' }); return; } } - acc[msg.track].push(b64decode(msg.b64)); + acc[msg.track].push(L.b64decode(msg.b64)); acc.seq++; sendResponse({ ok: true }); } catch (e) { diff --git a/tests/format.test.js b/tests/format.test.js new file mode 100644 index 0000000..ba0082c --- /dev/null +++ b/tests/format.test.js @@ -0,0 +1,229 @@ +// tests/format.test.js — node:test suite for the pure helpers in extension/lib/format.js. +// The extension loads the SAME file (as globalThis.YTDL_LIB), so these tests exercise the +// exact production logic — including the trim math that once caused a real bug (absolute +// -ss produced an empty file). Run with: node --test tests/ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const L = require('../extension/lib/format.js'); + +// ---- time ----------------------------------------------------------------- + +test('fmtTime formats h:mm:ss with zero padding', () => { + assert.equal(L.fmtTime(0), '0:00:00'); + assert.equal(L.fmtTime(59), '0:00:59'); + assert.equal(L.fmtTime(60), '0:01:00'); + assert.equal(L.fmtTime(3599), '0:59:59'); + assert.equal(L.fmtTime(3661), '1:01:01'); + assert.equal(L.fmtTime(90061), '25:01:01'); + assert.equal(L.fmtTime(-5), '0:00:00'); +}); + +test('parseTime parses 1-3 colon segments', () => { + assert.equal(L.parseTime('0'), 0); + assert.equal(L.parseTime('1:30'), 90); + assert.equal(L.parseTime('1:02:03'), 3723); + assert.equal(L.parseTime(' 2:00 '), 120); +}); + +test('parseTime rejects garbage', () => { + assert.equal(L.parseTime('abc'), null); + assert.equal(L.parseTime('1:xx'), null); + // Empty/whitespace parses to 0 (Number('') = 0) — callers treat end <= 0 as + // "whole video", so this is safe production behavior, documented here. + assert.equal(L.parseTime(''), 0); + assert.equal(L.parseTime(' '), 0); + assert.equal(L.parseTime(':30'), 30); // leading colon is tolerated +}); + +test('time round-trip: parseTime(fmtTime(sec)) === sec', () => { + for (const sec of [0, 1, 59, 60, 3599, 3661, 90061]) { + assert.equal(L.parseTime(L.fmtTime(sec)), sec, 'round-trip for ' + sec); + } +}); + +// ---- computeJob (trim / re-encode decision matrix) ------------------------- + +test('computeJob: full-video mp4, no transcode → plain stream copy', () => { + const j = L.computeJob({ start: 0, end: 3600, duration: 3600, capturedFrom: 0, isMp3: false, transcode: false }); + assert.deepEqual(j, { + trimStart: 0, trimDuration: 3600, isFragment: false, needsExactCut: false, + shortEnough: false, exactCut: false, doTranscode: false, alignedStart: false, quickEncode: false, + }); +}); + +test('computeJob: short fragment → frame-accurate re-encode cut', () => { + const j = L.computeJob({ start: 300, end: 360, duration: 3600, capturedFrom: 290, isMp3: false, transcode: false }); + assert.equal(j.trimStart, 10); // RELATIVE to the captured file, not 300 (the old bug) + assert.equal(j.trimDuration, 60); + assert.equal(j.isFragment, true); + assert.equal(j.needsExactCut, true); + assert.equal(j.shortEnough, true); + assert.equal(j.exactCut, true); + assert.equal(j.doTranscode, true); + assert.equal(j.alignedStart, false); + assert.equal(j.quickEncode, true); +}); + +test('computeJob: long fragment → keyframe-aligned copy, no re-encode', () => { + const j = L.computeJob({ start: 300, end: 600, duration: 3600, capturedFrom: 290, isMp3: false, transcode: false }); + assert.equal(j.trimStart, 10); + assert.equal(j.trimDuration, 300); + assert.equal(j.exactCut, false); + assert.equal(j.doTranscode, false); + assert.equal(j.alignedStart, true); +}); + +test('computeJob: user-selected H.264 wins over the copy path', () => { + const j = L.computeJob({ start: 300, end: 600, duration: 3600, capturedFrom: 290, isMp3: false, transcode: true }); + assert.equal(j.doTranscode, true); + assert.equal(j.alignedStart, false); + assert.equal(j.quickEncode, false); // user transcode, not an automatic exact cut +}); + +test('computeJob: mp3 always transcodes and never takes the exact-cut path', () => { + const j = L.computeJob({ start: 0, end: 3600, duration: 3600, capturedFrom: 0, isMp3: true, transcode: false }); + assert.equal(j.doTranscode, true); + assert.equal(j.exactCut, false); + assert.equal(j.alignedStart, false); +}); + +test('computeJob: trimStart inside keyframe tolerance (≤0.3s) needs no exact cut', () => { + const j = L.computeJob({ start: 300, end: 360, duration: 3600, capturedFrom: 299.9, isMp3: false, transcode: false }); + assert.ok(Math.abs(j.trimStart - 0.1) < 1e-9, 'trimStart ≈ 0.1, got ' + j.trimStart); + assert.equal(j.needsExactCut, false); + assert.equal(j.exactCut, false); + assert.equal(j.alignedStart, false); +}); + +test('computeJob: capturedFrom missing → trim from the requested start', () => { + // content_ui falls back to `start` when the hook doesn't report capturedFrom. + const j = L.computeJob({ start: 300, end: 360, duration: 3600, capturedFrom: 300, isMp3: false, transcode: false }); + assert.equal(j.trimStart, 0); + assert.equal(j.needsExactCut, false); +}); + +// ---- base64 --------------------------------------------------------------- + +test('b64 round-trip across the 0x8000 chunk boundary', () => { + for (const size of [0, 1, 3, 0x7fff, 0x8000, 0x8001, 100000]) { + const u8 = new Uint8Array(size); + for (let i = 0; i < size; i++) u8[i] = (i * 31 + (i >> 8)) & 0xff; // deterministic pseudo-random + const dec = L.b64decode(L.b64encode(u8)); + assert.equal(dec.length, size, 'length for size ' + size); + assert.deepEqual(dec, u8, 'bytes for size ' + size); + } +}); + +// ---- buildRuns (offscreen ffmpeg cascade) --------------------------------- + +test('buildRuns: mp3 → single mp3 run', () => { + const runs = L.buildRuns({ isMp3: true, transcode: false, quickEncode: false, trimStart: 0, trimDuration: 0, vName: null, aName: 'a.webm' }); + assert.equal(runs.length, 1); + assert.equal(runs[0].name, 'mp3'); + assert.equal(runs[0].ext, '.mp3'); + assert.ok(runs[0].args.includes('-c:a')); + assert.ok(runs[0].args.includes('libmp3lame')); +}); + +test('buildRuns: mp3 defaults to 192k and honours the menu bitrate', () => { + const def = L.buildRuns({ isMp3: true, transcode: false, quickEncode: false, trimStart: 0, trimDuration: 0, vName: null, aName: 'a.webm' }); + assert.ok(def[0].args.includes('192k'), 'default stays the original 192k'); + assert.ok(!def[0].args.includes('320k')); + const hi = L.buildRuns({ isMp3: true, transcode: false, quickEncode: false, trimStart: 0, trimDuration: 0, vName: null, aName: 'a.webm', mp3Bitrate: 320 }); + assert.ok(hi[0].args.includes('320k'), 'menu selection of 320 is honoured'); + // '320k' string input also normalises to '320k' + const str = L.buildRuns({ isMp3: true, transcode: false, quickEncode: false, trimStart: 0, trimDuration: 0, vName: null, aName: 'a.webm', mp3Bitrate: '320k' }); + assert.ok(str[0].args.includes('320k')); +}); + +test('buildRuns: transcode → single h264 run; quickEncode picks ultrafast', () => { + const plain = L.buildRuns({ isMp3: false, transcode: true, quickEncode: false, trimStart: 0, trimDuration: 0, vName: 'v.webm', aName: 'a.webm' }); + assert.equal(plain.length, 1); + assert.equal(plain[0].name, 'h264'); + assert.ok(plain[0].args.includes('veryfast')); + const quick = L.buildRuns({ isMp3: false, transcode: true, quickEncode: true, trimStart: 0, trimDuration: 0, vName: 'v.webm', aName: 'a.webm' }); + assert.ok(quick[0].args.includes('ultrafast')); +}); + +test('buildRuns: copy cascade order + untrimmed fallback only when trimming', () => { + const plain = L.buildRuns({ isMp3: false, transcode: false, quickEncode: false, trimStart: 0, trimDuration: 0, vName: 'v.mp4', aName: 'a.m4a' }); + assert.deepEqual(plain.map((r) => r.name), ['mp4-copy', 'webm-copy']); + const trimmed = L.buildRuns({ isMp3: false, transcode: false, quickEncode: false, trimStart: 10, trimDuration: 60, vName: 'v.mp4', aName: 'a.m4a' }); + assert.deepEqual(trimmed.map((r) => r.name), ['mp4-copy', 'mp4-copy-untrimmed', 'webm-copy']); + assert.equal(trimmed[trimmed.length - 1].name, 'webm-copy'); // last resort stays last +}); + +test('buildRuns: exact cut seeks (-ss) and limits (-t) with frame precision', () => { + const runs = L.buildRuns({ isMp3: false, transcode: true, quickEncode: false, trimStart: 10.5, trimDuration: 60, vName: 'v.webm', aName: 'a.webm' }); + const args = runs[0].args; + assert.ok(args.includes('-ss')); + assert.ok(args.includes('10.500')); + assert.ok(args.includes('-t')); + assert.ok(args.includes('60.000')); +}); + +test('buildRuns: copy path never seeks; trims to trimStart+trimDuration', () => { + const runs = L.buildRuns({ isMp3: false, transcode: false, quickEncode: false, trimStart: 10.5, trimDuration: 60, vName: 'v.webm', aName: 'a.webm' }); + const args = runs[0].args; // mp4-copy + assert.ok(!args.includes('-ss'), 'copy must not seek'); + assert.ok(args.includes('-t')); + assert.ok(args.includes('70.500')); + assert.ok(args.includes('-avoid_negative_ts')); +}); + +test('buildRuns: no video input (mp3) → args never reference the video file', () => { + const runs = L.buildRuns({ isMp3: true, transcode: false, quickEncode: false, trimStart: 0, trimDuration: 0, vName: null, aName: 'a.webm' }); + assert.ok(!runs[0].args.includes('v.')); +}); + +// ---- splitRange / extFor / concat / safeName / fragSuffix ----------------- + +test('splitRange splits into bounded parts and clamps the last one', () => { + const parts = L.splitRange(0, 3600, 900); + assert.equal(parts.length, 4); + assert.deepEqual(parts[0], { start: 0, end: 900 }); + assert.deepEqual(parts[3], { start: 2700, end: 3600 }); + assert.deepEqual(L.splitRange(0, 600, 900), [{ start: 0, end: 600 }]); + const last = L.splitRange(100, 3700, 900); + assert.equal(last.length, 4); + assert.equal(last[last.length - 1].end, 3700); +}); + +test('extFor guesses the container from the MIME', () => { + assert.equal(L.extFor('video/webm; codecs="vp9"'), 'webm'); + assert.equal(L.extFor('video/mp4'), 'mp4'); + assert.equal(L.extFor('audio/mpeg'), 'bin'); +}); + +test('concat joins byte chunks in order', () => { + const out = L.concat([new Uint8Array([1, 2]), new Uint8Array([3, 4, 5])]); + assert.deepEqual(out, new Uint8Array([1, 2, 3, 4, 5])); + assert.equal(L.concat([]).length, 0); +}); + +test('safeName strips illegal filename characters and caps length', () => { + assert.equal(L.safeName('a/b\\c:d*e?f"gi|j'), 'a b c d e f g h i j'); + assert.equal(L.safeName(' spaced out '), 'spaced out'); + assert.equal(L.safeName(''), 'video'); + assert.equal(L.safeName('x'.repeat(200)).length, 120); +}); + +test('safeName strips invisible format/control chars Chrome rejects ("Invalid filename")', () => { + // U+2060 WORD JOINER — exactly what the failing title wraps its text in + assert.equal(L.safeName('\u2060Masha x Maria Spichers - Otra noche (Video Oficial)\u2060'), + 'Masha x Maria Spichers - Otra noche (Video Oficial)'); + assert.equal(L.safeName('\u200bzero\u200b width\u200b'), 'zero width'); + assert.equal(L.safeName('\ufeffBOM'), 'BOM'); + assert.equal(L.safeName('\u00adsoft\u00ad hyphen'), 'soft hyphen'); + assert.equal(L.safeName('tab\there'), 'tabhere'); // control chars are deleted + assert.equal(L.safeName('trailing. '), 'trailing'); + assert.equal(L.safeName('\u2060\u2060'), 'video'); // all-invisible title falls back +}); + +test('fragSuffix only adds a suffix for real fragments', () => { + assert.equal(L.fragSuffix(0, 3600, 3600), ''); + assert.equal(L.fragSuffix(0, 3599.6, 3600), ''); // within the 0.5s tolerance + assert.equal(L.fragSuffix(60, 120, 3600), ' (0.01.00-0.02.00)'); + assert.equal(L.fragSuffix(0, 300, 3600), ' (0.00.00-0.05.00)'); +});