Compare commits
3 Commits
613f8fb902
...
5e78374efb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e78374efb | ||
|
|
f341b0c76e | ||
|
|
5485a3c2f4 |
109
docs/youtube-save-unavailable.md
Normal file
109
docs/youtube-save-unavailable.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Rock Medley save investigation
|
||||
|
||||
Video: `wZzRoXymOUU` — **Rock Medley**, Petra - Topic, 605 seconds.
|
||||
Investigation date: 2026-10-03. Main was pulled with `--ff-only` before changes.
|
||||
No production shell, credentials, cookies, vault, deployment or proxy was used.
|
||||
|
||||
## Evidence
|
||||
|
||||
YouTube's public oEmbed endpoint returns HTTP 200, the correct title/artist and
|
||||
thumbnail. This confirms the ID, but not playback permission. The watch page's
|
||||
initial player response on this development machine returns:
|
||||
|
||||
```json
|
||||
{"status":"UNPLAYABLE","reason":"Video unavailable"}
|
||||
```
|
||||
|
||||
The server's metadata invocation (`-J --no-warnings URL`) and the exact cache
|
||||
save invocation fail with `ERROR: [youtube] wZzRoXymOUU: Video unavailable`.
|
||||
Providing Bun as the JS runtime mirrors the Docker image's `/etc/yt-dlp.conf`.
|
||||
The error occurs during player extraction, before selecting/downloading a
|
||||
format. The server uses a ladder of selectors, not a hard-coded format ID.
|
||||
|
||||
| Local extractor | Default result |
|
||||
| --- | --- |
|
||||
| PATH yt-dlp 2026.07.04 | Video unavailable |
|
||||
| Repository binary 2026.08.19 | Video unavailable |
|
||||
| Downloaded nightly 2026.09.27.232945 | Video unavailable |
|
||||
|
||||
With 2026.08.19, explicit `web_embedded`, `web_safari`, `tv_simply`, `android_vr`,
|
||||
`mweb` and `web_music` clients all fail for this song too. A control video
|
||||
(`0gfX0dFLaBc`) succeeds from the same machine/runtime. This is video-specific
|
||||
upstream playability refusal, not a demonstrated stale-extractor, missing-format
|
||||
or broken-JS-runtime problem. It is **not exclusively reproducible on prod**.
|
||||
The generic response does not establish region, IP, account or licensing as the
|
||||
specific cause. Cookies or PO tokens were not requested or tested.
|
||||
|
||||
Upstream release notes: [2026.08.19](https://github.com/yt-dlp/yt-dlp/releases/tag/2026.08.19).
|
||||
[yt-dlp documentation](https://github.com/yt-dlp/yt-dlp#dependencies) describes
|
||||
runtime dependencies; no dependency update resolved this local reproduction.
|
||||
The Dockerfile is unchanged because an update alone is not a proven remedy.
|
||||
Its existing release-download layer is cached by Docker, so a future intentional
|
||||
extractor update must rebuild that layer, rather than assume new app code updates
|
||||
it automatically.
|
||||
|
||||
## Changes
|
||||
|
||||
1. Shared extraction now tries a bounded client ladder for generic unavailable
|
||||
and missing-format errors, as well as bot checks. This covers both metadata
|
||||
probes and actual saves. Explicit private/member/age/country/removal errors,
|
||||
local disk errors, cancellation and explicit client overrides do not trigger
|
||||
that ladder. The final diagnostic preserves the original failure and lists
|
||||
attempted clients; it does not mislabel the song as deleted.
|
||||
2. Explicit Retry POSTs `/api/download/:id/retry` before preparing a device save.
|
||||
It clears stale preparation/stream hints and calls the normal cache with
|
||||
`force:true` to bypass a **failed row's** cooldown. It does not delete ready
|
||||
media or duplicate running jobs. USB-volume, free-space/budget, duration and
|
||||
validation rules still belong to the existing cache. Repeated failed retries
|
||||
are limited to one per song per 15 seconds. Normal automatic resumes do not
|
||||
force a retry. Manual Retry follows the same new client ladder.
|
||||
3. Downloads, Settings → Downloads & storage, and save-failure toasts use plain
|
||||
language. Expandable “Technical details” retains the raw tool error as text,
|
||||
never HTML. Controls remain usable in both themes at 390/1440 px.
|
||||
|
||||
## Remaining reviewer check
|
||||
|
||||
**These changes do not demonstrate a successful download of Rock Medley.**
|
||||
The local default and all tested clients still receive YouTube's refusal.
|
||||
To establish the production-specific reason, the reviewer should compare:
|
||||
|
||||
- Actual yt-dlp version and the invocation below inside the deployed container.
|
||||
- Logged-out YouTube playback on the homelab's outbound network versus the
|
||||
working phone network; repeat with the phone on that same network if possible.
|
||||
- If only authenticated playback works, confirm whether the video needs account
|
||||
access before considering the existing optional read-only cookies mechanism.
|
||||
Do not assume a PO token is required without extractor diagnostics saying so.
|
||||
- If the failure tracks a network/region, use a permitted outbound network where
|
||||
the video is available, or a permitted alternate source/upload/device copy.
|
||||
Merely changing a region hint does not prove actual playback eligibility.
|
||||
|
||||
No network, cookies, token, Docker or deployment workaround was guessed or
|
||||
silently enabled. After any confirmed environment correction, Retry now starts
|
||||
a genuine fresh attempt instead of just returning the old backoff failure.
|
||||
|
||||
```sh
|
||||
# Metadata probe, same as the server (Docker config supplies the Bun runtime)
|
||||
yt-dlp -J --no-warnings 'https://www.youtube.com/watch?v=wZzRoXymOUU'
|
||||
|
||||
# Cache save, same selector/merge/rate args as the server's defaults
|
||||
yt-dlp 'https://www.youtube.com/watch?v=wZzRoXymOUU' --no-warnings --no-playlist \
|
||||
-f 'bv*[height<=720][vcodec^=avc1]+ba[ext=m4a]/bv*[height<=720][vcodec^=avc1]+ba/b[ext=mp4][vcodec^=avc1]/bv*[height<=720]+ba/b[ext=mp4]/b' \
|
||||
--merge-output-format mp4 --limit-rate 2M -o /tmp/rock-medley-check.mp4
|
||||
```
|
||||
|
||||
Tests: `node --test frontend/*.test.js`; app syntax check; unbundled server build;
|
||||
`bun test server/ytdlp-resilience.test.js server/download-retry.test.js
|
||||
server/media-cache.test.js`; and
|
||||
`npx playwright test --config playwright.download-retry.config.js`.
|
||||
The existing pool tests additionally need an importable yt-dlp zipapp:
|
||||
`YTDLP_PATH=/path/to/zipapp bun test server/ytdlp-pool.test.js`. The installed
|
||||
PATH executable here is a Python entry-point script, not an importable zipapp;
|
||||
providing the downloaded nightly zipapp makes all pool tests pass without any
|
||||
production code or environment changes.
|
||||
|
||||
Final validation: **118 frontend unit tests, 44 server tests and 13 browser cases
|
||||
pass**, plus the app syntax check and server build. Browser cases cover the real
|
||||
Retry controls in both Downloads and Settings, normal resumes, friendly failures,
|
||||
44 px details controls, safe raw text and overflow at phone/desktop widths.
|
||||
A real iPhone Safari/PWA save should still be checked after deployment, especially
|
||||
pause/resume of a partial copy and retry after a network change.
|
||||
@@ -437,7 +437,7 @@ async function resumableOpfsSave(videoId, qs, hevc, onProgress, signal) {
|
||||
return { ok: false, error: 'the server copy kept changing — try again' };
|
||||
}
|
||||
|
||||
async function opfsDownload(videoId, { mux = false, onProgress = null, signal } = {}) {
|
||||
async function opfsDownload(videoId, { mux = false, retry = false, onProgress = null, signal } = {}) {
|
||||
if (!window.OPFS || !window.OPFS.isSupported()) {
|
||||
return { ok: false, error: 'OPFS not supported in this browser' };
|
||||
}
|
||||
@@ -452,6 +452,15 @@ async function opfsDownload(videoId, { mux = false, onProgress = null, signal }
|
||||
const qs = params.toString();
|
||||
const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`;
|
||||
|
||||
if (retry) {
|
||||
try {
|
||||
const response = await fetch(`/api/download/${encodeURIComponent(videoId)}/retry`, { method: 'POST', cache: 'no-store', signal });
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.ok) return { ok: false, error: result.error || 'Could not restart this save.' };
|
||||
} catch (error) {
|
||||
return { ok: false, paused: true, error: error.name === 'AbortError' ? 'Save paused' : 'Connection lost' };
|
||||
}
|
||||
}
|
||||
const resumed = await resumableOpfsSave(videoId, qs, params.get('hevc') === '1', onProgress, signal);
|
||||
if (resumed) return resumed;
|
||||
|
||||
@@ -1791,7 +1800,7 @@ function recordDeviceFile(id, res) {
|
||||
}
|
||||
|
||||
// Download a video into the permanent offline cache. Safe to call repeatedly.
|
||||
async function preload(video, { quiet = false, mux = false } = {}) {
|
||||
async function preload(video, { quiet = false, mux = false, retry = false } = {}) {
|
||||
const id = video.id;
|
||||
// Custom (edited) videos have no YouTube source to (re)download — their
|
||||
// media is produced once by the editor. Never route them through the normal
|
||||
@@ -1816,7 +1825,7 @@ async function preload(video, { quiet = false, mux = false } = {}) {
|
||||
await SaveSlots.acquire(controller.signal);
|
||||
slot = true;
|
||||
SaveQueue.progress(id, null);
|
||||
const res = await API.cacheDownload(id, { mux, signal: controller.signal, onProgress: (p) => SaveQueue.progress(id, p) });
|
||||
const res = await API.cacheDownload(id, { mux, retry, signal: controller.signal, onProgress: (p) => SaveQueue.progress(id, p) });
|
||||
if (res && res.paused) {
|
||||
// The partial stays on the device; SaveQueue picks it up again.
|
||||
if (!quiet) toast(`Paused “${video.title}” — it will continue when the connection is back`);
|
||||
@@ -1833,7 +1842,7 @@ async function preload(video, { quiet = false, mux = false } = {}) {
|
||||
warmThumb(thumbUrlFor(id, video));
|
||||
if (!quiet) toast(`Saved “${video.title}” ✓`);
|
||||
} else if (!quiet && !(res && res.paused)) {
|
||||
toast('⚠ ' + ((res && res.error) || 'Could not save video'));
|
||||
toast('⚠ ' + (window.DownloadErrors ? DownloadErrors.message(res?.error) : res?.error || 'Could not save video'));
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') SaveQueue.state(id, 'failed', e.message || 'Save failed');
|
||||
@@ -12560,7 +12569,7 @@ async function boot() {
|
||||
updateBottomChrome();
|
||||
if (window.Downloads) Downloads.configure({
|
||||
jobs: () => SaveQueue.pending().map(v => ({ ...v, status: downloading.has(v.id) ? (SaveQueue.get(v.id)?.phase === 'waiting' ? 'queued' : 'active') : v.status || 'paused', progress: SaveQueue.get(v.id) })),
|
||||
action: (id, action) => { if (action === 'pause') SaveQueue.pause(id); else if (action === 'cancel') SaveQueue.cancel(id); else { const v = SaveQueue.pending().find(v => v.id === id); if (v) preload(v, { quiet: true }); } },
|
||||
action: (id, action) => { if (action === 'pause') SaveQueue.pause(id); else if (action === 'cancel') SaveQueue.cancel(id); else { const v = SaveQueue.pending().find(v => v.id === id); if (v) preload(v, { quiet: true, retry: action === 'retry' }); } },
|
||||
playlists: () => data.playlists, slots: () => data.settings.parallelSaves || 4,
|
||||
files: async () => { const result = []; if (!WEB || !navigator.storage?.getDirectory) return result;
|
||||
const root = await navigator.storage.getDirectory();
|
||||
|
||||
28
frontend/download-errors.js
Normal file
28
frontend/download-errors.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/* Plain-language save failures, with the original diagnostic kept on demand. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
function message(error) {
|
||||
const raw = String(error?.message || error || '').trim();
|
||||
if (!raw) return 'Could not save this video. Try Retry.';
|
||||
if (/not available in your country|geo.?restricted/i.test(raw)) return 'This video is unavailable in the server’s region. It may still play on your device.';
|
||||
if (/private video|members.only|join this channel|confirm your age|age.restricted/i.test(raw)) return 'YouTube requires access this server does not have to save this video.';
|
||||
if (/removed by|video has been removed|copyright claim/i.test(raw)) return 'YouTube is no longer making this video available to the server.';
|
||||
if (/sign in to confirm|not a bot/i.test(raw)) return 'YouTube temporarily blocked the server’s download request. Try Retry later.';
|
||||
if (/video unavailable/i.test(raw)) return 'YouTube isn’t letting the server save this video. Try Retry; it may still play on YouTube.';
|
||||
if (/requested format (?:is )?not available|no video formats found/i.test(raw)) return 'No downloadable version was available. Try Retry to check other download methods.';
|
||||
if (/^ERROR:|^WARNING:|Traceback|yt-dlp exited|yt-dlp not found/i.test(raw)) return 'The server could not save this video. Try Retry.';
|
||||
// Existing plain-language connection/quota/validation messages remain useful.
|
||||
return raw.length <= 180 && !raw.includes('\n') ? raw : 'Could not save this video. Try Retry.';
|
||||
}
|
||||
function appendDetails(container, error) {
|
||||
if (!error) return;
|
||||
const doc = container.ownerDocument;
|
||||
const details = doc.createElement('details'); details.className = 'download-error-details';
|
||||
const summary = doc.createElement('summary'); summary.textContent = 'Technical details';
|
||||
const pre = doc.createElement('pre'); pre.textContent = String(error?.message || error);
|
||||
details.append(summary, pre); container.append(details);
|
||||
return details;
|
||||
}
|
||||
const api = { message, appendDetails };
|
||||
if (typeof module !== 'undefined') module.exports = api; else root.DownloadErrors = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
24
frontend/download-errors.test.js
Normal file
24
frontend/download-errors.test.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { message } = require('./download-errors.js');
|
||||
const { formatJobStatus } = require('./downloads-page.js');
|
||||
test('unavailable songs get useful copy without claiming that they were removed', () => {
|
||||
const raw = 'ERROR: [youtube] wZzRoXymOUU: Video unavailable\nYouTube clients tried: default, web_embedded, web_safari';
|
||||
assert.equal(message(raw), 'YouTube isn’t letting the server save this video. Try Retry; it may still play on YouTube.');
|
||||
assert.equal(formatJobStatus({ status: 'failed', error: raw }), 'Failed: ' + message(raw));
|
||||
});
|
||||
test('known restrictions and bot failures have distinct explanations', () => {
|
||||
assert.match(message('Video unavailable. not available in your country'), /server’s region/);
|
||||
assert.match(message('Video unavailable. Private video'), /requires access/);
|
||||
assert.match(message('Video unavailable. Confirm your age'), /requires access/);
|
||||
assert.match(message('Video has been removed'), /no longer/);
|
||||
assert.match(message('Sign in to confirm you are not a bot'), /temporarily blocked/);
|
||||
assert.match(message('Requested format is not available'), /other download methods/);
|
||||
});
|
||||
test('missing and raw tool failures get a short fallback; existing useful messages survive', () => {
|
||||
assert.equal(message(null), 'Could not save this video. Try Retry.');
|
||||
assert.equal(message('ERROR: mysterious extractor failure'), 'The server could not save this video. Try Retry.');
|
||||
assert.equal(message('Connection lost'), 'Connection lost');
|
||||
assert.equal(message('Please wait a few seconds before trying again.'), 'Please wait a few seconds before trying again.');
|
||||
assert.equal(message('a'.repeat(200)), 'Could not save this video. Try Retry.');
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
/* Downloads page UI module. Grouped jobs, storage info, confirmed cancellation. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors;
|
||||
|
||||
const bytes = n => {
|
||||
if (typeof n !== 'number' || isNaN(n) || n <= 0) return '0 MB';
|
||||
@@ -18,7 +19,7 @@
|
||||
|
||||
function formatJobStatus(job) {
|
||||
if (job.status === 'failed') {
|
||||
return job.error ? `Failed: ${job.error}` : 'Save failed';
|
||||
return job.error ? `Failed: ${errors.message(job.error)}` : 'Save failed';
|
||||
}
|
||||
if (job.status === 'paused') {
|
||||
return 'Paused — resume when you’re ready';
|
||||
@@ -215,6 +216,8 @@
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (isFailed && job.error) errors.appendDetails(row.querySelector('.card-info'), job.error);
|
||||
|
||||
const acts = doc.createElement('div');
|
||||
acts.className = 'dl-actions';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* Download controls and device storage accounting. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
const errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors;
|
||||
const bytes = n => n >= 1073741824 ? `${(n / 1073741824).toFixed(1)} GB` : `${(n / 1048576).toFixed(1)} MB`;
|
||||
function breakdown(files, playlists) {
|
||||
const kinds = { video: 0, audio: 0, eq: 0 }, byId = new Map();
|
||||
@@ -24,13 +25,15 @@
|
||||
for (const job of items) {
|
||||
const row = doc.createElement('div'); row.className = 'download-job';
|
||||
const title = doc.createElement('b'); title.textContent = job.title || job.id;
|
||||
const status = doc.createElement('small'); status.textContent = job.error || job.status;
|
||||
const status = doc.createElement('small'); status.textContent = job.error ? errors.message(job.error) : job.status;
|
||||
const progress = doc.createElement('progress'); progress.max = job.progress?.total || 1; if (job.progress?.total) progress.value = job.progress.received || 0; progress.setAttribute('aria-label', `Saving ${job.title || job.id}`);
|
||||
const actions = doc.createElement('div');
|
||||
for (const action of [...(['active', 'queued'].includes(job.status) ? ['pause'] : [job.status === 'failed' ? 'retry' : 'resume']), 'cancel']) {
|
||||
const button = doc.createElement('button'); button.type = 'button'; button.textContent = action[0].toUpperCase() + action.slice(1); button.setAttribute('aria-label', `${button.textContent} ${job.title || job.id}`); button.onclick = () => { adapter.action(job.id, action); signature = ''; paint(); }; actions.append(button);
|
||||
}
|
||||
row.append(title, status, progress, actions); jobs.append(row);
|
||||
row.append(title, status, progress, actions);
|
||||
if (job.error) errors.appendDetails(row, job.error);
|
||||
jobs.append(row);
|
||||
}
|
||||
}
|
||||
async function disk() {
|
||||
|
||||
@@ -660,6 +660,7 @@
|
||||
<script src="async-guard.js"></script>
|
||||
<script src="sw-update.js"></script>
|
||||
<script src="settings-sections.js"></script>
|
||||
<script src="download-errors.js"></script>
|
||||
<script src="downloads.js"></script>
|
||||
<script src="downloads-page.js"></script>
|
||||
<script src="saved-page.js"></script>
|
||||
|
||||
@@ -21,3 +21,13 @@ html[data-layout] .dl-row .card-info { grid-column:2; grid-row:1; min-width:0; }
|
||||
.dl-row .dl-act.danger { color:var(--accent); background:transparent; }
|
||||
.dl-row .dl-act:disabled { opacity:.5; cursor:wait; }
|
||||
@media(min-width:900px) { html[data-layout] .card.dl-row { grid-template-columns:88px minmax(0,1fr); } html[data-layout] .dl-row .thumb { width:88px; } }
|
||||
|
||||
/* Save failures remain readable; raw extractor output is opt-in, never HTML. */
|
||||
.dl-failed .card-channel { white-space:normal; line-height:1.5; }
|
||||
.download-error-details { min-width:0; color:var(--text-2); font:12px/1.5 var(--ui); }
|
||||
.download-error-details summary { min-height:44px; padding:12px 0; cursor:pointer; }
|
||||
.download-error-details summary:focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:4px; }
|
||||
.download-error-details pre { white-space:pre-wrap; overflow-wrap:anywhere; max-height:240px; overflow:auto; margin:0 0 8px; font:12px/1.5 var(--mono); }
|
||||
.download-job { grid-template-columns:minmax(0,1fr); }
|
||||
.download-job > div { flex-wrap:wrap; }
|
||||
.download-job progress { min-width:0; }
|
||||
|
||||
@@ -84,6 +84,7 @@ const SHELL = [
|
||||
'/p2p-transfer.js',
|
||||
'/p2p-recv-worker.js',
|
||||
'/settings-sections.js',
|
||||
'/download-errors.js',
|
||||
'/downloads.js',
|
||||
'/downloads-page.js',
|
||||
'/offline-pages.css',
|
||||
|
||||
2
playwright.download-retry.config.js
Normal file
2
playwright.download-retry.config.js
Normal file
@@ -0,0 +1,2 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
module.exports = defineConfig({ ...require('./playwright.classic.config'), testMatch: /download-retry\.spec\.js/ });
|
||||
28
server/download-retry.js
Normal file
28
server/download-retry.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// Explicit user retries bypass a failed cache row's backoff, not disk guards.
|
||||
export function registerDownloadRetryRoute(app, { media, priority, clearHints = () => {}, now = Date.now, log = console }) {
|
||||
const recent = new Map();
|
||||
app.post('/api/download/:videoId/retry', async c => {
|
||||
const id = (c.req.param('videoId') || '').trim();
|
||||
const headers = { 'Cache-Control': 'no-store' };
|
||||
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.json({ ok: false, error: 'invalid video id' }, 400, headers);
|
||||
// Already-ready and active jobs are reusable, so Retry never deletes a
|
||||
// playable copy or spawns a second downloader for the same song.
|
||||
const status = await media.status(id);
|
||||
if (status.status === 'ready' || status.status === 'queued' || ['downloading', 'validating'].includes(status.status)) {
|
||||
clearHints(id);
|
||||
return c.json({ ok: true, state: status.status === 'ready' ? 'ready' : 'working' }, 200, headers);
|
||||
}
|
||||
const time = now();
|
||||
if (recent.has(id) && time - recent.get(id) < 15_000) {
|
||||
return c.json({ ok: false, error: 'Please wait a few seconds before trying again.' }, 429, { ...headers, 'Retry-After': '15' });
|
||||
}
|
||||
recent.set(id, time);
|
||||
if (recent.size > 2000) for (const [key, at] of recent) if (time - at >= 15_000) recent.delete(key);
|
||||
if (recent.size > 2000) recent.delete(recent.keys().next().value);
|
||||
clearHints(id);
|
||||
// The existing cache owns the asynchronous job and deduplicates races.
|
||||
// Its normal USB-volume, free-space, size and validation checks still run.
|
||||
media.ensureCached(id, { priority, force: true }).catch(error => log.warn?.(`[download retry] ${id}: ${error.message}`));
|
||||
return c.json({ ok: true, state: 'working' }, 202, headers);
|
||||
});
|
||||
}
|
||||
52
server/download-retry.test.js
Normal file
52
server/download-retry.test.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { test, expect } from 'bun:test';
|
||||
import { Hono } from 'hono';
|
||||
import { registerDownloadRetryRoute } from './download-retry.js';
|
||||
function fixture(status = 'failed') {
|
||||
const app = new Hono(), calls = [], hints = [];
|
||||
let clock = 1000;
|
||||
registerDownloadRetryRoute(app, {
|
||||
media: { status: async () => ({ status }), ensureCached: async (id, options) => { calls.push([id, options]); } },
|
||||
priority: 0, clearHints: id => hints.push(id), now: () => clock, log: { warn() {} },
|
||||
});
|
||||
return { app, calls, hints, advance: () => { clock += 16000; } };
|
||||
}
|
||||
const request = app => app.request('/api/download/wZzRoXymOUU/retry', { method: 'POST' });
|
||||
test('explicit retry starts a fresh guarded cache job and clears stale hints', async () => {
|
||||
const { app, calls, hints } = fixture();
|
||||
const response = await request(app);
|
||||
expect(response.status).toBe(202);
|
||||
expect(await response.json()).toEqual({ ok: true, state: 'working' });
|
||||
expect(calls).toEqual([['wZzRoXymOUU', { priority: 0, force: true }]]);
|
||||
expect(hints).toEqual(['wZzRoXymOUU']);
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
});
|
||||
test('ready and in-progress songs are reused instead of redownloaded', async () => {
|
||||
for (const status of ['ready', 'queued', 'downloading', 'validating']) {
|
||||
const { app, calls } = fixture(status);
|
||||
expect((await request(app)).status).toBe(200);
|
||||
expect(calls).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
test('failed-job retries are bounded while later deliberate retries work', async () => {
|
||||
const f = fixture();
|
||||
expect((await request(f.app)).status).toBe(202);
|
||||
const blocked = await request(f.app);
|
||||
expect(blocked.status).toBe(429);
|
||||
expect(blocked.headers.get('retry-after')).toBe('15');
|
||||
expect(f.calls).toHaveLength(1);
|
||||
f.advance();
|
||||
expect((await request(f.app)).status).toBe(202);
|
||||
expect(f.calls).toHaveLength(2);
|
||||
});
|
||||
test('invalid ids never reach cache machinery and GET cannot force a retry', async () => {
|
||||
const { app, calls } = fixture();
|
||||
expect((await app.request('/api/download/bad-id/retry', { method: 'POST' })).status).toBe(400);
|
||||
expect((await app.request('/api/download/wZzRoXymOUU/retry')).status).toBe(404);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
test('asynchronous upstream failure is handled and remains available through status', async () => {
|
||||
const app = new Hono(), warnings = [];
|
||||
registerDownloadRetryRoute(app, { priority: 0, media: { status: async () => ({ status: 'failed' }), ensureCached: async () => { throw new Error('Video unavailable'); } }, log: { warn: detail => warnings.push(detail) } });
|
||||
expect((await request(app)).status).toBe(202);
|
||||
expect(warnings[0]).toContain('Video unavailable');
|
||||
});
|
||||
@@ -192,6 +192,23 @@ describe('media cache jobs', () => {
|
||||
expect(calls.length).toBe(1);
|
||||
});
|
||||
|
||||
test('force retry bypasses failed backoff, deduplicates and preserves ready copies', async () => {
|
||||
await clearDb();
|
||||
const { cache, calls, state } = makeCache({ fixture: 'truncated.mp4' });
|
||||
await cache.init();
|
||||
await expect(cache.ensureCached('retryAAAAA1', { priority: HIGH })).rejects.toThrow();
|
||||
await expect(cache.ensureCached('retryAAAAA1')).rejects.toMatchObject({ code: 'BACKOFF' });
|
||||
state.fixture = 'good.mp4';
|
||||
const [first, joined] = await Promise.all([
|
||||
cache.ensureCached('retryAAAAA1', { priority: HIGH, force: true }),
|
||||
cache.ensureCached('retryAAAAA1', { priority: HIGH, force: true }),
|
||||
]);
|
||||
expect(first.gen).toBe(joined.gen);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect((await cache.ensureCached('retryAAAAA1', { force: true })).gen).toBe(first.gen);
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('a faststart download with cut media data is rejected too', async () => {
|
||||
await clearDb();
|
||||
const { cache, dir } = makeCache({ fixture: 'truncated-fs.mp4' });
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
* UI code works without modification in WEB mode.
|
||||
* ========================================================================== */
|
||||
|
||||
import { registerDownloadRetryRoute } from './download-retry.js';
|
||||
import { createResilientYtdlp, BOT_CHECK_RE, DEFAULT_FALLBACK_CLIENTS } from './ytdlp-resilience.js';
|
||||
import { Hono } from 'hono';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import { logger } from 'hono/logger';
|
||||
@@ -207,55 +209,17 @@ function runYtdlp(args, opts = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
// YouTube intermittently answers the default (web) innertube client with
|
||||
// "Sign in to confirm you're not a bot" — a per-IP rate signal, not a
|
||||
// per-video one, so the SAME video that just saved fine fails minutes later
|
||||
// and the user sees "sign in required". Other player clients are not gated by
|
||||
// that check from a datacentre IP, so retry the whole yt-dlp call against each
|
||||
// in turn instead of demanding cookies. Order is quality-first: web_embedded
|
||||
// still exposes the adaptive DASH ladder (399+251), while tv_simply /
|
||||
// android_vr / mweb typically only offer progressive itag 18 (360p) — a 360p
|
||||
// save beats a failed save.
|
||||
const BOT_CHECK_RE = /Sign in to confirm|not a bot|confirm you.{0,3}re not a bot/i;
|
||||
// Retry client-specific availability/format failures in both metadata probes
|
||||
// and downloads. Explicit restrictions remain errors; this cannot make a
|
||||
// region/account-restricted song available to the server.
|
||||
const FALLBACK_CLIENTS = (process.env.YTDLP_FALLBACK_CLIENTS
|
||||
|| 'web_embedded,tv_simply,android_vr,mweb').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
// Optional cookies jar (Netscape format) for the rare case every client is
|
||||
// gated. Mounted read-only; absent by default and never required.
|
||||
|| DEFAULT_FALLBACK_CLIENTS.join(',')).split(',').map(s => s.trim()).filter(Boolean);
|
||||
const YTDLP_COOKIES = process.env.YTDLP_COOKIES || '';
|
||||
|
||||
function withCookies(args) {
|
||||
if (!YTDLP_COOKIES || !existsSync(YTDLP_COOKIES)) return args;
|
||||
return ['--cookies', YTDLP_COOKIES, ...args];
|
||||
}
|
||||
|
||||
// runYtdlp + bot-check fallback. Every YouTube-facing call goes through this.
|
||||
async function runYtdlpResilient(args, opts = {}) {
|
||||
const hasClientArg = args.some((a) => String(a).includes('player_client='));
|
||||
try {
|
||||
return await runYtdlp(withCookies(args), opts);
|
||||
} catch (err) {
|
||||
if (hasClientArg || !BOT_CHECK_RE.test(err.message)) throw err;
|
||||
if (opts.signal?.aborted) throw err;
|
||||
let last = err;
|
||||
for (const client of FALLBACK_CLIENTS) {
|
||||
if (opts.signal?.aborted) throw last;
|
||||
try {
|
||||
const out = await runYtdlp(
|
||||
withCookies(['--extractor-args', `youtube:player_client=${client}`, ...args]),
|
||||
opts,
|
||||
);
|
||||
console.warn(`[ytplayer] bot check on default client, succeeded via player_client=${client}`);
|
||||
return out;
|
||||
} catch (e) {
|
||||
last = e;
|
||||
// A client that simply lacks the requested format is not a bot check;
|
||||
// keep walking the list either way, but surface the last real error.
|
||||
if (!BOT_CHECK_RE.test(e.message) && !/format is not available/i.test(e.message)) throw e;
|
||||
}
|
||||
}
|
||||
throw last;
|
||||
}
|
||||
}
|
||||
const runYtdlpResilient = createResilientYtdlp({ run: runYtdlp, withCookies, clients: FALLBACK_CLIENTS });
|
||||
|
||||
// Run ffmpeg the same way — async spawn so a multi-minute trim/concat never
|
||||
// blocks Bun's event loop. Rejects on non-zero exit with ffmpeg's stderr tail.
|
||||
@@ -1637,6 +1601,9 @@ app.post('/api/media/:id/redownload', async (c) => {
|
||||
// Saves fetch the copy in byte ranges (docs/resumable-downloads-plan.md):
|
||||
// the ETag pins the generation, so a resumed save never mixes two copies.
|
||||
const prepareSkips = new Map(); // videoId → { reason, at } for /prepare
|
||||
registerDownloadRetryRoute(app, {
|
||||
media, priority: HIGH, clearHints: id => { prepareSkips.delete(id); streamCache.delete(id); },
|
||||
});
|
||||
const DOWNLOAD_EXPOSE = 'X-Content-SHA256, Content-Range, Content-Length, ETag, Accept-Ranges';
|
||||
function cachedDownloadResponse(c, videoId, fp, row) {
|
||||
if (fp && !c.req.header('range')) recordVideoAccess(fp, { id: videoId }).catch(() => {});
|
||||
|
||||
39
server/ytdlp-resilience.js
Normal file
39
server/ytdlp-resilience.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// Bounded client fallbacks shared by metadata probes and actual downloads.
|
||||
export const BOT_CHECK_RE = /Sign in to confirm|not a bot|confirm you.{0,3}re not a bot/i;
|
||||
export const DEFAULT_FALLBACK_CLIENTS = ['web_embedded', 'web_safari', 'tv_simply', 'android_vr', 'mweb'];
|
||||
|
||||
export function canTryAnotherClient(error) {
|
||||
const message = String(error?.message || error || '');
|
||||
// Explicit access restrictions are not fixed by selecting a different client.
|
||||
if (/private video|members.only|join this channel|not available in your country|geo.?restricted|removed by|video has been removed|copyright claim|age.restricted|confirm your age/i.test(message)) return false;
|
||||
return BOT_CHECK_RE.test(message) || /video unavailable|requested format (?:is )?not available|no video formats found/i.test(message);
|
||||
}
|
||||
|
||||
export function createResilientYtdlp({ run, withCookies = args => args, clients = DEFAULT_FALLBACK_CLIENTS, log = console }) {
|
||||
return async function runResilient(args, opts = {}) {
|
||||
const explicitClient = args.some(arg => String(arg).includes('player_client=') || String(arg).includes('player-client='));
|
||||
if (opts.signal?.aborted) throw opts.signal.reason || new Error('save cancelled');
|
||||
try {
|
||||
return await run(withCookies(args), opts);
|
||||
} catch (initial) {
|
||||
if (explicitClient || opts.signal?.aborted || !canTryAnotherClient(initial)) throw initial;
|
||||
const failures = [{ client: 'default', error: initial }];
|
||||
for (const client of [...new Set(clients)]) {
|
||||
if (opts.signal?.aborted) throw opts.signal.reason || initial;
|
||||
try {
|
||||
const out = await run(withCookies(['--extractor-args', `youtube:player_client=${client}`, ...args]), opts);
|
||||
log.warn?.(`[ytplayer] YouTube fallback succeeded via player_client=${client}`);
|
||||
return out;
|
||||
} catch (error) {
|
||||
failures.push({ client, error });
|
||||
if (opts.signal?.aborted || !canTryAnotherClient(error)) throw error;
|
||||
}
|
||||
}
|
||||
// Keep the original useful diagnosis, not the final client's weaker
|
||||
// "no formats" error. Retain the bounded attempt trail for diagnostics.
|
||||
const error = new Error(`${initial.message}\nYouTube clients tried: ${failures.map(f => f.client).join(', ')}`, { cause: initial });
|
||||
error.attempts = failures.map(({ client, error }) => ({ client, detail: String(error.message || error) }));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
59
server/ytdlp-resilience.test.js
Normal file
59
server/ytdlp-resilience.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { test, expect } from 'bun:test';
|
||||
import { createResilientYtdlp, canTryAnotherClient } from './ytdlp-resilience.js';
|
||||
const quiet = { warn() {} };
|
||||
const unavailable = () => new Error('ERROR: [youtube] wZzRoXymOUU: Video unavailable');
|
||||
|
||||
test('metadata and save calls retry unavailable videos with alternate clients', async () => {
|
||||
for (const args of [['-J', '--no-warnings', 'https://www.youtube.com/watch?v=wZzRoXymOUU'], ['https://www.youtube.com/watch?v=wZzRoXymOUU', '-f', 'bv*+ba/b', '--merge-output-format', 'mp4', '-o', '/tmp/save.mp4']]) {
|
||||
const calls = [];
|
||||
const signal = new AbortController().signal;
|
||||
const run = createResilientYtdlp({ clients: ['web_embedded', 'web_safari'], log: quiet,
|
||||
withCookies: argv => ['--cookies', '/fixture/cookies.txt', ...argv],
|
||||
run: async (argv, opts) => { calls.push([argv, opts]); if (calls.length < 3) throw calls.length === 1 ? unavailable() : new Error('Requested format is not available'); return 'success'; },
|
||||
});
|
||||
expect(await run(args, { signal })).toBe('success');
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[2][0]).toEqual(['--cookies', '/fixture/cookies.txt', '--extractor-args', 'youtube:player_client=web_safari', ...args]);
|
||||
expect(calls.every(([, opts]) => opts.signal === signal)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('an initial missing-format error also gets a client fallback', async () => {
|
||||
let calls = 0;
|
||||
const run = createResilientYtdlp({ log: quiet, clients: ['web_safari'], run: async () => { if (++calls === 1) throw new Error('Requested format is not available'); return 'ok'; } });
|
||||
expect(await run(['-f', 'b'])).toBe('ok');
|
||||
});
|
||||
|
||||
test('exhaustion preserves the original diagnosis and the attempt trail', async () => {
|
||||
let calls = 0;
|
||||
const run = createResilientYtdlp({ clients: ['web_embedded', 'web_embedded', 'mweb'], log: quiet, run: async () => { if (++calls === 1) throw unavailable(); throw new Error('Requested format is not available'); } });
|
||||
const err = await run(['-J']).catch(e => e);
|
||||
expect(calls).toBe(3);
|
||||
expect(err.message).toContain('wZzRoXymOUU: Video unavailable');
|
||||
expect(err.message).toContain('default, web_embedded, mweb');
|
||||
expect(err.attempts).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('explicit client selection and unrelated failures are not retried', async () => {
|
||||
for (const [args, message] of [[['--extractor-args', 'youtube:player_client=mweb'], 'Video unavailable'], [['--extractor-args', 'youtube:player-client=mweb'], 'Video unavailable'], [[], 'No space left on device'], [[], 'yt-dlp not found'], [[], 'HTTP Error 429: Too Many Requests']]) {
|
||||
let calls = 0;
|
||||
const run = createResilientYtdlp({ log: quiet, run: async () => { calls++; throw new Error(message); } });
|
||||
await expect(run(args)).rejects.toThrow(message);
|
||||
expect(calls).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit access restrictions stop retries, even with an unavailable prefix', () => {
|
||||
for (const detail of ['Private video', 'Video has been removed', 'not available in your country', 'members-only', 'Confirm your age', 'copyright claim']) expect(canTryAnotherClient(new Error('Video unavailable. ' + detail))).toBe(false);
|
||||
expect(canTryAnotherClient(new Error('Sign in to confirm you are not a bot'))).toBe(true);
|
||||
});
|
||||
|
||||
test('cancellation stops the fallback chain', async () => {
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
const run = createResilientYtdlp({ log: quiet, run: async () => { calls++; controller.abort(); throw unavailable(); } });
|
||||
await expect(run(['-J'], { signal: controller.signal })).rejects.toThrow('Video unavailable');
|
||||
expect(calls).toBe(1);
|
||||
await expect(run(['-J'], { signal: controller.signal })).rejects.toThrow();
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
72
tests/download-retry.spec.js
Normal file
72
tests/download-retry.spec.js
Normal file
@@ -0,0 +1,72 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openClassic } = require('./helpers/classic-fixture');
|
||||
const id = 'wZzRoXymOUU';
|
||||
|
||||
for (const layout of ['classic', 'glass-stage']) for (const screen of ['downloads', 'settings']) {
|
||||
test(`${layout} ${screen}: Retry starts a fresh server job before preparation`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openClassic(page, { settings: { autoPreload: false } });
|
||||
const requests = [];
|
||||
await page.route(`**/api/download/${id}/retry`, route => {
|
||||
requests.push('retry:' + route.request().method());
|
||||
return route.fulfill({ status: 202, contentType: 'application/json', body: '{"ok":true,"state":"working"}' });
|
||||
});
|
||||
await page.route(`**/api/download/${id}/prepare*`, route => {
|
||||
requests.push('prepare');
|
||||
return route.fulfill({ contentType: 'application/json', body: '{"ok":true,"state":"ready","etag":"fixture","size":4096,"ext":"mp4"}' });
|
||||
});
|
||||
await page.evaluate(({ layout, screen, id }) => {
|
||||
data.settings.layout = layout; applyAppearance();
|
||||
window.OPFS.isSupported = () => true;
|
||||
window.OPFS.downloadVideo = async () => ({ ok: true, size: 4096 });
|
||||
SaveQueue.add({ id, title: 'Rock Medley', channel: 'Petra - Topic' });
|
||||
SaveQueue.state(id, 'failed', `ERROR: [youtube] ${id}: Video unavailable`);
|
||||
view = { type: screen }; render();
|
||||
}, { layout, screen, id });
|
||||
if (screen === 'settings') await page.locator('[data-section="downloads-storage"]').click();
|
||||
await page.getByRole('button', { name: 'Retry Rock Medley', exact: true }).click();
|
||||
await expect.poll(() => requests).toEqual(['retry:POST', 'prepare']);
|
||||
await expect.poll(() => page.evaluate(id => cachedIds.has(id), id)).toBe(true);
|
||||
expect(await page.evaluate(() => SaveQueue.pending().length)).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('normal resumptions do not force the server to restart a cache job', async ({ page }) => {
|
||||
await openClassic(page, { settings: { autoPreload: false } });
|
||||
let retries = 0;
|
||||
await page.route(`**/api/download/${id}/retry`, route => { retries++; return route.fulfill({ contentType: 'application/json', body: '{"ok":true}' }); });
|
||||
await page.route(`**/api/download/${id}/prepare*`, route => route.fulfill({ contentType: 'application/json', body: '{"ok":true,"state":"ready","etag":"fixture","size":4096}' }));
|
||||
await page.evaluate(async id => {
|
||||
window.OPFS.isSupported = () => true;
|
||||
window.OPFS.downloadVideo = async () => ({ ok: true, size: 4096 });
|
||||
await preload({ id, title: 'Rock Medley' }, { quiet: true });
|
||||
}, id);
|
||||
expect(retries).toBe(0);
|
||||
expect(await page.evaluate(id => cachedIds.has(id), id)).toBe(true);
|
||||
});
|
||||
|
||||
for (const layout of ['classic', 'glass-stage']) for (const width of [390, 1440]) for (const screen of ['downloads', 'settings']) {
|
||||
test(`${layout} ${width} ${screen}: friendly failure retains safe technical details`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await openClassic(page, { settings: { autoPreload: false } });
|
||||
const raw = `ERROR: [youtube] ${id}: Video unavailable\nYouTube clients tried: default, web_embedded, web_safari\n<script>window.unsafeDetail=true</script>`;
|
||||
await page.evaluate(({ layout, screen, id, raw }) => {
|
||||
data.settings.layout = layout; applyAppearance();
|
||||
SaveQueue.add({ id, title: 'Rock Medley', channel: 'Petra - Topic' });
|
||||
SaveQueue.state(id, 'failed', raw);
|
||||
view = { type: screen }; render();
|
||||
}, { layout, screen, id, raw });
|
||||
if (screen === 'settings') await page.locator('[data-section="downloads-storage"]').click();
|
||||
const row = page.locator(screen === 'settings' ? '.download-job' : '.dl-failed').first();
|
||||
await expect(row).toContainText('YouTube isn’t letting the server save this video.');
|
||||
await expect(row.locator('pre')).toBeHidden();
|
||||
const summary = row.locator('summary');
|
||||
expect((await summary.boundingBox()).height).toBeGreaterThanOrEqual(44);
|
||||
await summary.click();
|
||||
await expect(row.locator('pre')).toHaveText(raw);
|
||||
expect(await page.evaluate(() => window.unsafeDetail)).toBeUndefined();
|
||||
const bounds = await row.evaluate(el => ({ width: el.clientWidth, content: el.scrollWidth, children: [...el.querySelectorAll('*')].filter(child => child.scrollWidth > child.clientWidth + 1).map(child => [child.tagName, child.className, child.clientWidth, child.scrollWidth]) }));
|
||||
expect(bounds.content <= bounds.width + 1, JSON.stringify(bounds)).toBe(true);
|
||||
await expect(row.getByRole('button', { name: 'Retry Rock Medley', exact: true })).toBeVisible();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user