Skip to content

YouTube import diagnostics, an opt-in cookies file, and 0.75x playback - #439

Merged
thcp merged 6 commits into
mainfrom
fix/youtube-extraction-and-speed-presets
Aug 25, 2026
Merged

YouTube import diagnostics, an opt-in cookies file, and 0.75x playback#439
thcp merged 6 commits into
mainfrom
fix/youtube-extraction-and-speed-presets

Conversation

@thcp

@thcp thcp commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Five commits, four issues, one shared root: download.py built its yt-dlp options in four separate places and every failure came back as the word unknown.

Closes #433
Closes #434
Closes #435
Closes #436

Partially addresses #432. The JS challenge solver is held for investigation and tracked in #438.

What changed

Playback speed (#433). Presets are 0.75x and 1x. Below roughly 0.7x the time-stretch artefacts dominate and the part gets harder to follow, which is the opposite of what a practice speed is for. The preset list, the button id list and the markup all have to agree, so all three move together. No i18n keys change, the labels are numeric.

Failure diagnostics (#434). Every yt-dlp failure arrived as the bare word unknown, so diagnosing one meant reading data/logs/. Two independent causes, either enough on its own: _CAUSE_PATTERNS had no download category, and _quarantine_failed_job only appended a message when the exception carried a stderr tail, which only SeparationError does. Adds source-blocked and source-unavailable, ordered after the resource-level causes so a disk-full during a download is still a disk-full, and falls back to the exception message. The message goes through redact() first, since yt-dlp embeds the source URL in its errors and error_detail is served to the client.

One option builder (#435). Playlist expansion, the metadata probe, the audio fetch and the MP4 video fetch each built their own options and had already drifted. allowed_extractors stays a required argument because it is the SSRF boundary from #173 and the two valid values are genuinely different.

MP4 export failures are visible (#436). Both video paths stay best-effort, but video_status now records which outcome it was: ok, unavailable when the source has no video stream, failed when the fetch or extract errored. Only failed is shown. An absent MP4 button on an audio-only source is normal; a broken one nobody mentioned is the bug. The local .mp4 path also gained a try/except it never had, so a missing or timed-out ffmpeg no longer reports as "source has no video stream".

Opt-in cookies file (#432, partial). A cookies.txt path in Settings, empty by default. YouTube's bot check has no other remedy in-tree, since yt-dlp ships no PO token generator.

Cookies are a fallback, never the default path. Supplying them makes yt-dlp skip every client without SUPPORTS_COOKIES, and those are exactly the clients resolving formats today without any JS challenge solver. Applying them to every request would have broken imports that currently work. So the first attempt never carries cookies and they are used only after YouTube has actually turned us away. Same shape as separate()'s GPU to CPU retry: the fallback runs once the primary path is known to be dead.

Verification

Beyond the unit tests, the download path was exercised against real YouTube:

  • A full import through download(): 3.4 MB fetched, title, duration, tags and video track all correct
  • The same import with a cookies file configured: use_cookies was False on all three yt-dlp calls and the result was byte-identical, so the new setting cannot break an import that was going to succeed
  • video_status reaches to_state() correctly on a real job

650 tests pass. The 14 failures on this branch also fail on main in this environment (ffmpeg not on PATH for beatgrid, the Linux executable bit, a CRLF assertion in the logs zip test).

ruff check and ruff format --check clean. i18n parity clean across all eight tables.

Notes for review

  • No dependency change, so uv.lock and the desktop runtimeId are untouched and existing installs can take this as an in-app update
  • bundled_js_runtime() is wired into all four call sites but returns None until a build ships a runtime, so it changes nothing yet. It is the hook Ship YouTube's JS challenge solver (yt-dlp-ejs + a bundled JS runtime) #438 needs
  • Cookie file validation happens at the point of setting, so a bad path says so immediately rather than surfacing as a failed import later. The field deliberately does not route through the shared post() helper, which drops a non-ok response silently

Thales added 6 commits August 25, 2026 08:52
Below roughly 0.7x the time-stretch artefacts dominate and the part gets
harder to follow, which is the opposite of what a practice speed is for.

The preset list, the button id list in state.js and the markup all have to
agree, so all three move together. applySpeed already snaps to the nearest
preset, so a stored or stale 0.5 lands on 0.75 rather than being rejected.

No i18n keys change: the labels are numeric.
Every yt-dlp failure arrived as the bare word "unknown", so diagnosing one
meant reading data/logs/. Two independent causes, either enough on its own:

- _CAUSE_PATTERNS had no download category at all, so bot checks, HTTP 429
  and "Requested format is not available" all fell through to the honest
  default.
- _quarantine_failed_job only appended a message when the exception carried
  a stderr tail, and only SeparationError does. A yt-dlp DownloadError never
  has one, so even a correct cause arrived with no detail.

Adds source-blocked and source-unavailable, ordered after the resource-level
causes so a disk-full during a download is still a disk-full, and falls back
to the exception message when there is no tail. The message goes through
redact() first: yt-dlp embeds the source URL in its errors and error_detail
is served to the client and pasted into public reports.
download.py built its YoutubeDL options in four separate places: playlist
expansion, the metadata probe, the audio fetch and the MP4 video fetch. They
had already drifted (ffmpeg_location in one of four, noplaylist in three),
and the drift is invisible: _download_video_track swallows every exception
and falls back to audio-only, so an option missing there costs the user
their MP4 export with nothing surfaced anywhere. _base_ydl_opts() now
carries what all four must share. allowed_extractors stays a required
argument because it is the SSRF boundary from #173 and the two valid values
are genuinely different.

On top of that, a cookies.txt path in Settings. YouTube's bot check has no
other remedy in-tree, since yt-dlp ships no PO token generator, so an IP
YouTube has flagged cannot import anything without credentials. A file path
rather than cookiesfrombrowser: reading a live browser profile means
touching the user's logged-in session and only works while that browser is
closed.

Empty by default, and that matters. Supplying cookies makes yt-dlp skip
every client that does not support them, which removes the unauthenticated
fallback clients that work for most people today. Turning this on when you
do not need it makes imports worse.

The field does not route through the shared post() helper, which drops a
non-ok response silently. A path the user typed has to say when it is wrong.

config.bundled_js_runtime() is the hook the challenge solver needs; it
returns None until a build actually ships a runtime, so nothing changes yet.

No dependency change, so uv.lock and the desktop runtimeId are untouched and
existing installs can still take this as an in-app update.
Sending cookies on every request would have made imports worse for most
people. yt-dlp skips every client that does not support cookies, and those
skipped clients are exactly the ones resolving formats today without any JS
challenge solver. The setting would have removed a working path in order to
fix a broken one for the smaller group whose IP YouTube has flagged.

So the first attempt never carries cookies. They are used only after YouTube
has actually turned us away with a bot check or a 429. By then the path they
displace has already failed, which is what makes the setting incapable of
making anything worse. Same shape as separate()'s GPU to CPU retry: the
fallback runs only once the primary path is known to be dead.

The probe is where the bot check lands, so that is where the decision is
made. Whether cookies were needed is returned rather than re-derived, and
passed to the audio fetch and the video fetch so one job never spends a
second round trip proving the same thing twice.

Second half: when cookies clear the bot check and the job then dies for want
of a solver, say so. Without it the user sees 'Requested format is not
available' with no way to connect it to the setting they just turned on. The
rewrite only happens when no JS runtime can be found, so a genuine format
failure keeps its real message, and the original error stays chained.
Both video paths are best-effort by design, and they should stay that way:
a track that separated fine must not fail because a video stream could not
be had. But collapsing every outcome into has_video = False meant a user who
imported a track specifically to export a karaoke video could not tell 'this
never had video' from 'the video fetch broke', and nothing surfaced either.
The job just completed, with the MP4 option quietly absent.

video_status now records which it was: ok, unavailable when the source
genuinely offers no video stream, failed when the fetch or extract errored.
None stays the default for SoundCloud and non-mp4 uploads, which never try.

Only 'failed' is shown. An absent MP4 button on an audio-only source is
normal and not worth a message; a broken one nobody mentioned is the bug.

The local .mp4 path gained the same distinction, and a try/except it did not
have: a missing or timed-out ffmpeg used to surface as 'source has no video
stream', which sends the user looking at their file instead of their
install.
@thcp
thcp merged commit 635416a into main Aug 25, 2026
10 checks passed
@thcp
thcp deleted the fix/youtube-extraction-and-speed-presets branch August 25, 2026 10:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant