Merge feat/repair-exclude: -x/--exclude for repair/retag

This commit is contained in:
2026-06-10 22:41:16 -07:00
3 changed files with 38 additions and 8 deletions

View File

@@ -96,6 +96,7 @@ export LIDARR_API_KEY="your-lidarr-api-key"
| `--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
@@ -154,6 +155,9 @@ 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

View File

@@ -939,13 +939,13 @@ 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]:
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):
for path, source, _artist in _iter_source_files(root, exclude):
scanned += 1
try:
if repair_file(path, source, dry_run):
@@ -957,14 +957,20 @@ def repair_library(root: str, dry_run: bool) -> tuple[int, int]:
return scanned, changed
def _iter_source_files(root: str):
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)."""
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
@@ -1029,13 +1035,13 @@ def retag_file_from_path(path: str, folder_artist: str, dry_run: bool) -> list[s
return changed
def retag_library_from_path(root: str, dry_run: bool) -> tuple[int, int]:
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 path, _source, artist in _iter_source_files(root):
for path, _source, artist in _iter_source_files(root, exclude):
scanned += 1
try:
if retag_file_from_path(path, artist, dry_run):
@@ -1087,6 +1093,9 @@ def parse_args():
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()
@@ -1098,11 +1107,11 @@ def main():
query = " ".join(args.query).strip()
if args.retag_from_path:
retag_library_from_path(args.root, args.dry_run)
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

@@ -159,6 +159,23 @@ 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"