diff --git a/README.md b/README.md index 7a7920e..5ac9cf3 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ 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). | | `--debug` | Verbose output. | ### Examples @@ -144,6 +145,17 @@ It re-queries the source over the network, so run it occasionally, not constantl ./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 ```text diff --git a/musicfetch b/musicfetch index 083a5be..146f877 100755 --- a/musicfetch +++ b/musicfetch @@ -945,6 +945,21 @@ def repair_library(root: str, dry_run: bool) -> tuple[int, int]: 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 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 /// + where source is a yt-dlp source folder (Lidarr album folders are skipped).""" for artist in sorted(os.listdir(root)): adir = os.path.join(root, artist) 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): 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" + 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 ' []'.""" + 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") return scanned, changed @@ -1004,6 +1084,9 @@ 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("--debug", action="store_true", help="Verbose output.") return p.parse_args() @@ -1014,6 +1097,10 @@ def main(): DEBUG = args.debug query = " ".join(args.query).strip() + if args.retag_from_path: + retag_library_from_path(args.root, args.dry_run) + return + if args.repair: repair_library(args.root, args.dry_run) return diff --git a/tests/test_repair.py b/tests/test_repair.py index 8c333f2..6a637c2 100644 --- a/tests/test_repair.py +++ b/tests/test_repair.py @@ -157,3 +157,67 @@ def test_repair_library_scans_only_source_dirs(tmp_path, monkeypatch): def test_repair_library_missing_root(): 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"]