Skip to content

Commit 005b878

Browse files
aarroclaude
andcommitted
feat: save channel art metadata to <channel>.channel.json on volume
- _fetch_channel_art now accepts data_path and saves the raw yt-dlp info dict to <channel>.channel.json for debugging and future reference - Saves inside <data_path>/<channel_name>/ if that dir exists (per-channel layout), otherwise falls back to <data_path>/<channel_name>.channel.json - _sanitize_filename strips filesystem-unsafe chars from the channel name - _prefetch_channel_art_bg passes DATA_PATH to _fetch_channel_art - build_index comment explicitly documents why *.channel.json is skipped - 7 new tests: build_index isolation, save location, filename sanitization, save-error resilience, no-data-path skip Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 06d7b1f commit 005b878

2 files changed

Lines changed: 167 additions & 3 deletions

File tree

provider/app.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ def onerror(err: OSError) -> None:
134134

135135
for root, _, files in os.walk(data_path, onerror=onerror):
136136
for f in files:
137+
# Only index yt-dlp video metadata files. *.channel.json (channel art
138+
# cache written by _fetch_channel_art) and _collection_map.json must
139+
# not be treated as video entries.
137140
if not f.endswith(".info.json"):
138141
continue
139142
# Try the filename first (yt-dlp default: "Title [VIDEO_ID].info.json").
@@ -214,12 +217,23 @@ def _try_index_from_filename(video_id: str, media_path: str) -> bool:
214217

215218
# ── Channel art helpers ───────────────────────────────────────────────────────
216219

220+
_FILENAME_UNSAFE_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
217221

218-
def _fetch_channel_art(uploader_url: str) -> dict | None:
222+
223+
def _sanitize_filename(name: str) -> str:
224+
"""Replace filesystem-unsafe characters with underscores."""
225+
return _FILENAME_UNSAFE_RE.sub("_", name).strip()
226+
227+
228+
def _fetch_channel_art(uploader_url: str, data_path: str | None = None) -> dict | None:
219229
"""Fetch channel avatar and banner from YouTube via yt-dlp. Synchronous — call via thread.
220230
221231
Returns {channel, avatar_url, banner_url} or None on failure.
222232
Only attempts YouTube URLs (uploader_url contains 'youtube.com').
233+
234+
If data_path is given, saves the raw yt-dlp channel info dict to
235+
<channel_name>.channel.json in the channel's subdirectory (if it exists
236+
under data_path) or at the data_path root as a fallback.
223237
"""
224238
if "youtube.com" not in uploader_url:
225239
return None
@@ -234,8 +248,22 @@ def _fetch_channel_art(uploader_url: str) -> dict | None:
234248
}
235249
with _yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[union-attr]
236250
info = ydl.extract_info(uploader_url, download=False) or {}
251+
channel_name = info.get("channel") or info.get("uploader") or ""
252+
if data_path and channel_name:
253+
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")
259+
try:
260+
with open(save_path, "w", encoding="utf-8") as fh:
261+
json.dump(info, fh, indent=2, ensure_ascii=False)
262+
logger.debug("_fetch_channel_art: saved channel JSON to '%s'", save_path)
263+
except OSError as e:
264+
logger.warning("_fetch_channel_art: could not save channel JSON to '%s': %s", save_path, e)
237265
return {
238-
"channel": info.get("channel") or info.get("uploader") or "",
266+
"channel": channel_name,
239267
"avatar_url": info.get("thumbnail") or "",
240268
"banner_url": info.get("tvBanner") or info.get("banner") or "",
241269
}
@@ -318,7 +346,7 @@ async def _prefetch_channel_art_bg(collection_names: list[str]) -> None:
318346
for url in urls:
319347
if url not in _channel_art_cache:
320348
try:
321-
result = await asyncio.to_thread(_fetch_channel_art, url)
349+
result = await asyncio.to_thread(_fetch_channel_art, url, DATA_PATH)
322350
except Exception:
323351
logger.exception(
324352
"_prefetch_channel_art_bg: unhandled exception fetching art for '%s' (collection '%s')",

provider/tests/test_app.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1962,6 +1962,54 @@ async def test_put_collections_art_field_triggers_artwork_sync(patched_app, monk
19621962
mock_artwork.assert_awaited_once()
19631963

19641964

1965+
# ── build_index — *.channel.json and *.info.json separation ──────────────────
1966+
1967+
1968+
def test_build_index_ignores_channel_json(tmp_path):
1969+
"""*.channel.json files are not indexed as videos."""
1970+
from app import build_index
1971+
1972+
# A .channel.json file that would collide if incorrectly parsed
1973+
(tmp_path / "Studio Bruxelles.channel.json").write_text(
1974+
json.dumps({"id": "should_not_be_indexed", "channel": "Studio Bruxelles"}),
1975+
encoding="utf-8",
1976+
)
1977+
# A legitimate video info file
1978+
valid_id = "validIDxxxxx"
1979+
(tmp_path / f"Video [{valid_id}].info.json").write_text(
1980+
json.dumps({"id": valid_id, "title": "Test"}),
1981+
encoding="utf-8",
1982+
)
1983+
1984+
index, _ = build_index(str(tmp_path))
1985+
1986+
assert valid_id in index, "valid info.json should be indexed"
1987+
assert "should_not_be_indexed" not in index, "channel.json must not be indexed as a video"
1988+
assert len(index) == 1
1989+
1990+
1991+
def test_build_index_channel_json_in_subdir(tmp_path):
1992+
"""*.channel.json inside a channel subdirectory is also ignored."""
1993+
from app import build_index
1994+
1995+
channel_dir = tmp_path / "alt-J"
1996+
channel_dir.mkdir()
1997+
(channel_dir / "alt-J.channel.json").write_text(
1998+
json.dumps({"id": "fake", "channel": "alt-J"}),
1999+
encoding="utf-8",
2000+
)
2001+
valid_id = "altJvideo1234"
2002+
(channel_dir / f"Live Set [{valid_id}].info.json").write_text(
2003+
json.dumps({"id": valid_id, "title": "Live Set"}),
2004+
encoding="utf-8",
2005+
)
2006+
2007+
index, _ = build_index(str(tmp_path))
2008+
2009+
assert valid_id in index
2010+
assert "fake" not in index
2011+
2012+
19652013
# ── _fetch_channel_art ────────────────────────────────────────────────────────
19662014

19672015

@@ -2001,6 +2049,94 @@ def test_fetch_channel_art_exception_returns_none(monkeypatch):
20012049
assert result is None
20022050

20032051

2052+
def _make_yt_dlp_mock(monkeypatch, info: dict):
2053+
"""Wire up a mock yt-dlp module that returns `info` from extract_info."""
2054+
mock_ydl = MagicMock()
2055+
mock_ydl.__enter__ = MagicMock(return_value=mock_ydl)
2056+
mock_ydl.__exit__ = MagicMock(return_value=False)
2057+
mock_ydl.extract_info.return_value = info
2058+
mock_module = MagicMock()
2059+
mock_module.YoutubeDL.return_value = mock_ydl
2060+
monkeypatch.setattr(yamp_app, "_yt_dlp", mock_module)
2061+
monkeypatch.setattr(yamp_app, "_YT_DLP_AVAILABLE", True)
2062+
return mock_ydl
2063+
2064+
2065+
def test_fetch_channel_art_saves_json_flat(tmp_path, monkeypatch):
2066+
"""When no channel subdirectory exists, channel.json is saved at the data root."""
2067+
from app import _fetch_channel_art
2068+
2069+
_make_yt_dlp_mock(monkeypatch, {"channel": "Studio Bruxelles", "thumbnail": "https://img/av.jpg"})
2070+
2071+
result = _fetch_channel_art("https://www.youtube.com/@StudioBruxelles", data_path=str(tmp_path))
2072+
2073+
assert result is not None
2074+
assert result["channel"] == "Studio Bruxelles"
2075+
saved = tmp_path / "Studio Bruxelles.channel.json"
2076+
assert saved.exists(), "channel.json should be written at the data root"
2077+
data = json.loads(saved.read_text(encoding="utf-8"))
2078+
assert data["channel"] == "Studio Bruxelles"
2079+
2080+
2081+
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."""
2083+
from app import _fetch_channel_art
2084+
2085+
channel_dir = tmp_path / "Studio Bruxelles"
2086+
channel_dir.mkdir()
2087+
_make_yt_dlp_mock(monkeypatch, {"channel": "Studio Bruxelles", "thumbnail": "https://img/av.jpg"})
2088+
2089+
_fetch_channel_art("https://www.youtube.com/@StudioBruxelles", data_path=str(tmp_path))
2090+
2091+
saved = channel_dir / "Studio Bruxelles.channel.json"
2092+
assert saved.exists(), "channel.json should be saved inside the existing channel dir"
2093+
assert (tmp_path / "Studio Bruxelles.channel.json").exists() is False, "should not also save at root"
2094+
2095+
2096+
def test_fetch_channel_art_sanitizes_filename(tmp_path, monkeypatch):
2097+
"""Channel names with unsafe characters are sanitized in the filename."""
2098+
from app import _fetch_channel_art
2099+
2100+
_make_yt_dlp_mock(monkeypatch, {"channel": 'AC/DC: Rock"n"Roll', "thumbnail": ""})
2101+
2102+
_fetch_channel_art("https://www.youtube.com/@ACDC", data_path=str(tmp_path))
2103+
2104+
files = list(tmp_path.iterdir())
2105+
assert len(files) == 1
2106+
assert files[0].name.endswith(".channel.json")
2107+
assert "/" not in files[0].name
2108+
assert ":" not in files[0].name
2109+
2110+
2111+
def test_fetch_channel_art_save_error_does_not_raise(tmp_path, monkeypatch):
2112+
"""An OSError while saving channel.json is logged but does not affect the return value."""
2113+
from app import _fetch_channel_art
2114+
2115+
_make_yt_dlp_mock(monkeypatch, {"channel": "Test Channel", "thumbnail": "https://img/av.jpg"})
2116+
2117+
# Make tmp_path read-only so the write fails
2118+
tmp_path.chmod(0o555)
2119+
try:
2120+
result = _fetch_channel_art("https://www.youtube.com/@TestChannel", data_path=str(tmp_path))
2121+
finally:
2122+
tmp_path.chmod(0o755) # restore so tmp_path cleanup works
2123+
2124+
assert result is not None
2125+
assert result["channel"] == "Test Channel"
2126+
2127+
2128+
def test_fetch_channel_art_no_data_path_skips_save(tmp_path, monkeypatch):
2129+
"""When data_path is None, no file is written."""
2130+
from app import _fetch_channel_art
2131+
2132+
_make_yt_dlp_mock(monkeypatch, {"channel": "Test Channel", "thumbnail": "https://img/av.jpg"})
2133+
2134+
result = _fetch_channel_art("https://www.youtube.com/@TestChannel", data_path=None)
2135+
2136+
assert result is not None
2137+
assert list(tmp_path.iterdir()) == [], "no file should be written when data_path is None"
2138+
2139+
20042140
# ── _prefetch_channel_art_bg — error handling ────────────────────────────────
20052141

20062142

0 commit comments

Comments
 (0)