14 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
7ea3ad2538 Merge feat/retag-from-path: offline tag recovery 2026-06-10 22:30:25 -07:00
9af7f91a25 feat: --retag-from-path to recover tags damaged by a prior --repair
Offline re-tag of artist/title from the artist folder + filename: strips
(Official Video)/(Lyrics)-style decorations and trailing [id], and treats an
'Artist - Title' filename as authoritative (recovering the real artist for
music videos filed under a channel name). Overwrites artist/title only; leaves
album/year. Honors --dry-run.

Refactors the source-folder walk into _iter_source_files, shared by --repair
and --retag-from-path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:30:25 -07:00
567d7578ad Merge fix/repair-music-videos: conservative --repair + first-artist single folder 2026-06-10 18:53:27 -07:00
c6e28a4f75 fix: harden --repair against music videos; first-artist folder for single URLs
--repair was clobbering good tags and erroring on real libraries:
- Validate the parsed id per source (YouTube 11-char, SoundCloud numeric) so
  junk ids from bracketed descriptors ([Official Video]) are skipped, not queried.
- Skip files whose source returns no real music metadata (no album/year, e.g.
  music videos) instead of overwriting clean tags with channel/decorated titles.
- Year from release info only (sane 1000-2100), never upload_date (which gave
  wrong years for old songs and bogus values like 6577).
- album/year are authoritative; artist/title are fill-missing-only (no clobber).

Also: download_single now uses the first artist for the folder (matching the
search/playlist paths) so single-URL downloads stop creating multi-artist dirs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 18:53:27 -07:00
9 changed files with 628 additions and 107 deletions

2
.gitignore vendored
View File

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

View File

@@ -95,6 +95,8 @@ export LIDARR_API_KEY="your-lidarr-api-key"
| `-o`, `--root PATH` | Output root folder (default `/media/music`). |
| `--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
@@ -125,10 +127,18 @@ export LIDARR_API_KEY="your-lidarr-api-key"
`--repair` walks `<root>/<artist>/<source>/` (the `youtube`/`soundcloud`/… download
folders — Lidarr album folders are skipped), re-fetches authoritative metadata for each
file using the `[id]` in its filename, and fixes tags (album, year, artist, title). Useful
when downloads landed with missing album or wrong year. 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.
file using the `[id]` in its filename, and fixes tags. Useful when downloads landed with
missing album or wrong year.
It is deliberately **conservative**: it overwrites **album** and **year** (the usual
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.
```bash
# Preview what would change (writes nothing)
@@ -138,6 +148,20 @@ usually already present). CLI-only — not exposed via the REST API.
./musicfetch --repair -o /media/music
```
**`--retag-from-path`** is an offline companion: it derives **artist** and **title** purely
from the folder name + filename (stripping `(Official Video)` / `(Lyrics)`-style decorations,
and treating an `Artist - Title` filename correctly), with no network. Use it to undo bad
tags — e.g. titles/artists clobbered by an earlier `--repair` on music videos. It overwrites
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
```text

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
@@ -760,23 +787,28 @@ def download_single(url: str, root: str, quality: str, dry_run: bool) -> dict:
artist = get_artist_from_metadata(meta) if meta else "Unknown Artist"
title = (meta or {}).get("title", "")
source = _sanitize_source((meta or {}).get("extractor", "")) if meta else "downloads"
target = os.path.join(root, artist, source)
# First artist only for the folder (matches the search/playlist paths).
artist_dir = artist.split(",")[0].strip() or "Unknown Artist"
target = os.path.join(root, artist_dir, source)
ok = yt_download(url, target, quality, dry_run)
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):
@@ -827,18 +859,24 @@ def _repair_probe_url(source: str, vid: str):
return None
def _desired_tags(meta: dict) -> dict:
"""Authoritative tags from yt-dlp metadata (omit empties)."""
year = (str(meta.get("release_year") or "")
or (meta.get("release_date") or "")[:4]
or (meta.get("upload_date") or "")[:4])
fields = {
"artist": get_artist_from_metadata(meta),
"title": meta.get("title", ""),
"album": meta.get("album", ""),
"date": year,
}
return {k: v for k, v in fields.items() if v and v != "Unknown Artist"}
def _repair_id_ok(source: str, vid: str) -> bool:
"""True if the parsed id matches the source's id format (avoids querying
junk ids pulled from bracketed descriptors like '[Official Video]')."""
if source == "youtube":
return bool(re.fullmatch(r"[A-Za-z0-9_-]{11}", vid))
if source == "soundcloud":
return vid.isdigit()
return False
def _valid_year(meta: dict) -> str:
"""A plausible release year from metadata, or '' . Uses release info only —
NOT upload_date, which is the upload year, not the song's year."""
for v in (meta.get("release_year"), (meta.get("release_date") or "")[:4]):
s = str(v or "")
if s.isdigit() and 1000 <= int(s) <= 2100:
return s
return ""
def _open_audio(path: str):
@@ -867,22 +905,70 @@ 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. 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}")
return []
_, vid = parsed
if not _repair_id_ok(source, vid):
dbg(f"skip (bad {source} id '{vid}'): {path}")
return []
url = _repair_probe_url(source, vid)
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 []
desired = _desired_tags(meta)
try:
audio, key_map = _open_audio(path)
except Exception as e: # noqa: BLE001
@@ -890,8 +976,134 @@ def repair_file(path: str, source: str, dry_run: bool) -> list[str]:
return []
if audio is None:
return []
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
# 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 desired.items():
for field, value in updates.items():
if _read_tag(audio, key_map, field) != value:
changed.append(f"{field}={value}")
if not dry_run:
audio[key_map[field] if key_map else field] = [value]
if changed and not dry_run:
audio.save()
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, 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, exclude):
scanned += 1
try:
if repair_file(path, source, dry_run):
changed += 1
except Exception as e: # noqa: BLE001 — one bad file shouldn't abort
err(f"repair failed ({os.path.basename(path)}): {e}")
verb = "Would repair" if dry_run else "Repaired"
print(f"{verb} {changed}/{scanned} files")
return scanned, changed
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).
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
for fname in sorted(os.listdir(sdir)):
if fname.lower().endswith(_AUDIO_EXTS):
yield os.path.join(sdir, fname), source, artist
# --- Offline retag-from-path (recover from tags damaged by a prior --repair) ---
_DECORATION_RE = re.compile(
r"\s*[\(\[][^)\]]*\b(?:official|lyric[s]?|audio|visuali[sz]er|"
r"music\s+video|m/?v|hd|hq|4k|explicit|remaster(?:ed)?)\b[^)\]]*[\)\]]",
re.IGNORECASE)
def _title_from_filename(filename: str) -> str:
"""Filename minus extension and a trailing ' [<id>]'."""
stem = re.sub(r"\.(?:" + "|".join(_AUDIO_EXTS) + r")$", "", filename, flags=re.IGNORECASE)
return re.sub(r"\s*\[[^\]]+\]$", "", stem).strip()
def _strip_decorations(title: str) -> str:
return re.sub(r"\s{2,}", " ", _DECORATION_RE.sub("", title)).strip(" -–—")
def _derive_from_filename(filename: str, folder_artist: str) -> tuple[str, str]:
"""Best-effort (artist, title) from the filename. A 'Artist - Title' name wins
over the folder (handles music-video downloads filed under a channel name)."""
title = _strip_decorations(_title_from_filename(filename))
if " - " in title:
left, right = title.split(" - ", 1)
return left.strip(), right.strip()
return folder_artist, title
def retag_file_from_path(path: str, folder_artist: str, dry_run: bool) -> list[str]:
"""Overwrite artist/title from the folder + cleaned filename. Leaves album/date."""
artist, title = _derive_from_filename(os.path.basename(path), folder_artist)
try:
audio, key_map = _open_audio(path)
except Exception as e: # noqa: BLE001
err(f"cannot open {path}: {e}")
return []
if audio is None:
return []
updates = {}
if artist:
updates["artist"] = artist
if title:
updates["title"] = title
changed = []
for field, value in updates.items():
if _read_tag(audio, key_map, field) != value:
changed.append(f"{field}={value}")
if not dry_run:
@@ -904,30 +1116,20 @@ def repair_file(path: str, source: str, dry_run: bool) -> list[str]:
return changed
def repair_library(root: str, dry_run: bool) -> tuple[int, int]:
"""Walk <root>/<artist>/<source>/ and re-tag audio files. Returns (scanned, changed)."""
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 artist in sorted(os.listdir(root)):
adir = os.path.join(root, artist)
if not os.path.isdir(adir):
continue
for source in sorted(os.listdir(adir)):
sdir = os.path.join(adir, source)
if not os.path.isdir(sdir) or not _is_source_dir(source):
continue
for fname in sorted(os.listdir(sdir)):
if not fname.lower().endswith(_AUDIO_EXTS):
continue
scanned += 1
try:
if repair_file(os.path.join(sdir, fname), source, dry_run):
changed += 1
except Exception as e: # noqa: BLE001 — one bad file shouldn't abort
err(f"repair failed ({fname}): {e}")
verb = "Would repair" if dry_run else "Repaired"
for path, _source, artist in _iter_source_files(root, exclude):
scanned += 1
try:
if retag_file_from_path(path, artist, dry_run):
changed += 1
except Exception as e: # noqa: BLE001
err(f"retag failed ({os.path.basename(path)}): {e}")
verb = "Would retag" if dry_run else "Retagged"
print(f"{verb} {changed}/{scanned} files")
return scanned, changed
@@ -969,6 +1171,12 @@ def parse_args():
help="Search all albums when adding an artist to Lidarr.")
p.add_argument("--repair", action="store_true",
help="Re-tag existing downloads under --root from source metadata.")
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()
@@ -979,8 +1187,12 @@ def main():
DEBUG = args.debug
query = " ".join(args.query).strip()
if args.retag_from_path:
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

@@ -1,13 +1,15 @@
import server.mf # noqa: F401 — loads musicfetch, registers musicfetch_core
import musicfetch_core as mf
YT_ID = "dQw4w9WgXcQ" # valid 11-char YouTube id
# ---- _is_source_dir ----
def test_is_source_dir():
assert mf._is_source_dir("youtube") is True
assert mf._is_source_dir("soundcloud") is True
assert mf._is_source_dir("downloads") is True
assert mf._is_source_dir("Discovery") is False # Lidarr album folder
assert mf._is_source_dir("Discovery") is False # Lidarr album folder
assert mf._is_source_dir("Random Access Memories") is False
assert mf._is_source_dir("") is False
@@ -16,38 +18,39 @@ def test_is_source_dir():
def test_parse_track_file():
assert mf._parse_track_file("Under My Skin [nGSNF2l44Zc].opus") == ("Under My Skin", "nGSNF2l44Zc")
assert mf._parse_track_file("Ignomon [2202690443].m4a") == ("Ignomon", "2202690443")
# greedy title: real id is the LAST bracket
assert mf._parse_track_file("WHO GON' SLIDE [Official Music Video] [AxjP9s6J3uY].opus") \
== ("WHO GON' SLIDE [Official Music Video]", "AxjP9s6J3uY")
assert mf._parse_track_file("no-id-here.opus") is None
assert mf._parse_track_file("cover.jpg") is None
# ---- _repair_id_ok ----
def test_repair_id_ok():
assert mf._repair_id_ok("youtube", YT_ID) is True
assert mf._repair_id_ok("youtube", "Official Video") is False # space, wrong length
assert mf._repair_id_ok("youtube", "Cover") is False
assert mf._repair_id_ok("soundcloud", "2202690443") is True
assert mf._repair_id_ok("soundcloud", "abc") is False
assert mf._repair_id_ok("bandcamp", "x") is False
# ---- _valid_year ----
def test_valid_year():
assert mf._valid_year({"release_year": 2001}) == "2001"
assert mf._valid_year({"release_date": "1976-09-10"}) == "1976"
assert mf._valid_year({"upload_date": "20110101"}) == "" # upload date ignored
assert mf._valid_year({"release_year": 6577}) == "" # out of range
assert mf._valid_year({}) == ""
# ---- _repair_probe_url ----
def test_repair_probe_url():
assert mf._repair_probe_url("youtube", "vid") == "https://music.youtube.com/watch?v=vid"
assert mf._repair_probe_url("youtube", YT_ID) == f"https://music.youtube.com/watch?v={YT_ID}"
assert mf._repair_probe_url("soundcloud", "123") == "https://api.soundcloud.com/tracks/123"
assert mf._repair_probe_url("bandcamp", "x") is None
# ---- _desired_tags ----
def test_desired_tags_full():
meta = {"artist": "Daft Punk", "title": "Aerodynamic", "album": "Discovery", "release_year": 2001}
assert mf._desired_tags(meta) == {"artist": "Daft Punk", "title": "Aerodynamic",
"album": "Discovery", "date": "2001"}
def test_desired_tags_year_fallbacks_and_omits_empty():
meta = {"title": "T", "uploader": "Chan", "upload_date": "20230102"} # no album, no release_year
out = mf._desired_tags(meta)
assert out["date"] == "2023"
assert out["title"] == "T"
assert out["artist"] == "Chan"
assert "album" not in out # omitted when empty
def test_desired_tags_drops_unknown_artist():
meta = {"title": "T"} # get_artist_from_metadata -> "Unknown Artist"
assert "artist" not in mf._desired_tags(meta)
# ---- repair_file (fake audio + mocked metadata) ----
class _FakeAudio(dict):
def __init__(self, initial):
@@ -58,13 +61,13 @@ class _FakeAudio(dict):
self.saved = True
def test_repair_file_writes_changed_fields(monkeypatch):
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))
changed = mf.repair_file("X/youtube/Aerodynamic [vid].opus", "youtube", dry_run=False)
changed = mf.repair_file(f"X/youtube/Aerodynamic [{YT_ID}].opus", "youtube", dry_run=False)
assert set(changed) == {"album=Discovery", "date=2001"}
assert audio["album"] == ["Discovery"]
assert audio["date"] == ["2001"]
@@ -73,33 +76,94 @@ def test_repair_file_writes_changed_fields(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("X/youtube/T [vid].opus", "youtube", dry_run=True)
assert changed # reports would-change
assert audio == {} # nothing written
changed = mf.repair_file(f"X/youtube/T [{YT_ID}].opus", "youtube", dry_run=True)
assert changed
assert audio == {}
assert audio.saved is False
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, *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))
changed = mf.repair_file(f"X/youtube/Song [{YT_ID}].opus", "youtube", dry_run=False)
assert changed == []
assert audio == {"artist": ["Real Artist"], "title": ["Song"]} # untouched
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, *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))
changed = mf.repair_file(f"X/youtube/x [{YT_ID}].opus", "youtube", dry_run=False)
assert set(changed) == {"album=Real Album", "date=2019"}
assert audio["artist"] == ["Correct Artist"] # NOT overwritten with channel
assert audio["title"] == ["Clean Title"] # NOT overwritten with decorated title
def test_repair_file_fills_missing_artist_when_absent(monkeypatch):
monkeypatch.setattr(mf, "run_yt_dlp_get_metadata",
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))
changed = mf.repair_file(f"X/youtube/T [{YT_ID}].opus", "youtube", dry_run=False)
assert set(changed) == {"album=Alb", "date=2020", "artist=Real Artist", "title=T"}
def test_repair_file_skips_bad_id(monkeypatch):
called = {"meta": False}
monkeypatch.setattr(mf, "run_yt_dlp_get_metadata",
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
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 # never hit the network
assert called["meta"] is False
def test_repair_file_skips_unqueryable_source(monkeypatch):
monkeypatch.setattr(mf, "run_yt_dlp_get_metadata", lambda url: {"title": "x"})
assert mf.repair_file("X/bandcamp/T [id].m4a", "bandcamp", dry_run=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
(root / "Daft Punk" / "youtube").mkdir(parents=True)
(root / "Daft Punk" / "youtube" / "Aerodynamic [vid].opus").write_text("x")
(root / "Daft Punk" / "youtube" / f"Aerodynamic [{YT_ID}].opus").write_text("x")
(root / "Daft Punk" / "Discovery").mkdir(parents=True) # Lidarr album -> skip
(root / "Daft Punk" / "Discovery" / "Aerodynamic.flac").write_text("x")
(root / "Ephixa" / "soundcloud").mkdir(parents=True)
@@ -110,9 +174,165 @@ def test_repair_library_scans_only_source_dirs(tmp_path, monkeypatch):
lambda path, source, dry_run: visited.append((source, path)) or ["album=X"])
scanned, changed = mf.repair_library(str(root), dry_run=False)
assert scanned == 2 and changed == 2
sources = sorted(s for s, _ in visited)
assert sources == ["soundcloud", "youtube"] # Discovery album folder skipped
assert sorted(s for s, _ in visited) == ["soundcloud", "youtube"] # album folder skipped
def test_repair_library_missing_root(monkeypatch):
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"
assert mf._title_from_filename("STARDUST (Official Music Video) [3nsYNXtALhA].opus") \
== "STARDUST (Official Music Video)"
assert mf._title_from_filename("no brackets.mp3") == "no brackets"
def test_strip_decorations():
assert mf._strip_decorations("STARDUST (Official Music Video)") == "STARDUST"
assert mf._strip_decorations("Away From You (Lyrics)") == "Away From You"
assert mf._strip_decorations("More Than a Feeling (Official HD Video)") == "More Than a Feeling"
# real info like a feature credit is kept
assert mf._strip_decorations("WHO GON' SLIDE (Feat. Shakewell) [Official Music Video]") \
== "WHO GON' SLIDE (Feat. Shakewell)"
def test_derive_from_filename():
# plain title -> folder is the artist
assert mf._derive_from_filename(f"Aerodynamic [{YT_ID}].opus", "Daft Punk") == ("Daft Punk", "Aerodynamic")
# decorated music video filed under the artist
assert mf._derive_from_filename("STARDUST (Official Music Video) [3nsYNXtALhA].opus", "1nonly") \
== ("1nonly", "STARDUST")
# 'Artist - Title' name wins over a channel folder
assert mf._derive_from_filename("BLCKLGHT - Away From You (Lyrics) [QapF4b1jYw8].opus", "7clouds Techno") \
== ("BLCKLGHT", "Away From You")
def test_retag_file_from_path_fixes_clobbered_tags(monkeypatch):
audio = _FakeAudio({"artist": ["7clouds Techno"], "title": ["BLCKLGHT - Away From You (Lyrics)"]})
monkeypatch.setattr(mf, "_open_audio", lambda path: (audio, None))
changed = mf.retag_file_from_path(
"X/7clouds Techno/youtube/BLCKLGHT - Away From You (Lyrics) [QapF4b1jYw8].opus",
"7clouds Techno", dry_run=False)
assert set(changed) == {"artist=BLCKLGHT", "title=Away From You"}
assert audio["artist"] == ["BLCKLGHT"]
assert audio["title"] == ["Away From You"]
assert audio.saved is True
def test_retag_file_from_path_dry_run(monkeypatch):
audio = _FakeAudio({"artist": ["wrong"], "title": ["wrong"]})
monkeypatch.setattr(mf, "_open_audio", lambda path: (audio, None))
changed = mf.retag_file_from_path(f"X/Daft Punk/youtube/Aerodynamic [{YT_ID}].opus",
"Daft Punk", dry_run=True)
assert changed
assert audio == {"artist": ["wrong"], "title": ["wrong"]}
assert audio.saved is False
def test_retag_library_walks_source_files(tmp_path, monkeypatch):
root = tmp_path
(root / "Daft Punk" / "youtube").mkdir(parents=True)
(root / "Daft Punk" / "youtube" / f"Aerodynamic [{YT_ID}].opus").write_text("x")
(root / "Daft Punk" / "Discovery").mkdir(parents=True) # album folder -> skip
(root / "Daft Punk" / "Discovery" / "x.flac").write_text("x")
visited = []
monkeypatch.setattr(mf, "retag_file_from_path",
lambda path, artist, dry_run: visited.append(artist) or ["artist=x"])
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")