10 Commits

Author SHA1 Message Date
0347a638cf 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>
2026-06-13 11:09:27 -07:00
33ca743a34 removed useless portion of compose 2026-06-11 22:02:51 -07:00
7951c436dd Merge feat/unknown-album: default blank album to 'Unknown Album' 2026-06-11 21:53:11 -07:00
8b881c14bf feat: always embed an album tag (default 'Unknown Album')
Downloads with no album (regular YouTube videos, fan edits, etc) left a blank
album, which trips up players like Plexamp. yt_download now appends
--parse-metadata "%(album|Unknown Album)s:%(meta_album)s" so the native/resolved
album is kept when present, else 'Unknown Album' is embedded. Applies to all
download paths (search, playlist, single URL).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:53:11 -07:00
530b5b0406 typo in mutagen. fixed 2026-06-11 21:51:16 -07:00
0a4e6d474a added mutagen to requirements.txt 2026-06-11 21:48:33 -07:00
c0503187c5 Merge feat/repair-fast-meta: faster --repair via player_skip=js 2026-06-10 22:52:39 -07:00
a6aa469084 perf(repair): skip YouTube JS signature step when fetching tags
--repair only reads metadata (never downloads), so pass
--extractor-args youtube:player_skip=js to yt-dlp. Keeps album/artist/year/title
but avoids the slow, throttle-prone nsig JS step (which crawls without a JS
runtime and trips YouTube rate-limiting during bulk runs). run_yt_dlp_get_metadata
gains an optional extra_args param; the download path is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:52:39 -07:00
f071158c10 Merge feat/repair-exclude: -x/--exclude for repair/retag 2026-06-10 22:41:16 -07:00
c6bde6958a feat: -x/--exclude to skip folders during --repair/--retag-from-path
Repeatable -x/--exclude NAME skips any artist- or source-level folder whose name
matches (case-insensitive) when walking the library, so hand-curated folders like
/media/music/Unsorted or .../playlists are left untouched. Threaded through
_iter_source_files -> repair_library / retag_library_from_path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:41:16 -07:00
9 changed files with 339 additions and 63 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
__pycache__/
*.pyc
server/log.txt

View File

@@ -96,6 +96,7 @@ export LIDARR_API_KEY="your-lidarr-api-key"
| `--search-all` | Search all albums when adding an artist to Lidarr. |
| `--repair` | Re-tag existing downloads under `--root` from source metadata (see below). |
| `--retag-from-path` | Offline: re-tag artist/title from folder + filename (see below). |
| `-x`, `--exclude NAME` | Folder under `--root` to skip during `--repair`/`--retag-from-path` (repeatable). |
| `--debug` | Verbose output. |
### Examples
@@ -130,9 +131,11 @@ file using the `[id]` in its filename, and fixes tags. Useful when downloads lan
missing album or wrong year.
It is deliberately **conservative**: it overwrites **album** and **year** (the usual
breakage), but only *fills in* a missing **artist**/**title** — it never overwrites existing
artist/title with channel names or decorated video titles. Files whose source returns no real
music metadata (no album and no release year — e.g. plain music videos) are left untouched.
breakage), and fills in **artist**/**title** when they are missing *or* a known-bogus
placeholder (`NA`, `Unknown Album`, `Unknown Artist` — left behind by older buggy tagging) —
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
`mutagen` (a yt-dlp dependency, usually already present). CLI-only — not exposed via the REST API.
@@ -154,6 +157,9 @@ artist/title and leaves album/year alone.
```bash
./musicfetch --retag-from-path -d # preview
./musicfetch --retag-from-path -o /media/music
# Skip folders (e.g. hand-curated playlists you don't want re-tagged)
./musicfetch --repair -x Unsorted -x playlists
```
### 📁 Output Structure

View File

@@ -18,7 +18,8 @@ from dataclasses import dataclass, field
from typing import Optional
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
# 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)
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]:
try:
return [_album_to_hit(a) for a in lidarr_get("/api/v1/album/lookup", params={"term": term})]
except RequestException as e:
dbg(f"album/lookup failed: {e}")
_log_lidarr_failure("album/lookup", e)
return []
@@ -248,7 +260,7 @@ def _lidarr_artist_candidates(term: str) -> list[Hit]:
try:
return [_artist_to_hit(a) for a in lidarr_get("/api/v1/artist/lookup", params={"term": term})]
except RequestException as e:
dbg(f"artist/lookup failed: {e}")
_log_lidarr_failure("artist/lookup", e)
return []
@@ -605,19 +617,34 @@ def yt_download(url_or_query: str, target_folder: Optional[str], quality: str, d
cmd += ["-o", outtmpl]
else:
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.artist:
# First artist only; anchored ^.*$ replaces the whole field exactly once
# (a bare .* matches twice and doubles the value).
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"]
primary_artist = hit.artist.split(",")[0].strip() if hit.artist else ""
if primary_artist:
cmd += _force_tag("artist", primary_artist)
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:
cmd += ["--parse-metadata", f"{hit.year}:%(release_year)s"]
# When the hit carried no album, still embed one: the resolved/native album
# if present, else a placeholder so players (e.g. Plexamp) don't choke on a
# 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)
dest = outtmpl or target_folder
@@ -767,18 +794,21 @@ def download_single(url: str, root: str, quality: str, dry_run: bool) -> dict:
return {"title": title, "artist": artist, "ok": ok}
def run_yt_dlp_get_metadata(url: str) -> Optional[dict]:
def run_yt_dlp_get_metadata(url: str, extra_args=None) -> Optional[dict]:
cmd = ["yt-dlp", "-j", "--no-playlist", *(extra_args or []), url]
try:
result = subprocess.run(
["yt-dlp", "-j", "--no-playlist", url],
capture_output=True, text=True, check=True,
)
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return json.loads(result.stdout)
except (subprocess.CalledProcessError, json.JSONDecodeError) as e:
err(f"yt-dlp metadata extraction failed: {e}")
return None
# Repair only reads tags — skip YouTube's slow/throttled JS signature step
# (we never download here), which keeps metadata but is far faster per file.
_REPAIR_META_ARGS = ["--extractor-args", "youtube:player_skip=js"]
def get_artist_from_metadata(meta: dict) -> str:
for key in ("artist", "creator", "uploader", "channel"):
if meta.get(key):
@@ -875,12 +905,53 @@ def _read_tag(audio, key_map, field: str) -> str:
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]:
"""Re-tag one file from source metadata. Conservative: fixes album/year
(the common breakage) and only fills MISSING artist/title — never clobbers
existing tags with channel names or decorated music-video titles. Files whose
source has no real music metadata (no album/year, e.g. music videos) are left
untouched. Returns the list of changed fields."""
"""Re-tag one file from source metadata. album/year are authoritative
(overwrite); artist/title are filled when MISSING *or* a known-bogus
placeholder ('NA', 'Unknown …') — the old tagging bug wrote those — but a
genuine existing tag is never clobbered with a channel name or decorated
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))
if not parsed:
dbg(f"skip (no id): {path}")
@@ -893,17 +964,11 @@ def repair_file(path: str, source: str, dry_run: bool) -> list[str]:
if not url:
dbg(f"skip (source '{source}' not re-queryable): {path}")
return []
meta = run_yt_dlp_get_metadata(url)
meta = run_yt_dlp_get_metadata(url, _REPAIR_META_ARGS)
if not meta:
dbg(f"skip (no metadata): {path}")
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:
audio, key_map = _open_audio(path)
except Exception as e: # noqa: BLE001
@@ -912,18 +977,28 @@ def repair_file(path: str, source: str, dry_run: bool) -> list[str]:
if audio is None:
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 = {}
if 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:
updates["date"] = year
artist = get_artist_from_metadata(meta)
if artist and artist != "Unknown Artist" and not _read_tag(audio, key_map, "artist"):
updates["artist"] = artist
title = meta.get("title", "")
if title and not _read_tag(audio, key_map, "title"):
updates["title"] = title
# Fill artist/title when missing OR bogus; never overwrite a genuine value.
if meta_artist and not _is_bogus(meta_artist) and _is_bogus(cur_artist):
updates["artist"] = meta_artist
if meta_title and not _is_bogus(meta_title) and _is_bogus(cur_title):
updates["title"] = meta_title
changed = []
for field, value in updates.items():
@@ -936,16 +1011,22 @@ def repair_file(path: str, source: str, dry_run: bool) -> list[str]:
if changed:
prefix = "[dry-run] would set" if dry_run else "set"
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
def repair_library(root: str, dry_run: bool) -> tuple[int, int]:
def repair_library(root: str, dry_run: bool, exclude=()) -> tuple[int, int]:
"""Walk <root>/<artist>/<source>/ and re-tag audio files. Returns (scanned, changed)."""
if not os.path.isdir(root):
err(f"Root folder not found: {root}")
return 0, 0
scanned = changed = 0
for path, source, _artist in _iter_source_files(root):
for path, source, _artist in _iter_source_files(root, exclude):
scanned += 1
try:
if repair_file(path, source, dry_run):
@@ -957,14 +1038,20 @@ def repair_library(root: str, dry_run: bool) -> tuple[int, int]:
return scanned, changed
def _iter_source_files(root: str):
def _iter_source_files(root: str, exclude=()):
"""Yield (path, source, artist) for audio files under <root>/<artist>/<source>/
where source is a yt-dlp source folder (Lidarr album folders are skipped)."""
where source is a yt-dlp source folder (Lidarr album folders are skipped).
Skips any artist or source folder whose name is in `exclude` (case-insensitive)."""
skip = {e.lower() for e in exclude}
for artist in sorted(os.listdir(root)):
if artist.lower() in skip:
continue
adir = os.path.join(root, artist)
if not os.path.isdir(adir):
continue
for source in sorted(os.listdir(adir)):
if source.lower() in skip:
continue
sdir = os.path.join(adir, source)
if not os.path.isdir(sdir) or not _is_source_dir(source):
continue
@@ -1029,13 +1116,13 @@ def retag_file_from_path(path: str, folder_artist: str, dry_run: bool) -> list[s
return changed
def retag_library_from_path(root: str, dry_run: bool) -> tuple[int, int]:
def retag_library_from_path(root: str, dry_run: bool, exclude=()) -> tuple[int, int]:
"""Re-tag artist/title offline from folder+filename for every source file."""
if not os.path.isdir(root):
err(f"Root folder not found: {root}")
return 0, 0
scanned = changed = 0
for path, _source, artist in _iter_source_files(root):
for path, _source, artist in _iter_source_files(root, exclude):
scanned += 1
try:
if retag_file_from_path(path, artist, dry_run):
@@ -1087,6 +1174,9 @@ def parse_args():
p.add_argument("--retag-from-path", action="store_true",
help="Offline: re-tag artist/title from folder + filename "
"(fixes tags damaged by a prior --repair).")
p.add_argument("-x", "--exclude", action="append", default=[], metavar="NAME",
help="Folder name under --root to skip during --repair/--retag-from-path "
"(repeatable, e.g. -x Unsorted -x playlists).")
p.add_argument("--debug", action="store_true", help="Verbose output.")
return p.parse_args()
@@ -1098,11 +1188,11 @@ def main():
query = " ".join(args.query).strip()
if args.retag_from_path:
retag_library_from_path(args.root, args.dry_run)
retag_library_from_path(args.root, args.dry_run, args.exclude)
return
if args.repair:
repair_library(args.root, args.dry_run)
repair_library(args.root, args.dry_run, args.exclude)
return
if not query:

View File

@@ -1,7 +1,20 @@
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 \
&& 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/*
WORKDIR /app

View File

@@ -15,11 +15,3 @@ services:
MUSICFETCH_PORT: "6769"
volumes:
- /media/music:/media/music
networks:
- lidarr_net
networks:
lidarr_net:
external: true
# Set to the actual network name of your existing Lidarr stack, e.g.:
# name: media_default

View File

@@ -4,3 +4,4 @@ requests
ytmusicapi
rich
yt-dlp
mutagen

View File

@@ -81,3 +81,30 @@ def test_last_resort_universal_search(monkeypatch):
monkeypatch.setattr(mf, "lidarr_get", fake_get)
hits = mf.lidarr_search("Daft Punk - Discovery", 10)
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

View File

@@ -102,3 +102,33 @@ def test_yt_download_returns_true_on_zero_exit(monkeypatch):
def test_yt_download_dry_run_returns_true():
assert mf.yt_download("u", "/tmp/x", "best", True) is True
def test_yt_download_always_sets_album_default(monkeypatch):
captured = {}
monkeypatch.setattr(mf.os, "makedirs", lambda *a, **k: None)
monkeypatch.setattr(mf.subprocess, "run", lambda cmd, **k: captured.update(cmd=cmd) or _CP(""))
mf.yt_download("u", "/tmp/x", "best", False)
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

View File

@@ -63,7 +63,7 @@ class _FakeAudio(dict):
def test_repair_file_fixes_album_and_year(monkeypatch):
monkeypatch.setattr(mf, "run_yt_dlp_get_metadata",
lambda url: {"artist": "Daft Punk", "title": "Aerodynamic",
lambda url, *a: {"artist": "Daft Punk", "title": "Aerodynamic",
"album": "Discovery", "release_year": 2001})
audio = _FakeAudio({"artist": ["Daft Punk"], "title": ["Aerodynamic"]}) # album/date missing
monkeypatch.setattr(mf, "_open_audio", lambda path: (audio, None))
@@ -76,7 +76,7 @@ def test_repair_file_fixes_album_and_year(monkeypatch):
def test_repair_file_dry_run_writes_nothing(monkeypatch):
monkeypatch.setattr(mf, "run_yt_dlp_get_metadata",
lambda url: {"artist": "A", "title": "T", "album": "Alb", "release_year": 2020})
lambda url, *a: {"artist": "A", "title": "T", "album": "Alb", "release_year": 2020})
audio = _FakeAudio({})
monkeypatch.setattr(mf, "_open_audio", lambda path: (audio, None))
changed = mf.repair_file(f"X/youtube/T [{YT_ID}].opus", "youtube", dry_run=True)
@@ -88,7 +88,7 @@ def test_repair_file_dry_run_writes_nothing(monkeypatch):
def test_repair_file_skips_music_video(monkeypatch):
# No album AND no valid release year -> treat as a video, leave tags alone.
monkeypatch.setattr(mf, "run_yt_dlp_get_metadata",
lambda url: {"title": "Artist - Song (Official Music Video)",
lambda url, *a: {"title": "Artist - Song (Official Music Video)",
"uploader": "SomeVEVO", "upload_date": "20110101"})
audio = _FakeAudio({"artist": ["Real Artist"], "title": ["Song"]})
monkeypatch.setattr(mf, "_open_audio", lambda path: (audio, None))
@@ -100,7 +100,7 @@ def test_repair_file_skips_music_video(monkeypatch):
def test_repair_file_fills_missing_but_never_clobbers(monkeypatch):
# Source artist is a channel name; existing artist must be kept.
monkeypatch.setattr(mf, "run_yt_dlp_get_metadata",
lambda url: {"artist": "SomeChannelVEVO", "title": "Channel Decorated Title",
lambda url, *a: {"artist": "SomeChannelVEVO", "title": "Channel Decorated Title",
"album": "Real Album", "release_year": 2019})
audio = _FakeAudio({"artist": ["Correct Artist"], "title": ["Clean Title"]})
monkeypatch.setattr(mf, "_open_audio", lambda path: (audio, None))
@@ -112,7 +112,7 @@ def test_repair_file_fills_missing_but_never_clobbers(monkeypatch):
def test_repair_file_fills_missing_artist_when_absent(monkeypatch):
monkeypatch.setattr(mf, "run_yt_dlp_get_metadata",
lambda url: {"artist": "Real Artist", "title": "T",
lambda url, *a: {"artist": "Real Artist", "title": "T",
"album": "Alb", "release_year": 2020})
audio = _FakeAudio({}) # nothing present -> fill artist + title too
monkeypatch.setattr(mf, "_open_audio", lambda path: (audio, None))
@@ -123,7 +123,7 @@ def test_repair_file_fills_missing_artist_when_absent(monkeypatch):
def test_repair_file_skips_bad_id(monkeypatch):
called = {"meta": False}
monkeypatch.setattr(mf, "run_yt_dlp_get_metadata",
lambda url: called.update(meta=True) or {})
lambda url, *a: called.update(meta=True) or {})
# last bracket is a descriptor, not a real id
assert mf.repair_file("X/youtube/Song [Official Video].opus", "youtube", dry_run=False) == []
assert called["meta"] is False # never hit the network
@@ -132,11 +132,33 @@ def test_repair_file_skips_bad_id(monkeypatch):
def test_repair_file_skips_unparseable(monkeypatch):
called = {"meta": False}
monkeypatch.setattr(mf, "run_yt_dlp_get_metadata",
lambda url: called.update(meta=True) or {})
lambda url, *a: called.update(meta=True) or {})
assert mf.repair_file("X/youtube/no-id.opus", "youtube", dry_run=False) == []
assert called["meta"] is False
def test_run_yt_dlp_get_metadata_passes_extra_args(monkeypatch):
captured = {}
class _R:
stdout = '{"title": "x"}'
monkeypatch.setattr(mf.subprocess, "run", lambda cmd, **k: captured.update(cmd=cmd) or _R())
mf.run_yt_dlp_get_metadata("http://u", ["--extractor-args", "youtube:player_skip=js"])
assert "youtube:player_skip=js" in captured["cmd"]
def test_repair_uses_player_skip_fast_args(monkeypatch):
captured = {}
def fake_meta(url, extra_args=None):
captured["extra"] = extra_args
return {"album": "A", "release_year": 2020, "artist": "X", "title": "T"}
monkeypatch.setattr(mf, "run_yt_dlp_get_metadata", fake_meta)
monkeypatch.setattr(mf, "_open_audio", lambda p: (_FakeAudio({}), None))
mf.repair_file(f"X/youtube/T [{YT_ID}].opus", "youtube", dry_run=True)
assert captured["extra"] == mf._REPAIR_META_ARGS
# ---- repair_library (real temp tree, repair_file mocked) ----
def test_repair_library_scans_only_source_dirs(tmp_path, monkeypatch):
root = tmp_path
@@ -159,6 +181,23 @@ def test_repair_library_missing_root():
assert mf.repair_library("/no/such/dir", dry_run=False) == (0, 0)
def test_repair_library_exclude_skips_folders(tmp_path, monkeypatch):
root = tmp_path
(root / "Daft Punk" / "youtube").mkdir(parents=True)
(root / "Daft Punk" / "youtube" / f"A [{YT_ID}].opus").write_text("x")
(root / "Unsorted" / "youtube").mkdir(parents=True) # excluded artist folder
(root / "Unsorted" / "youtube" / f"B [{YT_ID}].opus").write_text("x")
(root / "Ephixa" / "playlists").mkdir(parents=True) # excluded source folder
(root / "Ephixa" / "playlists" / f"C [{YT_ID}].opus").write_text("x")
visited = []
monkeypatch.setattr(mf, "repair_file",
lambda path, source, dry_run: visited.append(path) or ["x"])
scanned, _ = mf.repair_library(str(root), dry_run=False, exclude=["unsorted", "playlists"])
assert scanned == 1
assert visited and "Daft Punk" in visited[0]
# ---- offline retag-from-path ----
def test_title_from_filename():
assert mf._title_from_filename(f"Song [{YT_ID}].opus") == "Song"
@@ -221,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)
assert (scanned, changed) == (1, 1)
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")