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)');
+});