Merge feat/retag-from-path: offline tag recovery

This commit is contained in:
2026-06-10 22:30:25 -07:00
3 changed files with 172 additions and 9 deletions

View File

@@ -95,6 +95,7 @@ export LIDARR_API_KEY="your-lidarr-api-key"
| `-o`, `--root PATH` | Output root folder (default `/media/music`). | | `-o`, `--root PATH` | Output root folder (default `/media/music`). |
| `--search-all` | Search all albums when adding an artist to Lidarr. | | `--search-all` | Search all albums when adding an artist to Lidarr. |
| `--repair` | Re-tag existing downloads under `--root` from source metadata (see below). | | `--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). |
| `--debug` | Verbose output. | | `--debug` | Verbose output. |
### Examples ### Examples
@@ -144,6 +145,17 @@ It re-queries the source over the network, so run it occasionally, not constantl
./musicfetch --repair -o /media/music ./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
```
### 📁 Output Structure ### 📁 Output Structure
```text ```text

View File

@@ -945,6 +945,21 @@ def repair_library(root: str, dry_run: bool) -> tuple[int, int]:
err(f"Root folder not found: {root}") err(f"Root folder not found: {root}")
return 0, 0 return 0, 0
scanned = changed = 0 scanned = changed = 0
for path, source, _artist in _iter_source_files(root):
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):
"""Yield (path, source, artist) for audio files under <root>/<artist>/<source>/
where source is a yt-dlp source folder (Lidarr album folders are skipped)."""
for artist in sorted(os.listdir(root)): for artist in sorted(os.listdir(root)):
adir = os.path.join(root, artist) adir = os.path.join(root, artist)
if not os.path.isdir(adir): if not os.path.isdir(adir):
@@ -954,15 +969,80 @@ def repair_library(root: str, dry_run: bool) -> tuple[int, int]:
if not os.path.isdir(sdir) or not _is_source_dir(source): if not os.path.isdir(sdir) or not _is_source_dir(source):
continue continue
for fname in sorted(os.listdir(sdir)): for fname in sorted(os.listdir(sdir)):
if not fname.lower().endswith(_AUDIO_EXTS): if fname.lower().endswith(_AUDIO_EXTS):
continue yield os.path.join(sdir, fname), source, artist
scanned += 1
try:
if repair_file(os.path.join(sdir, fname), source, dry_run): # --- Offline retag-from-path (recover from tags damaged by a prior --repair) ---
changed += 1 _DECORATION_RE = re.compile(
except Exception as e: # noqa: BLE001 — one bad file shouldn't abort r"\s*[\(\[][^)\]]*\b(?:official|lyric[s]?|audio|visuali[sz]er|"
err(f"repair failed ({fname}): {e}") r"music\s+video|m/?v|hd|hq|4k|explicit|remaster(?:ed)?)\b[^)\]]*[\)\]]",
verb = "Would repair" if dry_run else "Repaired" 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:
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}")
return changed
def retag_library_from_path(root: str, dry_run: bool) -> 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):
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") print(f"{verb} {changed}/{scanned} files")
return scanned, changed return scanned, changed
@@ -1004,6 +1084,9 @@ def parse_args():
help="Search all albums when adding an artist to Lidarr.") help="Search all albums when adding an artist to Lidarr.")
p.add_argument("--repair", action="store_true", p.add_argument("--repair", action="store_true",
help="Re-tag existing downloads under --root from source metadata.") 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("--debug", action="store_true", help="Verbose output.") p.add_argument("--debug", action="store_true", help="Verbose output.")
return p.parse_args() return p.parse_args()
@@ -1014,6 +1097,10 @@ def main():
DEBUG = args.debug DEBUG = args.debug
query = " ".join(args.query).strip() query = " ".join(args.query).strip()
if args.retag_from_path:
retag_library_from_path(args.root, args.dry_run)
return
if args.repair: if args.repair:
repair_library(args.root, args.dry_run) repair_library(args.root, args.dry_run)
return return

View File

@@ -157,3 +157,67 @@ def test_repair_library_scans_only_source_dirs(tmp_path, monkeypatch):
def test_repair_library_missing_root(): def test_repair_library_missing_root():
assert mf.repair_library("/no/such/dir", dry_run=False) == (0, 0) assert mf.repair_library("/no/such/dir", dry_run=False) == (0, 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"]