fix: reliable YouTube tagging, loud Lidarr failures, deno runtime, repair recovery
Root cause of bad album/title tags: yt-dlp's --parse-metadata reads a
single-word FROM (matching field_to_template's ^[a-zA-Z_]+$) as a *field
name*, so literal one-word titles/albums like "Cochise" became "NA". Inject
literals via seed-then-replace into meta_<tag> instead (--parse-metadata to
create the field, --replace-in-metadata with literal args to set it), which
is immune to template parsing and also creates tags the source lacks.
- yt_download: literal-safe meta_artist/title/album; hit album no longer
clobbered by the Unknown-Album default; artist tag now created when missing.
- lidarr_search: connection/timeout errors surface via err() ("Lidarr
unreachable … falling back to YouTube") instead of silent dbg(), so the
YouTube fallback isn't mistaken for "no Lidarr match".
- Dockerfile: install deno (arch-aware) — the JS runtime yt-dlp needs for
YouTube; without it: "No supported JavaScript runtime" / HTTP 403.
- repair: treat NA/Unknown placeholders as bogus and overwrite title/artist
from source (was fill-missing-only); normalise literal "NA" album to
"Unknown Album"; rename bogus "NA [<id>]" filenames to the recovered title.
- README updated; .gitignore excludes server/log.txt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
|
server/log.txt
|
||||||
|
|||||||
@@ -131,9 +131,11 @@ file using the `[id]` in its filename, and fixes tags. Useful when downloads lan
|
|||||||
missing album or wrong year.
|
missing album or wrong year.
|
||||||
|
|
||||||
It is deliberately **conservative**: it overwrites **album** and **year** (the usual
|
It is deliberately **conservative**: it overwrites **album** and **year** (the usual
|
||||||
breakage), but only *fills in* a missing **artist**/**title** — it never overwrites existing
|
breakage), and fills in **artist**/**title** when they are missing *or* a known-bogus
|
||||||
artist/title with channel names or decorated video titles. Files whose source returns no real
|
placeholder (`NA`, `Unknown Album`, `Unknown Artist` — left behind by older buggy tagging) —
|
||||||
music metadata (no album and no release year — e.g. plain music videos) are left untouched.
|
but it never overwrites a genuine existing artist/title with a channel name or decorated video
|
||||||
|
title. A bogus `NA [<id>].<ext>` filename is renamed to the recovered title, and a literal
|
||||||
|
`NA` album with no source album is normalised to `Unknown Album`.
|
||||||
|
|
||||||
It re-queries the source over the network, so run it occasionally, not constantly. Requires
|
It re-queries the source over the network, so run it occasionally, not constantly. Requires
|
||||||
`mutagen` (a yt-dlp dependency, usually already present). CLI-only — not exposed via the REST API.
|
`mutagen` (a yt-dlp dependency, usually already present). CLI-only — not exposed via the REST API.
|
||||||
|
|||||||
141
musicfetch
141
musicfetch
@@ -18,7 +18,8 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests.exceptions import RequestException
|
from requests.exceptions import ConnectionError as ReqConnectionError
|
||||||
|
from requests.exceptions import RequestException, Timeout
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
# Optional deps — degrade gracefully if missing.
|
# Optional deps — degrade gracefully if missing.
|
||||||
@@ -236,11 +237,22 @@ def lidarr_search(query: str, limit: int) -> list[Hit]:
|
|||||||
return _fallback_lookup(query, limit, artist_first=True)
|
return _fallback_lookup(query, limit, artist_first=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_lidarr_failure(label: str, e: Exception) -> None:
|
||||||
|
"""A connection/timeout error means Lidarr is unreachable — the silent
|
||||||
|
YouTube fallback that follows is easy to mistake for "Lidarr had no match",
|
||||||
|
so surface it loudly. Ordinary HTTP errors stay debug-only."""
|
||||||
|
if isinstance(e, (ReqConnectionError, Timeout)):
|
||||||
|
err(f"Lidarr unreachable ({label} at {LIDARR_URL}): {e}. "
|
||||||
|
f"Falling back to YouTube.")
|
||||||
|
else:
|
||||||
|
dbg(f"{label} failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _lidarr_album_candidates(term: str) -> list[Hit]:
|
def _lidarr_album_candidates(term: str) -> list[Hit]:
|
||||||
try:
|
try:
|
||||||
return [_album_to_hit(a) for a in lidarr_get("/api/v1/album/lookup", params={"term": term})]
|
return [_album_to_hit(a) for a in lidarr_get("/api/v1/album/lookup", params={"term": term})]
|
||||||
except RequestException as e:
|
except RequestException as e:
|
||||||
dbg(f"album/lookup failed: {e}")
|
_log_lidarr_failure("album/lookup", e)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -248,7 +260,7 @@ def _lidarr_artist_candidates(term: str) -> list[Hit]:
|
|||||||
try:
|
try:
|
||||||
return [_artist_to_hit(a) for a in lidarr_get("/api/v1/artist/lookup", params={"term": term})]
|
return [_artist_to_hit(a) for a in lidarr_get("/api/v1/artist/lookup", params={"term": term})]
|
||||||
except RequestException as e:
|
except RequestException as e:
|
||||||
dbg(f"artist/lookup failed: {e}")
|
_log_lidarr_failure("artist/lookup", e)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -605,22 +617,34 @@ def yt_download(url_or_query: str, target_folder: Optional[str], quality: str, d
|
|||||||
cmd += ["-o", outtmpl]
|
cmd += ["-o", outtmpl]
|
||||||
else:
|
else:
|
||||||
cmd += ["-P", target_folder]
|
cmd += ["-P", target_folder]
|
||||||
# Override tags from the chosen hit so they don't rely on scraped titles.
|
# Override embedded tags from the chosen hit. Inject literals via a
|
||||||
|
# seed-then-replace pair: --parse-metadata first copies an always-present
|
||||||
|
# field into meta_<tag> (so the tag exists even when the source lacks it,
|
||||||
|
# e.g. YouTube videos with no album), then --replace-in-metadata overwrites
|
||||||
|
# it with the literal value. This dodges yt-dlp's output-template trap where
|
||||||
|
# a bare-word FROM (e.g. "Cochise") matches field_to_template's r'[a-zA-Z_]+$'
|
||||||
|
# and is read as a *field name* -> "NA". --replace-in-metadata args are
|
||||||
|
# literal, so single-word values and parens survive intact.
|
||||||
|
def _force_tag(field: str, value: str) -> list[str]:
|
||||||
|
repl = value.replace("\\", r"\\") # backslash is special in re.sub repl
|
||||||
|
return ["--parse-metadata", f"%(title,id)s:%(meta_{field})s",
|
||||||
|
"--replace-in-metadata", f"meta_{field}", "^.*$", repl]
|
||||||
|
|
||||||
if hit:
|
if hit:
|
||||||
if hit.artist:
|
primary_artist = hit.artist.split(",")[0].strip() if hit.artist else ""
|
||||||
# First artist only; anchored ^.*$ replaces the whole field exactly once
|
if primary_artist:
|
||||||
# (a bare .* matches twice and doubles the value).
|
cmd += _force_tag("artist", primary_artist)
|
||||||
primary_artist = hit.artist.split(",")[0].strip()
|
|
||||||
cmd += ["--replace-in-metadata", "artist", "^.*$", primary_artist]
|
|
||||||
if hit.album:
|
|
||||||
cmd += ["--parse-metadata", f"{hit.album}:%(album)s"]
|
|
||||||
if hit.title:
|
if hit.title:
|
||||||
cmd += ["--parse-metadata", f"{hit.title}:%(title)s"]
|
cmd += _force_tag("title", hit.title)
|
||||||
|
if hit.album:
|
||||||
|
cmd += _force_tag("album", hit.album)
|
||||||
if hit.year:
|
if hit.year:
|
||||||
cmd += ["--parse-metadata", f"{hit.year}:%(release_year)s"]
|
cmd += ["--parse-metadata", f"{hit.year}:%(release_year)s"]
|
||||||
# Always embed an album: the resolved/native album if present, else a
|
# When the hit carried no album, still embed one: the resolved/native album
|
||||||
# placeholder so players (e.g. Plexamp) don't choke on a blank album.
|
# if present, else a placeholder so players (e.g. Plexamp) don't choke on a
|
||||||
cmd += ["--parse-metadata", "%(album|Unknown Album)s:%(meta_album)s"]
|
# blank album. (A hit album is already forced above and must not be clobbered.)
|
||||||
|
if not (hit and hit.album):
|
||||||
|
cmd += ["--parse-metadata", "%(album|Unknown Album)s:%(meta_album)s"]
|
||||||
cmd.append(url_or_query)
|
cmd.append(url_or_query)
|
||||||
|
|
||||||
dest = outtmpl or target_folder
|
dest = outtmpl or target_folder
|
||||||
@@ -881,12 +905,53 @@ def _read_tag(audio, key_map, field: str) -> str:
|
|||||||
return str(val[0]) if isinstance(val, list) else str(val)
|
return str(val[0]) if isinstance(val, list) else str(val)
|
||||||
|
|
||||||
|
|
||||||
|
# Placeholder tag values the old tagging bug left behind (yt-dlp's "NA" missing
|
||||||
|
# marker, and the "Unknown *" fallbacks). Treated as empty so repair overwrites
|
||||||
|
# them rather than mistaking them for a real, present tag.
|
||||||
|
_BOGUS_TAGS = {"", "na", "n/a", "unknown", "unknown album", "unknown artist"}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_bogus(value: str) -> bool:
|
||||||
|
return (value or "").strip().casefold() in _BOGUS_TAGS
|
||||||
|
|
||||||
|
|
||||||
|
def _fs_safe(name: str) -> str:
|
||||||
|
"""Filesystem-safe filename stem: mirror yt-dlp's default '/'->'⧸' so the
|
||||||
|
path stays a single segment, and drop NULs."""
|
||||||
|
return name.replace("/", "⧸").replace("\0", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_rename_bogus(path: str, title: str, dry_run: bool) -> tuple[str, Optional[str]]:
|
||||||
|
"""When the filename stem is a placeholder (e.g. 'NA [<id>]'), rename to
|
||||||
|
'<title> [<id>].<ext>'. Returns (current_path, change_note_or_None)."""
|
||||||
|
fname = os.path.basename(path)
|
||||||
|
parsed = _parse_track_file(fname)
|
||||||
|
if not parsed:
|
||||||
|
return path, None
|
||||||
|
stem_title, vid = parsed
|
||||||
|
if not _is_bogus(stem_title) or _is_bogus(title):
|
||||||
|
return path, None
|
||||||
|
ext = fname.rsplit(".", 1)[-1]
|
||||||
|
new_name = f"{_fs_safe(title)} [{vid}].{ext}"
|
||||||
|
new_path = os.path.join(os.path.dirname(path), new_name)
|
||||||
|
if new_path == path or not new_name:
|
||||||
|
return path, None
|
||||||
|
note = f"renamed -> {new_name}"
|
||||||
|
if dry_run:
|
||||||
|
print(f"[dry-run] would rename {fname} -> {new_name}")
|
||||||
|
return path, note
|
||||||
|
os.rename(path, new_path)
|
||||||
|
print(f"renamed {fname} -> {new_name}")
|
||||||
|
return new_path, note
|
||||||
|
|
||||||
|
|
||||||
def repair_file(path: str, source: str, dry_run: bool) -> list[str]:
|
def repair_file(path: str, source: str, dry_run: bool) -> list[str]:
|
||||||
"""Re-tag one file from source metadata. Conservative: fixes album/year
|
"""Re-tag one file from source metadata. album/year are authoritative
|
||||||
(the common breakage) and only fills MISSING artist/title — never clobbers
|
(overwrite); artist/title are filled when MISSING *or* a known-bogus
|
||||||
existing tags with channel names or decorated music-video titles. Files whose
|
placeholder ('NA', 'Unknown …') — the old tagging bug wrote those — but a
|
||||||
source has no real music metadata (no album/year, e.g. music videos) are left
|
genuine existing tag is never clobbered with a channel name or decorated
|
||||||
untouched. Returns the list of changed fields."""
|
music-video title. A bogus 'NA [<id>]' filename is renamed to the recovered
|
||||||
|
title. Returns the list of changed fields."""
|
||||||
parsed = _parse_track_file(os.path.basename(path))
|
parsed = _parse_track_file(os.path.basename(path))
|
||||||
if not parsed:
|
if not parsed:
|
||||||
dbg(f"skip (no id): {path}")
|
dbg(f"skip (no id): {path}")
|
||||||
@@ -904,12 +969,6 @@ def repair_file(path: str, source: str, dry_run: bool) -> list[str]:
|
|||||||
dbg(f"skip (no metadata): {path}")
|
dbg(f"skip (no metadata): {path}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
album = (meta.get("album") or "").strip()
|
|
||||||
year = _valid_year(meta)
|
|
||||||
if not album and not year:
|
|
||||||
dbg(f"skip (no music metadata, likely a video): {path}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
audio, key_map = _open_audio(path)
|
audio, key_map = _open_audio(path)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
@@ -918,18 +977,28 @@ def repair_file(path: str, source: str, dry_run: bool) -> list[str]:
|
|||||||
if audio is None:
|
if audio is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# album/year are authoritative (overwrite); artist/title fill-missing only.
|
album = (meta.get("album") or "").strip()
|
||||||
|
year = _valid_year(meta)
|
||||||
|
cur_artist = _read_tag(audio, key_map, "artist")
|
||||||
|
cur_title = _read_tag(audio, key_map, "title")
|
||||||
|
cur_album = _read_tag(audio, key_map, "album")
|
||||||
|
meta_artist = get_artist_from_metadata(meta)
|
||||||
|
meta_title = (meta.get("title") or "").strip()
|
||||||
|
|
||||||
updates = {}
|
updates = {}
|
||||||
if album:
|
if album:
|
||||||
updates["album"] = album
|
updates["album"] = album
|
||||||
|
elif cur_album and _is_bogus(cur_album) and cur_album.strip().casefold() != "unknown album":
|
||||||
|
# No source album, but the tag is a literal 'NA' — normalise it so no
|
||||||
|
# file keeps the placeholder (a blank album is left blank, as before).
|
||||||
|
updates["album"] = "Unknown Album"
|
||||||
if year:
|
if year:
|
||||||
updates["date"] = year
|
updates["date"] = year
|
||||||
artist = get_artist_from_metadata(meta)
|
# Fill artist/title when missing OR bogus; never overwrite a genuine value.
|
||||||
if artist and artist != "Unknown Artist" and not _read_tag(audio, key_map, "artist"):
|
if meta_artist and not _is_bogus(meta_artist) and _is_bogus(cur_artist):
|
||||||
updates["artist"] = artist
|
updates["artist"] = meta_artist
|
||||||
title = meta.get("title", "")
|
if meta_title and not _is_bogus(meta_title) and _is_bogus(cur_title):
|
||||||
if title and not _read_tag(audio, key_map, "title"):
|
updates["title"] = meta_title
|
||||||
updates["title"] = title
|
|
||||||
|
|
||||||
changed = []
|
changed = []
|
||||||
for field, value in updates.items():
|
for field, value in updates.items():
|
||||||
@@ -942,6 +1011,12 @@ def repair_file(path: str, source: str, dry_run: bool) -> list[str]:
|
|||||||
if changed:
|
if changed:
|
||||||
prefix = "[dry-run] would set" if dry_run else "set"
|
prefix = "[dry-run] would set" if dry_run else "set"
|
||||||
print(f"{prefix} [{', '.join(changed)}] on {path}")
|
print(f"{prefix} [{', '.join(changed)}] on {path}")
|
||||||
|
|
||||||
|
# Repair a placeholder filename using the final (recovered) title.
|
||||||
|
final_title = updates.get("title") or cur_title
|
||||||
|
_, rename_note = _maybe_rename_bogus(path, final_title, dry_run)
|
||||||
|
if rename_note:
|
||||||
|
changed.append(rename_note)
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# ffmpeg for audio extraction/embedding; deno is the JS runtime yt-dlp needs
|
||||||
|
# for YouTube (without it: "No supported JavaScript runtime" -> missing formats
|
||||||
|
# / HTTP 403). yt-dlp auto-detects deno on PATH.
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates curl unzip \
|
||||||
|
&& arch="$(uname -m)" \
|
||||||
|
&& case "$arch" in \
|
||||||
|
x86_64) deno_arch=x86_64-unknown-linux-gnu ;; \
|
||||||
|
aarch64) deno_arch=aarch64-unknown-linux-gnu ;; \
|
||||||
|
*) echo "unsupported arch: $arch" >&2; exit 1 ;; \
|
||||||
|
esac \
|
||||||
|
&& curl -fsSL "https://github.com/denoland/deno/releases/latest/download/deno-${deno_arch}.zip" -o /tmp/deno.zip \
|
||||||
|
&& unzip /tmp/deno.zip -d /usr/local/bin \
|
||||||
|
&& rm /tmp/deno.zip \
|
||||||
|
&& apt-get purge -y --auto-remove curl unzip \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -81,3 +81,30 @@ def test_last_resort_universal_search(monkeypatch):
|
|||||||
monkeypatch.setattr(mf, "lidarr_get", fake_get)
|
monkeypatch.setattr(mf, "lidarr_get", fake_get)
|
||||||
hits = mf.lidarr_search("Daft Punk - Discovery", 10)
|
hits = mf.lidarr_search("Daft Punk - Discovery", 10)
|
||||||
assert hits and hits[0].album == "Discovery"
|
assert hits and hits[0].album == "Discovery"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreachable_lidarr_warns_loudly(monkeypatch, capsys):
|
||||||
|
# A connection error must surface on stderr (not silent dbg) so the
|
||||||
|
# YouTube fallback isn't mistaken for "Lidarr had no match".
|
||||||
|
monkeypatch.setattr(mf, "API_KEY", "testkey")
|
||||||
|
monkeypatch.setattr(mf, "DEBUG", False)
|
||||||
|
|
||||||
|
def boom(path, params=None, timeout=15):
|
||||||
|
raise mf.ReqConnectionError("Name or service not known")
|
||||||
|
monkeypatch.setattr(mf, "lidarr_get", boom)
|
||||||
|
|
||||||
|
hits = mf._lidarr_album_candidates("anything")
|
||||||
|
assert hits == []
|
||||||
|
assert "Lidarr unreachable" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_error_stays_quiet(monkeypatch, capsys):
|
||||||
|
monkeypatch.setattr(mf, "API_KEY", "testkey")
|
||||||
|
monkeypatch.setattr(mf, "DEBUG", False)
|
||||||
|
|
||||||
|
def boom(path, params=None, timeout=15):
|
||||||
|
raise mf.RequestException("500 Server Error")
|
||||||
|
monkeypatch.setattr(mf, "lidarr_get", boom)
|
||||||
|
|
||||||
|
assert mf._lidarr_album_candidates("anything") == []
|
||||||
|
assert "Lidarr unreachable" not in capsys.readouterr().err
|
||||||
|
|||||||
@@ -110,3 +110,25 @@ def test_yt_download_always_sets_album_default(monkeypatch):
|
|||||||
monkeypatch.setattr(mf.subprocess, "run", lambda cmd, **k: captured.update(cmd=cmd) or _CP(""))
|
monkeypatch.setattr(mf.subprocess, "run", lambda cmd, **k: captured.update(cmd=cmd) or _CP(""))
|
||||||
mf.yt_download("u", "/tmp/x", "best", False)
|
mf.yt_download("u", "/tmp/x", "best", False)
|
||||||
assert "%(album|Unknown Album)s:%(meta_album)s" in captured["cmd"]
|
assert "%(album|Unknown Album)s:%(meta_album)s" in captured["cmd"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_yt_download_single_word_tags_injected_literally(monkeypatch):
|
||||||
|
# Regression: `--parse-metadata "Cochise:%(title)s"` makes yt-dlp treat the
|
||||||
|
# bare word 'Cochise' as a FIELD name (field_to_template's r'[a-zA-Z_]+$'),
|
||||||
|
# producing 'NA'. Single-word album/title must reach yt-dlp as literals.
|
||||||
|
captured = {}
|
||||||
|
monkeypatch.setattr(mf.os, "makedirs", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(mf.subprocess, "run", lambda cmd, **k: captured.update(cmd=cmd) or _CP(""))
|
||||||
|
hit = mf.Hit(source="youtube", kind="track", title="Cochise",
|
||||||
|
artist="Audioslave", album="Solid", payload={"videoId": "x"})
|
||||||
|
mf.yt_download("u", "/tmp/x", "best", False, hit=hit)
|
||||||
|
cmd = captured["cmd"]
|
||||||
|
joined = " ".join(cmd)
|
||||||
|
# The buggy bare-word parse-metadata FROM must be gone.
|
||||||
|
assert "Solid:%(album)s" not in joined
|
||||||
|
assert "Cochise:%(title)s" not in joined
|
||||||
|
# Literal values must be passed as literal args (immune to template parsing).
|
||||||
|
assert "Solid" in cmd
|
||||||
|
assert "Cochise" in cmd
|
||||||
|
# A hit album must not be clobbered by the Unknown-Album default.
|
||||||
|
assert "%(album|Unknown Album)s:%(meta_album)s" not in cmd
|
||||||
|
|||||||
@@ -260,3 +260,79 @@ def test_retag_library_walks_source_files(tmp_path, monkeypatch):
|
|||||||
scanned, changed = mf.retag_library_from_path(str(root), dry_run=False)
|
scanned, changed = mf.retag_library_from_path(str(root), dry_run=False)
|
||||||
assert (scanned, changed) == (1, 1)
|
assert (scanned, changed) == (1, 1)
|
||||||
assert visited == ["Daft Punk"]
|
assert visited == ["Daft Punk"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- bogus-tag recovery (old-code NA / Unknown breakage) ----
|
||||||
|
def test_is_bogus():
|
||||||
|
for v in ("", "NA", "na", "N/A", "Unknown", "Unknown Album", "unknown artist", " NA "):
|
||||||
|
assert mf._is_bogus(v) is True, v
|
||||||
|
for v in ("Cochise", "Solid", "Brother Stoon", "Discovery"):
|
||||||
|
assert mf._is_bogus(v) is False, v
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_file_overwrites_bogus_title(monkeypatch):
|
||||||
|
monkeypatch.setattr(mf, "run_yt_dlp_get_metadata",
|
||||||
|
lambda url, *a: {"artist": "Audioslave", "title": "Cochise",
|
||||||
|
"album": "Audioslave", "release_year": 2002})
|
||||||
|
audio = _FakeAudio({"artist": ["Audioslave"], "title": ["NA"]}) # bogus title
|
||||||
|
monkeypatch.setattr(mf, "_open_audio", lambda path: (audio, None))
|
||||||
|
changed = mf.repair_file(f"X/youtube/Brother Stoon [{YT_ID}].opus", "youtube", dry_run=False)
|
||||||
|
assert "title=Cochise" in changed
|
||||||
|
assert audio["title"] == ["Cochise"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_file_overwrites_bogus_artist(monkeypatch):
|
||||||
|
monkeypatch.setattr(mf, "run_yt_dlp_get_metadata",
|
||||||
|
lambda url, *a: {"artist": "Real Artist", "title": "Real Title",
|
||||||
|
"album": "Alb", "release_year": 2020})
|
||||||
|
audio = _FakeAudio({"artist": ["NA"], "title": ["Good Title"]}) # bogus artist, good title
|
||||||
|
monkeypatch.setattr(mf, "_open_audio", lambda path: (audio, None))
|
||||||
|
changed = mf.repair_file(f"X/youtube/Good Title [{YT_ID}].opus", "youtube", dry_run=False)
|
||||||
|
assert "artist=Real Artist" in changed
|
||||||
|
assert audio["artist"] == ["Real Artist"]
|
||||||
|
assert audio["title"] == ["Good Title"] # good title untouched
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_file_normalizes_na_album_when_source_has_none(monkeypatch):
|
||||||
|
# Music video: no source album/year, but album tag is the literal 'NA' -> Unknown Album.
|
||||||
|
monkeypatch.setattr(mf, "run_yt_dlp_get_metadata",
|
||||||
|
lambda url, *a: {"title": "Some Live Thing", "uploader": "Chan"})
|
||||||
|
audio = _FakeAudio({"artist": ["X"], "title": ["Y"], "album": ["NA"]})
|
||||||
|
monkeypatch.setattr(mf, "_open_audio", lambda path: (audio, None))
|
||||||
|
changed = mf.repair_file(f"X/youtube/Y [{YT_ID}].opus", "youtube", dry_run=False)
|
||||||
|
assert "album=Unknown Album" in changed
|
||||||
|
assert audio["album"] == ["Unknown Album"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_file_renames_bogus_filename(tmp_path, monkeypatch):
|
||||||
|
d = tmp_path / "Audioslave" / "youtube"
|
||||||
|
d.mkdir(parents=True)
|
||||||
|
f = d / f"NA [{YT_ID}].opus"
|
||||||
|
f.write_text("x")
|
||||||
|
monkeypatch.setattr(mf, "run_yt_dlp_get_metadata",
|
||||||
|
lambda url, *a: {"artist": "Audioslave", "title": "Cochise",
|
||||||
|
"album": "Audioslave", "release_year": 2002})
|
||||||
|
audio = _FakeAudio({"artist": ["Audioslave"], "title": ["NA"]})
|
||||||
|
monkeypatch.setattr(mf, "_open_audio", lambda path: (audio, None))
|
||||||
|
changed = mf.repair_file(str(f), "youtube", dry_run=False)
|
||||||
|
assert (d / f"Cochise [{YT_ID}].opus").exists()
|
||||||
|
assert not f.exists()
|
||||||
|
assert any("rename" in c.lower() or c.startswith("title=") for c in changed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_file_dry_run_does_not_rename(tmp_path, monkeypatch):
|
||||||
|
d = tmp_path / "Audioslave" / "youtube"
|
||||||
|
d.mkdir(parents=True)
|
||||||
|
f = d / f"NA [{YT_ID}].opus"
|
||||||
|
f.write_text("x")
|
||||||
|
monkeypatch.setattr(mf, "run_yt_dlp_get_metadata",
|
||||||
|
lambda url, *a: {"artist": "Audioslave", "title": "Cochise",
|
||||||
|
"album": "Audioslave", "release_year": 2002})
|
||||||
|
monkeypatch.setattr(mf, "_open_audio", lambda path: (_FakeAudio({"title": ["NA"]}), None))
|
||||||
|
mf.repair_file(str(f), "youtube", dry_run=True)
|
||||||
|
assert f.exists() # untouched in dry-run
|
||||||
|
assert not (d / f"Cochise [{YT_ID}].opus").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fs_safe_replaces_slash():
|
||||||
|
assert "/" not in mf._fs_safe("AC/DC Live")
|
||||||
|
|||||||
Reference in New Issue
Block a user