# 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 -J` on a bare channel URL (`@handle`, `/c/`, `/user/`, `/channel/`, `music.youtube.com/channel/`) 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" that `download_video()` tries to fetch as `watch?v=` → `[youtube] : This video is unavailable`. - **Fix (validated, not implemented):** (a) probe-time canonicalization — new helper `canonical_channel_url()` appends `/videos` to bare channel URLs, called at the top of `run_probe()` (fixes both `add` and `sync` of already-stored bare-handle registry entries, no migration); (b) defense-in-depth filter in `collect_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`+`.title` of **every** `.entries[]` element; no `_type`/`url` filter; fallback to single object when no entries. - `is_signin_skipped()` line 401: only skips empty ids / `[Private`/`[Deleted`/`[Unavailable` titles — tab entries pass. - `classify_url()` line 157: video / playlist / channel. - Registry stores the **original** URL (`finish_add` line 773-774). `sync` re-probes the stored URL verbatim (`run_sync_set` line 923 → `pass_prepare` line 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/` | 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: 1. `cmd_add` line 876 — `run_probe "$url"` (user-supplied add URL; explicit mode). 2. `ask_url_interactive` line 839 — `run_probe "$u"` (interactive add; `finish_add` stores `$ASKED_URL`). 3. `pass_prepare` line 559 — `run_probe "$S_URL"`; `S_URL` comes from the registry line (read at `run_sync_set` line 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()` ```bash # ── 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/` → untouched. ### 4.2 `run_probe()` — one line, after `local url="$1"` (line 317) ```bash 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): ```bash 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: ```bash 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: `/videos` probe → `entries:[]` → 0 valid → graceful "0 new" summary (verified with `empty-videos.json` fixture). - Explicit `/live` when not live: probe rc=1 → existing `notfound`/`unreachable` handling (sync: skip + `FAILED (probe)` history; add: retry prompt). Unchanged. - Explicit `/playlists` / `/featured` URLs: 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 fake `yt-dlp`; truncates `bin/pos-media-ytsync` at the `# ── Argument dispatch ──` marker and sources it (function-level tests, no network/state). - 17 assertions: 7× `classify_url` regression; 16× `canonical_channel_url` matrix; 8× `collect_entries` fixture behavior; 1× `run_probe` URL 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): 1. `bash -n bin/pos-media-ytsync` 2. `bash /tmp/opencode/ytsync-test/run-tests.sh` → 0 failed 3. `./bin/pos-media-ytsync sync --dry-run` → `Resolved : 3Blue1Brown (channel · 151 videos)`, real titles, `151 would be downloaded` 4. 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/@3blue1brown` then interrupt after the first videos (safe: temp dir; yt-dlp renames atomically, archive records completed ones); verify `archive/3blue1brown.txt` grows and flat `.mp4` layout. 5. `make check` and `make lint` (must end `0 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`, `/featured` explicit 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), `/videos` suffix 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).