Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ No `yt-dlp`, no third‑party sites or servers: everything runs locally in your
compatibility with older players, slow).
- **Auto‑disables Autoplay** — the extension turns off YouTube's "Autoplay next" so the next
video won't start on its own.
- **SponsorBlock** — optionally removes sponsor segments, self-promotion, and interaction
reminders; lookups use k-anonymity and never send the complete video ID.

## ⚠️ Requirements

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
(перекодирование для совместимости со старыми плеерами, медленно).
- **Авто-отключение автовоспроизведения** — расширение само выключает «Автовоспроизведение»
YouTube, чтобы следующий ролик не запускался сам.
- **SponsorBlock** — по желанию вырезает спонсорские вставки, саморекламу и призывы
подписаться; поиск выполняется с k-анонимностью, без передачи идентификатора ролика целиком.

## ⚠️ Требования

Expand Down
138 changes: 133 additions & 5 deletions extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,110 @@
// performs the final chrome.downloads save. ffmpeg.wasm cannot run here (a
// service worker has no DOM/Worker/document that ffmpeg needs), so all muxing
// happens in the offscreen document; the worker only orchestrates.
// Also queries the SponsorBlock API (the content script cannot: the page's CORS
// applies there, while the extension's host_permissions apply here).

let creating = null; // de-dupe concurrent createDocument calls
let creating = null; // de-dupe concurrent createDocument calls
let progressTab = null; // tab that started the current job, for progress relay

// Is the offscreen document actually responding? hasDocument() keeps returning
// true for a crashed (e.g. out-of-memory) document, so we ping it and recreate
// on silence — otherwise every later chunk message goes nowhere and the transfer
// dies with "передача данных прервалась".
function pingOffscreen() {
return Promise.race([
chrome.runtime.sendMessage({ t: 'ytdl-ping' })
.then((r) => !!(r && r.pong)).catch(() => false),
new Promise((res) => setTimeout(() => res(false), 2000)),
]);
}

// createDocument() resolves as soon as the document exists — NOT when offscreen.js
// has run. offscreen.html first loads the (large) vendor ffmpeg.js, so for a while
// there is a live document with no onMessage listener: every message sent in that
// window resolves to undefined and the transfer dies with "передача данных
// прервалась". So we always wait for a real pong before reporting readiness.
async function waitForOffscreen(attempts) {
for (let i = 0; i < attempts; i++) {
if (await pingOffscreen()) return true;
await new Promise((r) => setTimeout(r, 300));
}
return false;
}

async function ensureOffscreen() {
const has = await chrome.offscreen.hasDocument();
if (has) return;
if (creating) { await creating; return; }
if (creating) { await creating; }

if (await chrome.offscreen.hasDocument()) {
if (await waitForOffscreen(10)) return; // alive (or still booting) → done
try { await chrome.offscreen.closeDocument(); } catch (e) {} // crashed → recreate
}

creating = chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['WORKERS', 'BLOBS'],
justification: 'Run ffmpeg.wasm to mux captured video and audio tracks into an MP4.',
});
try { await creating; } finally { creating = null; }
try { await creating; } catch (e) { /* may already exist — the ping below decides */ }
finally { creating = null; }

if (!(await waitForOffscreen(40))) {
// Silence almost always means offscreen.js never ran, and the usual reason is
// a missing vendor build: offscreen.html loads vendor/ffmpeg/ffmpeg.js first,
// and if that 404s then FFmpegWASM is undefined, offscreen.js throws on its
// very first line and no message listener is ever registered.
const missing = await missingFiles();
throw new Error(missing.length
? 'в папке расширения нет файлов: ' + missing.join(', ') +
' — скопируйте их из репозитория и перезагрузите расширение'
: 'offscreen-документ не отвечает');
}
}

async function missingFiles() {
const need = ['offscreen.html', 'offscreen.js',
'vendor/ffmpeg/ffmpeg.js', 'vendor/ffmpeg/ffmpeg-core.js', 'vendor/ffmpeg/ffmpeg-core.wasm'];
const missing = [];
for (const f of need) {
try {
const r = await fetch(chrome.runtime.getURL(f), { method: 'GET' });
if (!r.ok) missing.push(f);
} catch (e) { missing.push(f); }
}
return missing;
}

// ---- SponsorBlock ---------------------------------------------------------
// Which segment categories get cut out of the download:
// sponsor — paid sponsor ads
// selfpromo — self-promotion / merch
// interaction — "like & subscribe" reminders
const SB_API = 'https://sponsor.ajay.app';
const SB_CATEGORIES = ['sponsor', 'selfpromo', 'interaction'];

async function sha256Hex(s) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
}

// Privacy-preserving lookup (k-anonymity): we send only the first 4 hex chars of
// sha256(videoID); the server returns all matching videos and we pick ours locally,
// so the API never learns which exact video was requested.
async function sbFetchSegments(videoId) {
if (!videoId) return [];
const prefix = (await sha256Hex(videoId)).slice(0, 4);
const url = SB_API + '/api/skipSegments/' + prefix +
'?categories=' + encodeURIComponent(JSON.stringify(SB_CATEGORIES));
const resp = await fetch(url);
if (resp.status === 404) return []; // no segments for this prefix
if (!resp.ok) throw new Error('SponsorBlock HTTP ' + resp.status);
const list = await resp.json();
const entry = Array.isArray(list) ? list.find((v) => v.videoID === videoId) : null;
if (!entry || !Array.isArray(entry.segments)) return [];
return entry.segments
.filter((s) => (s.actionType ? s.actionType === 'skip' : true))
.filter((s) => Array.isArray(s.segment) && s.segment[1] > s.segment[0])
.map((s) => ({ start: s.segment[0], end: s.segment[1], category: s.category }));
}

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
Expand All @@ -28,6 +119,43 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
return true; // async
}

// Proxy for everything the offscreen document owns (begin / chunk / finalize).
// The content script used to message the offscreen document directly, but a
// content script and an offscreen document are not guaranteed to share a
// messaging channel — the send resolved to undefined and the transfer died.
// The service worker CAN talk to the offscreen document, so it relays.
// ffmpeg progress travels offscreen → worker → tab: runtime.sendMessage from an
// extension page does not reach content scripts, only tabs.sendMessage does.
if (msg.t === 'ytdl-progress') {
if (progressTab != null) {
chrome.tabs.sendMessage(progressTab, msg).catch(() => {});
}
return;
}

if (msg.t === 'ytdl-proxy') {
(async () => {
const inner = msg.m || {};
if (inner.t === 'ytdl-begin') {
progressTab = (sender && sender.tab && sender.tab.id) != null ? sender.tab.id : null;
await ensureOffscreen();
}
const r = await chrome.runtime.sendMessage(inner);
if (r === undefined) return { ok: false, error: 'offscreen не ответил на ' + inner.t };
return r;
})()
.then(sendResponse)
.catch((e) => sendResponse({ ok: false, error: String((e && e.message) || e) }));
return true; // async
}

if (msg.t === 'ytdl-sb-get') {
sbFetchSegments(String(msg.videoId || ''))
.then((segments) => sendResponse({ ok: true, segments }))
.catch((e) => sendResponse({ ok: false, error: String(e) }));
return true; // async
}

if (msg.t === 'ytdl-save') {
// Offscreen finished muxing and handed us a blob URL to save.
chrome.downloads.download({ url: msg.url, filename: msg.filename, saveAs: false })
Expand Down
87 changes: 49 additions & 38 deletions extension/content_hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,30 +182,33 @@
const dur = v.duration;
if (!isFinite(dur) || dur <= 0) throw new Error('duration unknown');
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();

const prev = { paused: v.paused, rate: v.playbackRate, time: v.currentTime, muted: v.muted };
keepAutoplayOff();
try { v.muted = true; } catch (e) {}
try { v.pause(); } catch (e) {}

// Every seek we make goes through goTo so we remember where we asked the
// playhead to be. Anything further ahead than that was moved by someone else
// (see the external-skip rescue in the capture loop below).
let lastReq = v.currentTime;
const goTo = (t) => { lastReq = t; seekVia(t); };

// 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
// 1) switch to a low quality and seek to a NON-ZERO position (so position 0 is
// left unbuffered). Seeking to 0 later is then a real jump that forces BOTH
// tracks to re-fetch from the start — 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));
// 2) start recording, switch to the target quality, then seek to 0.
setQualityRaw(preQ);
await sleep(500);
seekVia(preSeek);
goTo(Math.min(35, dur * 0.4));
await sleep(700);
resetTracks();
store.capturing = true;
setQualityRaw(targetQ);
seekVia(capStart);
goTo(0);
await sleep(500);

// wait until the tracks we need have their init before entering the capture loop
Expand All @@ -224,44 +227,57 @@
}
return t;
};
// Where the captured data actually begins: the player can only start at a segment
// boundary at or before capStart, so the file may lead in by a few seconds. The
// caller needs this to trim RELATIVE to the file (ffmpeg's -ss counts from the
// file's own start, not from the video's absolute timeline).
const bufferedStartAt = (t) => {
for (let i = 0; i < v.buffered.length; i++) {
if (v.buffered.start(i) <= t + 0.5 && v.buffered.end(i) >= t) return v.buffered.start(i);
}
return t;
};
let capturedFrom = capStart;
let cursor = capStart, stall = 0;
const span = Math.max(0.1, capEnd - capStart);
let cursor = 0, stall = 0, rescues = 0;
const started = Date.now();
try {
try { v.pause(); } catch (e) {}
capturedFrom = Math.min(capStart, bufferedStartAt(capStart));
while (true) {
await sleep(350);
if (vidId() !== capId) throw new Error('видео переключилось во время захвата');
try { if (!v.paused) v.pause(); } catch (e) {} // keep it paused; buffering runs anyway

// --- external skip rescue ------------------------------------------
// Another extension (typically the standalone SponsorBlock) jumps the
// playhead over sponsor segments. The skipped range is then never fetched,
// the contiguous buffer edge stops growing at the segment start, and the
// capture hangs there forever. The video is paused during capture, so ANY
// forward jump beyond what we asked for is external. Pull the playhead back:
// already-buffered ranges are not re-fetched (no duplicated bytes), and
// SponsorBlock does not re-skip a segment it has already skipped once.
if (v.currentTime > lastReq + 2) {
rescues++;
console.warn('[YTDL] внешняя перемотка ' + lastReq.toFixed(1) + ' → ' +
v.currentTime.toFixed(1) + ' — возвращаю плейхед (' + rescues + ')');
if (rescues > 20) {
throw new Error('стороннее расширение перематывает видео (SponsorBlock?) — ' +
'отключите его на youtube.com: встроенное вырезание уже есть здесь');
}
goTo(Math.min(cursor, capEnd - 0.1));
stall = 0;
continue;
}

const edge = bufferedEndAt(cursor);
onProgress(Math.min(0.99, Math.max(0, edge - capStart) / span));
if (edge >= capEnd - 0.6) break; // range fully buffered → captured
onProgress(Math.min(0.99, edge / capEnd));
if (edge >= capEnd - 0.6) break; // fully buffered → captured

if (edge > cursor + 0.3) { // window extended → hop to the edge
cursor = edge;
seekVia(Math.min(cursor, capEnd - 0.1));
goTo(Math.min(cursor, capEnd - 0.1));
stall = 0;
} else { // plateaued → nudge to re-trigger fetch
stall++;
if (stall % 4 === 0) seekVia(Math.min(cursor + 0.1, capEnd - 0.1));
if (stall >= 60) break; // ~21s with no progress → give up
if (stall % 4 === 0) goTo(Math.min(cursor + 0.1, capEnd - 0.1));
if (stall >= 60) { // ~21s with no progress → give up
if (rescues > 0) {
throw new Error('захват встал на отрезке, который пропускает стороннее ' +
'расширение (SponsorBlock?) — отключите его на youtube.com');
}
break;
}
}
if (Date.now() - started > 20 * 60 * 1000) break; // hard cap
}
capturedFrom = Math.min(capturedFrom, bufferedStartAt(capStart));
} finally {
store.capturing = false;
// restore player state
Expand All @@ -272,7 +288,6 @@
if (!prev.paused) { try { v.play(); } catch (e) {} }
}
onProgress(1);
return { capturedFrom: Math.max(0, capturedFrom) };
}

// ---- subtitles (read from the built-in transcript panel) -----------------
Expand Down Expand Up @@ -395,7 +410,7 @@
// ---- bridge to the isolated-world UI script ------------------------------
window.addEventListener('message', async (ev) => {
if (ev.source !== window || !ev.data || ev.data.__ytdl_to_hook !== true) return;
const { cmd, reqId, height, format, start, end } = ev.data;
const { cmd, reqId, height, format, end } = ev.data;
const reply = (payload, transfer) => window.postMessage(
Object.assign({ __ytdl_from_hook: true, reqId }, payload), '*', transfer || []);
try {
Expand All @@ -413,17 +428,13 @@
// (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 },
await playthrough(
{ targetQ, preQ, end, needVideo: !isMp3 },
(pct) => reply({ progress: pct, phase: 'buffering' }));

const aud = assemble('audio');
if (!aud) throw new Error('не удалось захватить аудио');
const payload = {
ok: true, done: true,
capturedFrom: cap.capturedFrom, // where the captured file actually begins
audio: { mime: aud.mime, size: aud.bytes.byteLength },
};
const payload = { ok: true, done: true, audio: { mime: aud.mime, size: aud.bytes.byteLength } };
const transfers = [aud.bytes.buffer];
payload._a = aud.bytes.buffer;
if (!isMp3) {
Expand Down
2 changes: 2 additions & 0 deletions extension/content_ui.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
border-radius: 12px;
padding: 6px;
min-width: 240px;
max-height: 70vh;
overflow-y: auto;
box-shadow: 0 8px 28px rgba(0,0,0,.55);
font-family: "YouTube Sans", Roboto, Arial, sans-serif;
}
Expand Down
Loading