Compare commits
14 Commits
1a81f64cc3
...
fix/yt-tag
| Author | SHA1 | Date | |
|---|---|---|---|
| 0347a638cf | |||
| 33ca743a34 | |||
| 7951c436dd | |||
| 8b881c14bf | |||
| 530b5b0406 | |||
| 0a4e6d474a | |||
| c0503187c5 | |||
| a6aa469084 | |||
| f071158c10 | |||
| c6bde6958a | |||
| 7ea3ad2538 | |||
| 9af7f91a25 | |||
| 567d7578ad | |||
| c6e28a4f75 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
server/log.txt
|
||||
|
||||
32
README.md
32
README.md
@@ -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
|
||||
|
||||
322
musicfetch
322
musicfetch
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,3 +4,4 @@ requests
|
||||
ytmusicapi
|
||||
rich
|
||||
yt-dlp
|
||||
mutagen
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user