Skip to content

Commit e28f530

Browse files
aarroclaude
andcommitted
feat: improve channel.json placement, strip entries, prefer uncropped thumbnails
- Find channel dir by scanning for [channel_id] suffix (yt-dlp default output template) rather than exact channel name match - Strip 'entries' key before saving channel.json (video listing is large and not useful for debugging artwork issues) - Prefer avatar_uncropped / banner_uncropped thumbnail variants by named id rather than array index; fall back to top-level thumbnail/banner - Add docs/yt-dlp-json-reference.md: annotated examples of .info.json and .channel.json with full thumbnail ID taxonomy - Update CLAUDE.md and README.md to reference the new doc Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 005b878 commit e28f530

5 files changed

Lines changed: 299 additions & 10 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,10 @@ Edit `docker-compose.yml`: set the `device` path under `volumes.youtube-data` an
204204
| `API_KEY` || Bearer token for write API endpoints (`PUT /api/collections`, `POST /api/rescan`, `POST /api/thumbnails/fix`, `POST /api/index/rebuild`). If unset, those endpoints are open (backward-compatible). |
205205
| `YAMP_URL` || Override for YAMP's own base URL. Normally not needed — YAMP derives its URL from the incoming request (Plex already knows it). Set this only if YAMP is behind a reverse proxy that rewrites the `Host` header. |
206206

207+
## Reference Docs
208+
209+
- `docs/yt-dlp-json-reference.md` — Annotated examples of `*.info.json` (per-video) and `*.channel.json` (per-channel). Covers every field YAMP reads, the full thumbnail ID taxonomy (`avatar_uncropped`, `banner_uncropped`, numeric crop variants), and how `_fetch_channel_art` decides where to save the channel JSON.
210+
207211
## Key Files
208212

209213
- `Makefile` — common dev tasks (test, build, dev, docker-*)

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ See [CLAUDE.md](CLAUDE.md) for full documentation.
5151
```
5252
the-last-plex-plugin/
5353
├── CLAUDE.md # Full developer docs
54+
├── docs/ # Reference documents
55+
│ └── yt-dlp-json-reference.md # Annotated examples of .info.json and .channel.json
5456
├── provider/ # YAMP HTTP provider (FastAPI + React UI)
5557
└── legacy/ # Original .bundle agent (reference only)
5658
```

docs/yt-dlp-json-reference.md

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
# yt-dlp JSON Reference
2+
3+
Reference for the two JSON files YAMP reads from disk: the per-video `*.info.json` and the
4+
per-channel `*.channel.json` saved by `_fetch_channel_art`.
5+
6+
---
7+
8+
## Video — `*.info.json`
9+
10+
Written by yt-dlp alongside each downloaded media file.
11+
Filename pattern: `Video Title [VIDEO_ID].info.json`
12+
13+
```json
14+
{
15+
"id": "aG_9T2uOLeM",
16+
"display_id": "aG_9T2uOLeM",
17+
"title": "Another Sky - Watching Basinski (Live Session)",
18+
"fulltitle": "Another Sky - Watching Basinski (Live Session)",
19+
"description": "Another Sky - 'Watching Basinski' out now!\n\nListen: https://...",
20+
21+
"channel": "Another Sky",
22+
"channel_id": "UCXyNhY9FcGAQOVnNIDimrbw",
23+
"channel_url": "https://www.youtube.com/channel/UCXyNhY9FcGAQOVnNIDimrbw",
24+
"channel_follower_count": 5330,
25+
"channel_is_verified": true,
26+
27+
"uploader": "Another Sky",
28+
"uploader_id": "@anotherskyvevo2939",
29+
"uploader_url": "https://www.youtube.com/@anotherskyvevo2939",
30+
31+
"upload_date": "20230623",
32+
"timestamp": 1687492800,
33+
34+
"duration": 225,
35+
"duration_string": "3:45",
36+
37+
"thumbnail": "https://i.ytimg.com/vi/aG_9T2uOLeM/maxresdefault.jpg",
38+
39+
"categories": ["Music"],
40+
"tags": ["Another Sky", "Fiction", "Alternative"],
41+
42+
"view_count": 4537,
43+
"like_count": 126,
44+
"comment_count": 11,
45+
46+
"live_status": "not_live",
47+
"is_live": false,
48+
"was_live": false,
49+
"media_type": "video",
50+
"age_limit": 0,
51+
"availability": "public",
52+
53+
"extractor": "youtube",
54+
"extractor_key": "Youtube",
55+
"webpage_url": "https://www.youtube.com/watch?v=aG_9T2uOLeM",
56+
57+
"width": 3840,
58+
"height": 2160,
59+
"resolution": "3840x2160",
60+
"fps": 25,
61+
"aspect_ratio": 1.78,
62+
"vcodec": "vp9",
63+
"acodec": "opus",
64+
"ext": "webm",
65+
"format": "313 - 3840x2160 (2160p)+251 - audio only (medium)",
66+
"format_id": "313+251",
67+
68+
"epoch": 1774279906,
69+
70+
"formats": [ "... one entry per available quality, large array, skipped ..." ],
71+
"thumbnails": [ "... multiple resolution variants, skipped ..." ],
72+
"subtitles": {},
73+
"automatic_captions": {},
74+
"heatmap": [ "... engagement heatmap, skipped ..." ]
75+
}
76+
```
77+
78+
### Fields YAMP uses
79+
80+
| Field | Where used | Notes |
81+
|---|---|---|
82+
| `id` | `extract_video_id()` fallback; index key | YouTube IDs are exactly 11 chars; Bilibili IDs start with `BV` |
83+
| `title` | Plex `title` | |
84+
| `description` | Plex `summary` | |
85+
| `upload_date` | Plex `originallyAvailableAt`, `year` | Format: `YYYYMMDD` string |
86+
| `duration` | Plex `duration` | Seconds; multiplied ×1000 for Plex (ms) |
87+
| `thumbnail` | Plex `thumb`; thumbnail proxy | Direct CDN URL; YAMP proxies it so Plex can always reach it |
88+
| `categories` | Plex `Genre[].tag` | |
89+
| `channel` | Plex `Director[].tag`; collection matching | |
90+
| `tags` | Collection rule matching (`MATCH_FIELDS`) | Consumed on match to prevent double-matching |
91+
| `extractor` | Plex `studio` | e.g. `"youtube"`, `"bilibili"` |
92+
| `uploader_url` | Channel art prefetch trigger | Used as the key into `_channel_art_cache` |
93+
94+
### Fields used in collection matching (`MATCH_FIELDS` in `collection_map.py`)
95+
96+
`title`, `description`, `channel`, `uploader`, `tags`, `categories`
97+
98+
---
99+
100+
## Channel — `*.channel.json`
101+
102+
Written by `_fetch_channel_art` when it successfully fetches channel metadata via yt-dlp.
103+
Saved as `<sanitized_channel_name>.channel.json` in the channel's subdirectory (matched by
104+
the `[channel_id]` suffix in the dir name — how yt-dlp's default output template names
105+
directories), or at the data root if no matching dir exists.
106+
107+
The `entries` key (the full video listing) is stripped before saving — it can be thousands
108+
of items and is not useful for debugging artwork issues.
109+
110+
```json
111+
{
112+
"id": "UC-smeLB9AnOTeypr1YyjJ3A",
113+
"channel": "ARTE Concert",
114+
"channel_id": "UC-smeLB9AnOTeypr1YyjJ3A",
115+
"title": "ARTE Concert",
116+
"availability": null,
117+
118+
"channel_follower_count": 1920000,
119+
"description": "",
120+
"tags": ["ARTE", "concert", "live", "web", "music"],
121+
"playlist_count": 3,
122+
123+
"uploader": "ARTE Concert",
124+
"uploader_id": "@arteconcert",
125+
"uploader_url": "https://www.youtube.com/@arteconcert",
126+
"channel_url": "https://www.youtube.com/channel/UC-smeLB9AnOTeypr1YyjJ3A",
127+
128+
"modified_date": null,
129+
"view_count": null,
130+
"release_year": null,
131+
132+
"extractor": "youtube:tab",
133+
"extractor_key": "YoutubeTab",
134+
"_type": "playlist",
135+
"webpage_url": "https://www.youtube.com/channel/UC-smeLB9AnOTeypr1YyjJ3A",
136+
"original_url": "https://www.youtube.com/@arteconcert",
137+
"epoch": 1774297897,
138+
139+
"thumbnails": [
140+
{ "id": "0", "preference": -10, "width": 1060, "height": 175, "resolution": "1060x175",
141+
"url": "https://yt3.googleusercontent.com/...=w1060-fcrop64=1,...-no-nd-rj" },
142+
{ "id": "1", "preference": -10, "width": 1138, "height": 188, "resolution": "1138x188",
143+
"url": "https://yt3.googleusercontent.com/...=w1138-fcrop64=1,...-no-nd-rj" },
144+
{ "id": "2", "preference": -10, "width": 1707, "height": 283, "resolution": "1707x283",
145+
"url": "https://yt3.googleusercontent.com/...=w1707-fcrop64=1,...-no-nd-rj" },
146+
{ "id": "3", "preference": -10, "width": 2120, "height": 351, "resolution": "2120x351",
147+
"url": "https://yt3.googleusercontent.com/...=w2120-fcrop64=1,...-no-nd-rj" },
148+
{ "id": "4", "preference": -10, "width": 2276, "height": 377, "resolution": "2276x377",
149+
"url": "https://yt3.googleusercontent.com/...=w2276-fcrop64=1,...-no-nd-rj" },
150+
{ "id": "5", "preference": -10, "width": 2560, "height": 424, "resolution": "2560x424",
151+
"url": "https://yt3.googleusercontent.com/...=w2560-fcrop64=1,...-no-nd-rj" },
152+
{ "id": "banner_uncropped", "preference": -5,
153+
"url": "https://yt3.googleusercontent.com/...=s0" },
154+
{ "id": "7", "width": 900, "height": 900, "resolution": "900x900",
155+
"url": "https://yt3.googleusercontent.com/...=s900-c-k-c0x00ffffff-no-rj" },
156+
{ "id": "avatar_uncropped", "preference": 1,
157+
"url": "https://yt3.googleusercontent.com/...=s0" }
158+
]
159+
}
160+
```
161+
162+
### Thumbnail IDs (consistent across all observed channels)
163+
164+
| ID | Type | Size | Preference | Notes |
165+
|---|---|---|---|---|
166+
| `0``5` | Banner crop | 1060×175 → 2560×424 | `-10` | Same image, different widths; ~6:1 aspect ratio. On channels with no banner, `0` may be the avatar at 900×900 instead. |
167+
| `banner_uncropped` | Banner full | native (no metadata) | `-5` | Full-resolution banner; no `width`/`height` fields. Used as `art` (Background) in Plex. |
168+
| `7` | Avatar crop | 900×900 | none | Cropped/padded square variant. |
169+
| `avatar_uncropped` | Avatar full | native (no metadata) | `1` | Full-resolution avatar; no `width`/`height` fields. Highest preference value. Used as `image` (Poster) in Plex. |
170+
171+
**Key finding:** match thumbnail IDs by their named `id` string, not by array index — the
172+
array order is stable in practice but the IDs are explicit and safe to rely on. Both
173+
`*_uncropped` variants lack `width`/`height` metadata but are the highest-quality options.
174+
175+
### Fields YAMP uses from channel.json
176+
177+
| Field | Where used |
178+
|---|---|
179+
| `channel` / `uploader` | `_fetch_channel_art` return value; filename for the `.channel.json` itself |
180+
| `channel_id` | Used to find the channel's subdirectory (`[channel_id]` suffix match) |
181+
| `thumbnails[id=avatar_uncropped].url` | Returned as `avatar_url` → suggested as collection `image` (poster) |
182+
| `thumbnails[id=banner_uncropped].url` | Returned as `banner_url` → suggested as collection `art` (background) |
183+
| `thumbnail` (top-level) | Fallback `avatar_url` when `avatar_uncropped` is absent |

provider/app.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -249,23 +249,52 @@ def _fetch_channel_art(uploader_url: str, data_path: str | None = None) -> dict
249249
with _yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[union-attr]
250250
info = ydl.extract_info(uploader_url, download=False) or {}
251251
channel_name = info.get("channel") or info.get("uploader") or ""
252+
channel_id = info.get("channel_id") or ""
252253
if data_path and channel_name:
253254
safe_name = _sanitize_filename(channel_name)
254-
channel_dir = os.path.join(data_path, channel_name)
255-
if os.path.isdir(channel_dir):
256-
save_path = os.path.join(channel_dir, f"{safe_name}.channel.json")
257-
else:
258-
save_path = os.path.join(data_path, f"{safe_name}.channel.json")
255+
# Prefer a subdirectory whose name ends with [channel_id] (yt-dlp's default
256+
# output template produces names like "Amber_Mark [UCMpn3xVjGSB8zztwLjENjvw]").
257+
# Fall back to an exact channel_name match, then save at the data root.
258+
channel_dir: str | None = None
259+
if channel_id:
260+
suffix = f"[{channel_id}]"
261+
try:
262+
for entry in os.scandir(data_path):
263+
if entry.is_dir() and entry.name.endswith(suffix):
264+
channel_dir = entry.path
265+
break
266+
except OSError:
267+
pass
268+
if channel_dir is None:
269+
direct = os.path.join(data_path, channel_name)
270+
if os.path.isdir(direct):
271+
channel_dir = direct
272+
save_dir = channel_dir if channel_dir is not None else data_path
273+
save_path = os.path.join(save_dir, f"{safe_name}.channel.json")
274+
info_to_save = {k: v for k, v in info.items() if k != "entries"}
259275
try:
260276
with open(save_path, "w", encoding="utf-8") as fh:
261-
json.dump(info, fh, indent=2, ensure_ascii=False)
277+
json.dump(info_to_save, fh, indent=2, ensure_ascii=False)
262278
logger.debug("_fetch_channel_art: saved channel JSON to '%s'", save_path)
263279
except OSError as e:
264280
logger.warning("_fetch_channel_art: could not save channel JSON to '%s': %s", save_path, e)
281+
# Prefer "uncropped" thumbnail variants for best quality.
282+
thumbnails = info.get("thumbnails") or []
283+
avatar_url = (
284+
next((t["url"] for t in thumbnails if t.get("id") == "avatar_uncropped" and t.get("url")), None)
285+
or info.get("thumbnail")
286+
or ""
287+
)
288+
banner_url = (
289+
next((t["url"] for t in thumbnails if t.get("id") == "banner_uncropped" and t.get("url")), None)
290+
or info.get("tvBanner")
291+
or info.get("banner")
292+
or ""
293+
)
265294
return {
266295
"channel": channel_name,
267-
"avatar_url": info.get("thumbnail") or "",
268-
"banner_url": info.get("tvBanner") or info.get("banner") or "",
296+
"avatar_url": avatar_url,
297+
"banner_url": banner_url,
269298
}
270299
except Exception:
271300
logger.exception("_fetch_channel_art: unexpected error for '%s'", uploader_url)

provider/tests/test_app.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2078,8 +2078,79 @@ def test_fetch_channel_art_saves_json_flat(tmp_path, monkeypatch):
20782078
assert data["channel"] == "Studio Bruxelles"
20792079

20802080

2081+
def test_fetch_channel_art_saves_json_excludes_entries(tmp_path, monkeypatch):
2082+
"""The 'entries' key (video listing) is stripped before saving channel.json."""
2083+
from app import _fetch_channel_art
2084+
2085+
_make_yt_dlp_mock(
2086+
monkeypatch,
2087+
{"channel": "Test Channel", "thumbnail": "https://img/av.jpg", "entries": [{"id": "vid1"}, {"id": "vid2"}]},
2088+
)
2089+
2090+
_fetch_channel_art("https://www.youtube.com/@TestChannel", data_path=str(tmp_path))
2091+
2092+
saved = tmp_path / "Test Channel.channel.json"
2093+
assert saved.exists()
2094+
data = json.loads(saved.read_text(encoding="utf-8"))
2095+
assert "entries" not in data, "entries list must be stripped to keep channel.json small"
2096+
assert data["channel"] == "Test Channel"
2097+
2098+
2099+
def test_fetch_channel_art_prefers_uncropped_thumbnails(tmp_path, monkeypatch):
2100+
"""avatar_uncropped and banner_uncropped thumbnail variants are preferred over fallbacks."""
2101+
from app import _fetch_channel_art
2102+
2103+
thumbnails = [
2104+
{"id": "avatar_uncropped", "url": "https://img/avatar_uncropped.jpg", "preference": 1},
2105+
{"id": "banner_uncropped", "url": "https://img/banner_uncropped.jpg", "preference": 1},
2106+
{"id": "0", "url": "https://img/cropped.jpg"},
2107+
]
2108+
_make_yt_dlp_mock(
2109+
monkeypatch,
2110+
{"channel": "Test Channel", "thumbnail": "https://img/cropped.jpg", "thumbnails": thumbnails},
2111+
)
2112+
2113+
result = _fetch_channel_art("https://www.youtube.com/@TestChannel")
2114+
2115+
assert result is not None
2116+
assert result["avatar_url"] == "https://img/avatar_uncropped.jpg"
2117+
assert result["banner_url"] == "https://img/banner_uncropped.jpg"
2118+
2119+
2120+
def test_fetch_channel_art_falls_back_when_no_uncropped(monkeypatch):
2121+
"""Falls back to info['thumbnail'] when no avatar_uncropped thumbnail exists."""
2122+
from app import _fetch_channel_art
2123+
2124+
_make_yt_dlp_mock(monkeypatch, {"channel": "Test Channel", "thumbnail": "https://img/regular.jpg"})
2125+
2126+
result = _fetch_channel_art("https://www.youtube.com/@TestChannel")
2127+
2128+
assert result is not None
2129+
assert result["avatar_url"] == "https://img/regular.jpg"
2130+
assert result["banner_url"] == ""
2131+
2132+
20812133
def test_fetch_channel_art_saves_json_in_channel_dir(tmp_path, monkeypatch):
2082-
"""When a matching channel subdirectory exists, channel.json is saved inside it."""
2134+
"""When a channel subdirectory with [channel_id] suffix exists, channel.json is saved inside it."""
2135+
from app import _fetch_channel_art
2136+
2137+
# yt-dlp names dirs like "Studio_Bruxelles [UCxxxxxx]"
2138+
channel_dir = tmp_path / "Studio_Bruxelles [UCxxxxxx]"
2139+
channel_dir.mkdir()
2140+
_make_yt_dlp_mock(
2141+
monkeypatch,
2142+
{"channel": "Studio Bruxelles", "channel_id": "UCxxxxxx", "thumbnail": "https://img/av.jpg"},
2143+
)
2144+
2145+
_fetch_channel_art("https://www.youtube.com/@StudioBruxelles", data_path=str(tmp_path))
2146+
2147+
saved = channel_dir / "Studio Bruxelles.channel.json"
2148+
assert saved.exists(), "channel.json should be saved inside the channel dir matched by [channel_id]"
2149+
assert (tmp_path / "Studio Bruxelles.channel.json").exists() is False, "should not also save at root"
2150+
2151+
2152+
def test_fetch_channel_art_saves_json_in_channel_dir_exact_name_fallback(tmp_path, monkeypatch):
2153+
"""When no [channel_id] dir exists, falls back to exact channel_name directory match."""
20832154
from app import _fetch_channel_art
20842155

20852156
channel_dir = tmp_path / "Studio Bruxelles"
@@ -2089,7 +2160,7 @@ def test_fetch_channel_art_saves_json_in_channel_dir(tmp_path, monkeypatch):
20892160
_fetch_channel_art("https://www.youtube.com/@StudioBruxelles", data_path=str(tmp_path))
20902161

20912162
saved = channel_dir / "Studio Bruxelles.channel.json"
2092-
assert saved.exists(), "channel.json should be saved inside the existing channel dir"
2163+
assert saved.exists(), "channel.json should be saved inside the exact-name channel dir as fallback"
20932164
assert (tmp_path / "Studio Bruxelles.channel.json").exists() is False, "should not also save at root"
20942165

20952166

0 commit comments

Comments
 (0)