16 KiB
ytsync channel handle bug — root cause investigation
Date: 2026-09-04 Agent: Detective (read-only) Status: ROOT_CAUSE_ESTABLISHED
TL;DR
- Confirmed root cause:
run_probe()probes the user's channel URL verbatim; yt-dlp--flat-playlist -Jon a bare channel URL (@handle,/c/,/user/,/channel/<ID>,music.youtube.com/channel/<ID>) returns the channel's TAB structure (Videos/Live/Shorts:_type:"playlist",url:null,id==channel_id) instead of videos.collect_entries()records every entry unfiltered, so the three tabs become three "new videos" thatdownload_video()tries to fetch aswatch?v=<channel_id>→[youtube] <channel_id>: This video is unavailable. - Fix (validated, not implemented): (a) probe-time canonicalization — new helper
canonical_channel_url()appends/videosto bare channel URLs, called at the top ofrun_probe()(fixes bothaddandsyncof already-stored bare-handle registry entries, no migration); (b) defense-in-depth filter incollect_entries()keeping only watchable entry URLs (watch?v=,youtu.be/,/shorts/), plus a_type=="video"guard on the single-object fallback (handles empty channels gracefully). - Artifacts: probes under
/tmp/opencode/*.json; validated stub harness at/tmp/opencode/ytsync-test/run-tests.sh(17 assertions; 5 fail on the unfixed script, all must pass after the fix). - Classification: FACT (reproduced live + function-level).
Step 1: Read evidence + code
Read /tmp/opencode/ytsync-probe.json, /tmp/opencode/ytsync-vtab.json, and all of bin/pos-media-ytsync (1191 lines). Key code facts:
run_probe()line 316:yt-dlp --flat-playlist -J --no-warnings -- "$url".collect_entries()lines 374-392: records.id+.titleof every.entries[]element; no_type/urlfilter; fallback to single object when no entries.is_signin_skipped()line 401: only skips empty ids /[Private/[Deleted/[Unavailabletitles — tab entries pass.classify_url()line 157: video / playlist / channel.- Registry stores the original URL (
finish_addline 773-774).syncre-probes the stored URL verbatim (run_sync_setline 923 →pass_prepareline 559 →run_probe "$S_URL").
[DONE]
Step 2: Probe channel URL forms (my own reproductions)
All probes: yt-dlp 2026.08.19 --flat-playlist -J --no-warnings. Summarized as shapes → outcome:
| Probe URL | Probe shape | Verdict |
|---|---|---|
https://www.youtube.com/@3blue1brown |
_type:playlist, id:@3blue1brown, playlist_count:3; entries = 3 tabs (_type:"playlist", url:null, id==channel_id) |
TABS — the bug |
https://www.youtube.com/@3blue1brown/videos |
playlist_count:151; entries = 151 real videos (_type:"url", url:watch?v=…) |
canonical target OK |
https://www.youtube.com/@3blue1brown/shorts |
playlist_count:81; real entries, url:youtube.com/shorts/<id> |
OK (needs /shorts/ in filter) |
https://www.youtube.com/@3blue1brown/streams |
playlist_count:10; real entries watch?v= |
OK |
https://www.youtube.com/@3blue1brown/live |
rc=1, stdout null, stderr The channel is not currently live |
probe-fail path (unchanged) |
https://www.youtube.com/@3blue1brown/playlists |
playlist_count:24; entries _type:"url" but url:playlist?list=… |
false-positive if unfiltered |
https://www.youtube.com/@3blue1brown/featured |
title "… - Home", 7 entries, id:null, playlist/tab urls |
false-positive if unfiltered |
https://www.youtube.com/c/3Blue1Brown |
tabs (playlist_count:3) |
needs canonicalization |
https://www.youtube.com/c/3Blue1Brown/videos |
151 real videos | OK |
https://www.youtube.com/user/3Blue1Brown (+/videos) |
rc=1, HTTP 404 | user/ dead for this channel |
https://www.youtube.com/user/pewdiepie |
tabs (playlist_count:2) |
works → needs canonicalization |
https://www.youtube.com/user/MarquesBrownlee |
tabs (playlist_count:3) |
works → needs canonicalization |
https://www.youtube.com/channel/UCYO_… |
tabs | needs canonicalization |
https://www.youtube.com/channel/UCYO_…/videos |
151 real videos | OK |
https://www.youtube.com/playlist?list=… |
_type:playlist; entries real watch?v= |
OK — must stay untouched |
https://www.youtube.com/watch?v=… / https://youtu.be/… |
_type:"video" single object, no entries |
OK — fallback path |
@3blue1brown (no domain) |
rc=1 [generic] not a valid URL |
latent ytsync wart (is_youtube_url accepts @*) |
youtube.com/@3blue1brown (no proto) |
tabs | works; canonicalizable |
youtube.com/@3blue1brown/videos |
151 real videos | OK |
…/@3blue1brown/videos/ (trailing slash) |
151 real videos | OK — suffix check must strip slash |
…/@3blue1brown/videos?view=0&sort=dd |
151 real videos | OK — suffix check must strip query |
…/@3blue1brown/VIDEOS (uppercase) |
rc=1 channel does not have a VIDEOS tab |
yt-dlp suffix is case-sensitive |
https://music.youtube.com/channel/UCYO_… |
tabs | needs canonicalization |
https://music.youtube.com/channel/UCYO_…/videos |
151 real videos | OK |
Live functional reproduction (read-only, --dry-run writes nothing — registry write is behind DRY_RUN -eq 0, history only in non-dry runs):
$ bin/pos-media-ytsync sync --dry-run
Source : https://www.youtube.com/@3blue1brown ← stored URL re-probed verbatim
Resolved : 3Blue1Brown (channel · 3 videos) ← the 3 TABS
New : 3 would be downloaded (0 already present)
3Blue1Brown - Videos.mp4 / - Live.mp4 / - Shorts.mp4 ← tab titles as "videos"
Matches the reported add output exactly (first divergence: parse_probe+collect_entries treating tab entries as videos).
[DONE]
Step 3: Trace the call surface (who probes what URL)
run_probe() callers:
cmd_addline 876 —run_probe "$url"(user-supplied add URL; explicit mode).ask_url_interactiveline 839 —run_probe "$u"(interactive add;finish_addstores$ASKED_URL).pass_prepareline 559 —run_probe "$S_URL";S_URLcomes from the registry line (read atrun_sync_setline 923).
Mandatory conclusion: the stored registry URL is re-probed verbatim on every sync (confirmed by code trace AND the live dry-run above). Therefore canonicalization inside run_probe() fixes both flows at once — the existing machine registry entry (3blue1brown … https://www.youtube.com/@3blue1brown) needs no migration; it simply re-canonicalizes each probe.
parse_probe/P_KEY impact: both the bare-handle and /videos probe shapes carry uploader_id:"@3blue1brown" → P_KEY="@3blue1brown" → slug 3blue1brown (matches existing registry slug). P_TITLE changes cosmetically (3Blue1Brown → 3Blue1Brown - Videos); it is stored as the registry S_TITLE field but never displayed by cmd_list/digests. No functional impact.
[DONE]
Step 4: Fix spec (validated, Builder-executable)
Recommended combination: canonicalization (primary) + entry filter + fallback guard (defense-in-depth). Canonicalization alone fixes the report end-to-end; the filter alone would degrade a bare-handle add to "0 videos" (graceful but useless). Both are needed; both validated by simulation below.
4.1 New helper — insert after classify_url() (after line 176), before sanitize_component()
# ── Channel URL canonicalization ─────────────────────────────────────
# yt-dlp --flat-playlist on a bare channel URL returns the channel's
# TAB list (Videos/Live/Shorts; _type "playlist", url null, id==channel_id),
# not videos. Appending /videos makes the probe return the real videos.
canonical_channel_url() { # add /videos to bare channel URLs; echo canonical
local u="$1"
case "$u" in
@*) u="https://www.youtube.com/$u" ;; # bare 'handle' → full URL
esac
[ "$(classify_url "$u")" = "channel" ] || { printf '%s' "$u"; return 0; }
local path="${u%%\?*}"
path="${path%%\#*}"
path="${path%/}"
case "${path##*/}" in
videos | shorts | streams | live | playlists | featured | releases | podcasts | search)
printf '%s' "$u" ;;
*)
printf '%s/videos' "$u" ;;
esac
}
Behavior (all 16 cases tested PASS in the harness): bare @handle full URL → …/videos; bare @3blue1brown (no domain, fixes the latent generic-error failure) → https://www.youtube.com/@3blue1brown/videos; youtube.com/@… no-protocol → + /videos; /c/NAME, /user/NAME, /channel/ID → + /videos; music.youtube.com/channel/ID → + /videos; already-suffixed /videos /shorts /streams /live /playlists /featured, with trailing slash or query → untouched; ?list= / watch?v= / youtu.be/<id> → untouched.
4.2 run_probe() — one line, after local url="$1" (line 317)
url="$(canonical_channel_url "$url")"
That is the whole integration point: add (both modes) and sync (stored URLs) now probe the Videos tab. Nothing downstream changes (S_URL stays the original; display is cosmetic).
4.3 collect_entries() — two edits (lines 374-392)
(a) Line 381 — add a select so only watchable entries are collected (exact in-file quoting verified):
mapfile -t pairs < <(jq -r '.entries[] | select((.url // "") | test("watch\\?v=|youtu\\.be/|/shorts/")) | ((.id // "") + "\u001f" + (.title // ""))' "$PROBE_JSON")
(b) Lines 388-391 — guard the single-object fallback so empty tab/playlist probes never become one bogus video:
elif [ "$(jq -r '._type // ""' "$PROBE_JSON")" = "video" ]; then
ENTRY_IDS+=("$(jq -r '.id // ""' "$PROBE_JSON")")
ENTRY_TITLES+=("$(jq -r '.title // ""' "$PROBE_JSON")")
fi
Filter counts (validated): tab probe → 0; videos → 151; shorts → 81; streams → 10; playlists-tab → 0; featured → 0; playlist ?list= → 16; video ?v= → 1 (fallback). Empty channel (entries:[], _type:"playlist") → 0 → pass_execute line 624 prints Sync complete: 0 new, 0 already present, 0 failed (no 3-failure spam).
4.4 Error behavior — empty channel / not-live channel
- Channel with zero videos:
/videosprobe →entries:[]→ 0 valid → graceful "0 new" summary (verified withempty-videos.jsonfixture). - Explicit
/livewhen not live: probe rc=1 → existingnotfound/unreachablehandling (sync: skip +FAILED (probe)history; add: retry prompt). Unchanged. - Explicit
/playlists//featuredURLs: untouched by canonicalization; filter now drops all playlist-URL entries → 0 new / 0 failed (previously N failed downloads). Graceful improvement, no new error path.
4.5 Things deliberately NOT changed
parse_probe,P_KEY, registry line format, download loop,classify_url— untouched.- Registry migration — none required (probe-time canonicalization covers stored URLs).
- Storing the canonical URL in the registry for new adds — optional cosmetic enhancement, NOT needed for correctness; recommended to skip to keep the change minimal.
[DONE]
Step 5: Test plan
Harness (new, stub-based — no ytsync harness exists in repo): /tmp/opencode/ytsync-test/run-tests.sh
- Self-contained: generates 6 inline fixture JSONs (tab-probe, videos+shorts-tab, playlist, playlists-tab, single-video, empty-videos), stub
common.sh/notify.sh, a recording fakeyt-dlp; truncatesbin/pos-media-ytsyncat the# ── Argument dispatch ──marker and sources it (function-level tests, no network/state). - 17 assertions: 7×
classify_urlregression; 16×canonical_channel_urlmatrix; 8×collect_entriesfixture behavior; 1×run_probeURL recording. - Baseline on the unfixed script: 12 PASS / 5 FAIL (exactly the fix-specific assertions fail — proves the harness discriminates the bug). After the fix: all 17 must pass.
Recommended Builder self-verification (in order):
bash -n bin/pos-media-ytsyncbash /tmp/opencode/ytsync-test/run-tests.sh→ 0 failed./bin/pos-media-ytsync sync --dry-run→Resolved : 3Blue1Brown (channel · 151 videos), real titles,151 would be downloaded- Real limited download into temp dirs (never the real
~/Videos):YTSYNC_STATE_DIR=/tmp/yts-state YTSYNC_VIDEOS_DIR=/tmp/yts-vids ./bin/pos-media-ytsync add https://www.youtube.com/@3blue1brownthen interrupt after the first videos (safe: temp dir; yt-dlp renames atomically, archive records completed ones); verifyarchive/3blue1brown.txtgrows and flat<title>.mp4layout. make checkandmake lint(must end0 FAIL, 0 WARN); commit with conventional prefix + AGENT_TODO.md Done move.
Edge-case matrix (URL form → after-fix behavior):
- bare
@handle//c///user/(resolvable) //channel/ID/music.…/channel/ID→ canonicalized to/videos→ real videos. @handle(no domain) → full canonical URL → real videos (fixes latent failure)./videos,/shorts,/streams,/live,/playlists,/featuredexplicit suffixes → untouched; shorts/streams pass filter; playlists/featured yield graceful 0; live fails gracefully if not live.?list=,?v=,youtu.be/<id>→ untouched, behave as today./user/URLs that 404 → unchanged notfound failure path (pre-existing;/user/is deprecated by YouTube).- Uppercase suffix (
@h/VIDEOS) → untouched → yt-dlp error (pre-existing; suffix check is intentionally case-sensitive). - Existing stored bare-handle registry entries → work without migration; archive has no stale ids (the 3 broken downloads never reached the archive).
[DONE]
Handoff
Status: ROOT_CAUSE_ESTABLISHED
Symptom: pos media ytsync add https://www.youtube.com/@3blue1brown (and sync of the stored URL) resolves the channel's 3 tabs as videos and fails each download with [youtube] UCYO_jab_es: This video is unavailable.
Expected: resolve the channel's actual videos and download new ones incrementally.
Actual: probe returns tab entries (Videos/Live/Shorts); collect_entries records them unfiltered; 3 failed downloads; summary "0 new … 3 failed".
Root cause: run_probe probes the bare channel URL verbatim; yt-dlp flat-playlist on channel URLs returns channel tabs (_type:"playlist", url:null, id==channel_id), not videos; collect_entries has no non-video entry filter, so tabs are downloaded as watch?v=<channel_id> and fail.
Classification: FACT (live reproduction: sync dry-run shows the 3 tab titles; function-level reproduction: tab fixture yields 3 entries; probe evidence table).
Evidence: probe JSONs /tmp/opencode/ytsync-{probe,vtab}.json + my additional probes; filter counts above; harness baseline 12/17.
Tests performed: 24 URL-form probes (matrix above); jq filter validation on 6 shapes; fallback guard validation on 4 fixtures; live sync --dry-run reproduction; harness mechanics + baseline run.
Alternatives eliminated:
- "yt-dlp version regression" — no; current yt-dlp behavior is inherent for channel URLs (tabs),
/videossuffix returns videos (probed). - "URL should be classified as playlist" — no; channel classification is correct; the probe target is the problem.
- "Filter
_type=="url"only" — rejected:/playlists-tab entries are_type:"url"with playlist IDs; the watch-URL test is the correct discriminator (validated). - "Registry migration needed" — no; probe-time canonicalization covers stored URLs (traced + live dry-run).
Affected components: bin/pos-media-ytsync — run_probe (line 316), collect_entries (lines 374-392); new helper canonical_channel_url; no change to registry format.
Scope / decision boundary: none — pure bug fix within the tool; no architectural decisions required.
Recommended next agent: Builder
Reason: root cause and the exact code-level fix (validated by simulation and the stub harness) are established; implementation + make gen/check/lint + harness green are Builder work.
Changes made by Detective: none (repo untouched; scaffolding + harness in /tmp/opencode/ only).