feat: -x/--exclude to skip folders during --repair/--retag-from-path
Repeatable -x/--exclude NAME skips any artist- or source-level folder whose name matches (case-insensitive) when walking the library, so hand-curated folders like /media/music/Unsorted or .../playlists are left untouched. Threaded through _iter_source_files -> repair_library / retag_library_from_path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,7 @@ export LIDARR_API_KEY="your-lidarr-api-key"
|
|||||||
| `--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). |
|
| `--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. |
|
| `--debug` | Verbose output. |
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
@@ -154,6 +155,9 @@ artist/title and leaves album/year alone.
|
|||||||
```bash
|
```bash
|
||||||
./musicfetch --retag-from-path -d # preview
|
./musicfetch --retag-from-path -d # preview
|
||||||
./musicfetch --retag-from-path -o /media/music
|
./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
|
### 📁 Output Structure
|
||||||
|
|||||||
25
musicfetch
25
musicfetch
@@ -939,13 +939,13 @@ def repair_file(path: str, source: str, dry_run: bool) -> list[str]:
|
|||||||
return changed
|
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)."""
|
"""Walk <root>/<artist>/<source>/ and re-tag audio files. Returns (scanned, changed)."""
|
||||||
if not os.path.isdir(root):
|
if not os.path.isdir(root):
|
||||||
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):
|
for path, source, _artist in _iter_source_files(root, exclude):
|
||||||
scanned += 1
|
scanned += 1
|
||||||
try:
|
try:
|
||||||
if repair_file(path, source, dry_run):
|
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
|
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>/
|
"""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)):
|
for artist in sorted(os.listdir(root)):
|
||||||
|
if artist.lower() in skip:
|
||||||
|
continue
|
||||||
adir = os.path.join(root, artist)
|
adir = os.path.join(root, artist)
|
||||||
if not os.path.isdir(adir):
|
if not os.path.isdir(adir):
|
||||||
continue
|
continue
|
||||||
for source in sorted(os.listdir(adir)):
|
for source in sorted(os.listdir(adir)):
|
||||||
|
if source.lower() in skip:
|
||||||
|
continue
|
||||||
sdir = os.path.join(adir, source)
|
sdir = os.path.join(adir, source)
|
||||||
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
|
||||||
@@ -1029,13 +1035,13 @@ def retag_file_from_path(path: str, folder_artist: str, dry_run: bool) -> list[s
|
|||||||
return changed
|
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."""
|
"""Re-tag artist/title offline from folder+filename for every source file."""
|
||||||
if not os.path.isdir(root):
|
if not os.path.isdir(root):
|
||||||
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):
|
for path, _source, artist in _iter_source_files(root, exclude):
|
||||||
scanned += 1
|
scanned += 1
|
||||||
try:
|
try:
|
||||||
if retag_file_from_path(path, artist, dry_run):
|
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",
|
p.add_argument("--retag-from-path", action="store_true",
|
||||||
help="Offline: re-tag artist/title from folder + filename "
|
help="Offline: re-tag artist/title from folder + filename "
|
||||||
"(fixes tags damaged by a prior --repair).")
|
"(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.")
|
p.add_argument("--debug", action="store_true", help="Verbose output.")
|
||||||
return p.parse_args()
|
return p.parse_args()
|
||||||
|
|
||||||
@@ -1098,11 +1107,11 @@ def main():
|
|||||||
query = " ".join(args.query).strip()
|
query = " ".join(args.query).strip()
|
||||||
|
|
||||||
if args.retag_from_path:
|
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
|
return
|
||||||
|
|
||||||
if args.repair:
|
if args.repair:
|
||||||
repair_library(args.root, args.dry_run)
|
repair_library(args.root, args.dry_run, args.exclude)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not query:
|
if not query:
|
||||||
|
|||||||
@@ -159,6 +159,23 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
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 ----
|
# ---- offline retag-from-path ----
|
||||||
def test_title_from_filename():
|
def test_title_from_filename():
|
||||||
assert mf._title_from_filename(f"Song [{YT_ID}].opus") == "Song"
|
assert mf._title_from_filename(f"Song [{YT_ID}].opus") == "Song"
|
||||||
|
|||||||
Reference in New Issue
Block a user