Context
_get_formats() returns {"video_formats": [], "audio_formats": []} on any
yt-dlp non-zero exit — bot check, JS challenge, expired cookies, network blip,
private video — with no message and no logging. The UI faithfully renders that
as two empty dropdowns, so the user sees "no formats" and has no way to learn
why. stderr is captured on the line above and then discarded.
Found alongside #66. It is how the bad format pick there went unnoticed: the
playlist resolve silently returned no formats, the audio dropdown stayed
populated with a stale 140-drc from an earlier single-video fetch, and
that itag then failed 110 queue items one by one.
Acceptance criteria
Files / where this lives
fetchforge/server.py:2090 — _get_formats(); the silent return is line 2108
fetchforge/server.py:2068 — playlist call site (resolves the FIRST entry)
fetchforge/server.py:2079 — single-video call site
fetchforge/index.html:2975 — info.audio_formats.forEach(...) populates the
dropdown; _lastAudioFormats is set at 2982
tests/test_server.py — add the unit test here
How to verify
.venv/bin/python -m unittest discover -s tests — all pass (257 at time of
writing, plus your new test)
npm --prefix tests/frontend test — all 25 pass
- Manual:
GET /video-info?url=https://www.youtube.com/watch?v=00000000000
(a nonexistent id) must return a populated error, not a silent empty pair.
Constraints
- Preserve the success-path response shape exactly —
video_formats /
audio_formats are consumed in several places in index.html.
- Do not make a failed format fetch raise or 500. An empty-with-reason response
is the contract; the endpoint must stay non-fatal.
- Match the existing convention for this:
/check-cookies already returns an
optional reason field alongside its status. Mirror that naming and shape.
- Do not paste raw yt-dlp stderr wholesale into the response — trim to the last
meaningful line or two. It can be long and it reaches the DOM.
- Frontend:
textContent only, never innerHTML — the reason string contains
URLs and untrusted text.
⚠ Danger zones (review focus)
- The success path must not regress. The easy mistake is adding
error on
every return, or reordering the dict so a caller's **format_info spread
changes. Confirm a healthy single video and a healthy playlist both still
populate both dropdowns.
- Error-path plumbing is exactly where cheap models fake-pass: check the
reason actually survives from proc.stderr through _get_formats → the
endpoint → the DOM, not just that a key exists.
- Stale-state clearing is the load-bearing half of this fix. Verify by
resolving a good video, then a failing one, and confirming the dropdown is
empty afterwards rather than still holding the first video's itags.
Out of scope
Notes
Context
_get_formats()returns{"video_formats": [], "audio_formats": []}on anyyt-dlp non-zero exit — bot check, JS challenge, expired cookies, network blip,
private video — with no message and no logging. The UI faithfully renders that
as two empty dropdowns, so the user sees "no formats" and has no way to learn
why.
stderris captured on the line above and then discarded.Found alongside #66. It is how the bad format pick there went unnoticed: the
playlist resolve silently returned no formats, the audio dropdown stayed
populated with a stale
140-drcfrom an earlier single-video fetch, andthat itag then failed 110 queue items one by one.
Acceptance criteria
_get_formats()returns a third key on failure,error(a short reasonstring built from yt-dlp's stderr), alongside the two empty lists.
erroris absent or empty — no behaviour change to the happypath, and the existing
video_formats/audio_formatsshapes are untouched.logger(warning level)including the URL and the reason.
GET /video-infopasseserrorthrough to the client for both call sites(the playlist branch at server.py:2068 and the single-video branch at 2079).
error, the frontend shows it (reuse theexisting
log(msg, 'error')path) instead of leaving the user with asilently empty dropdown.
<select>elements and_lastAudioFormatsare cleared, so a stale pick from a previous fetchcan never be submitted with a new URL.
plus a non-empty
error.Files / where this lives
fetchforge/server.py:2090—_get_formats(); the silent return is line 2108fetchforge/server.py:2068— playlist call site (resolves the FIRST entry)fetchforge/server.py:2079— single-video call sitefetchforge/index.html:2975—info.audio_formats.forEach(...)populates thedropdown;
_lastAudioFormatsis set at 2982tests/test_server.py— add the unit test hereHow to verify
.venv/bin/python -m unittest discover -s tests— all pass (257 at time ofwriting, plus your new test)
npm --prefix tests/frontend test— all 25 passGET /video-info?url=https://www.youtube.com/watch?v=00000000000(a nonexistent id) must return a populated
error, not a silent empty pair.Constraints
video_formats/audio_formatsare consumed in several places inindex.html.is the contract; the endpoint must stay non-fatal.
/check-cookiesalready returns anoptional
reasonfield alongside its status. Mirror that naming and shape.meaningful line or two. It can be long and it reaches the DOM.
textContentonly, neverinnerHTML— the reason string containsURLs and untrusted text.
⚠ Danger zones (review focus)
erroronevery return, or reordering the dict so a caller's
**format_infospreadchanges. Confirm a healthy single video and a healthy playlist both still
populate both dropdowns.
reason actually survives from
proc.stderrthrough_get_formats→ theendpoint → the DOM, not just that a key exists.
resolving a good video, then a failing one, and confirming the dropdown is
empty afterwards rather than still holding the first video's itags.
Out of scope
checks). This issue only makes the failure visible.
-fselectors — that is Fall back to best audio when the picked itag is missing; plainer capability banner #66.Notes