Merge feat/retag-from-path: offline tag recovery
This commit is contained in:
12
README.md
12
README.md
@@ -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
|
||||||
|
|||||||
105
musicfetch
105
musicfetch
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user