Files
beets-setup/scripts/audit/audit-library-paths
2026-05-18 14:25:54 -04:00

406 lines
9.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Audit a beets-managed music library for path layout violations.
Allowed layouts:
Normal music:
<root>/<AlbumArtist>/{Live,Remixes,Albums,EPs,Singles,Anthology}/...
OSTs:
<root>/OSTs/<Album>/...
Examples:
PASS:
/data/Music/Library/originals/Metallica/Albums/Master of Puppets/01 Battery.flac
/data/Music/Library/originals/Daft Punk/Remixes/TRON Legacy Reconfigured/01 Derezzed.flac
/data/Music/Library/originals/OSTs/Final Fantasy VII/01 Prelude.flac
FAIL:
/data/Music/Library/originals/Metallica/Remix/Some Album/01 Track.flac
/data/Music/Library/originals/Metallica/Bootlegs/Some Album/01 Track.flac
/data/Music/Library/originals/OSTs/01 Track.flac
/data/Music/Library/originals/Some Artist/OSTs/Some Album/01 Track.flac
"""
from __future__ import annotations
import argparse
import csv
import os
import sqlite3
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
VALID_BUCKETS = {
"Live",
"Remixes",
"Albums",
"EPs",
"Singles",
"Anthology",
}
OST_TOP_LEVEL = "OSTs"
DEFAULT_AUDIO_EXTS = {
".flac",
".mp3",
".m4a",
".aac",
".ogg",
".opus",
".wav",
".aiff",
".aif",
".wma",
".alac",
".ape",
".wv",
".mpc",
".dsf",
".dff",
}
@dataclass
class BeetsItem:
item_id: int
path: Path
albumartist: str
artist: str
album: str
title: str
def decode_beets_path(value) -> str:
"""
Beets commonly stores paths as bytes/BLOB values in sqlite.
Decode them safely into a filesystem string.
"""
if isinstance(value, bytes):
return os.fsdecode(value)
return str(value)
def normalize_path(path: Path) -> str:
"""
Normalize for comparison without requiring the file to exist.
"""
return str(path.expanduser().resolve(strict=False))
def validate_path(path: Path, root: Path) -> tuple[Optional[str], str]:
"""
Return (reason, relative_path).
reason is None when the path is valid.
"""
path = path.expanduser().resolve(strict=False)
root = root.expanduser().resolve(strict=False)
try:
rel = path.relative_to(root)
except ValueError:
return "OUTSIDE_ROOT", str(path)
parts = rel.parts
if not parts:
return "EMPTY_RELATIVE_PATH", str(rel)
# Special OST rule:
#
# <root>/OSTs/<Album>/...
#
# This intentionally does NOT follow:
#
# <root>/<AlbumArtist>/<Bucket>/...
#
if parts[0] == OST_TOP_LEVEL:
if len(parts) < 3:
return "OST_TOO_SHALLOW", str(rel)
return None, str(rel)
# Normal music rule:
#
# <root>/<AlbumArtist>/<Bucket>/...
#
# At minimum, an actual track path needs:
#
# AlbumArtist/Bucket/File
#
# or:
#
# AlbumArtist/Bucket/Album/File
#
# The validator only enforces the structural prefix.
if len(parts) < 3:
return "TOO_SHALLOW", str(rel)
albumartist = parts[0]
bucket = parts[1]
if not albumartist:
return "MISSING_ALBUMARTIST", str(rel)
if bucket not in VALID_BUCKETS:
if bucket == "Remix":
return "BAD_BUCKET_RENAME_TO_REMIXES", str(rel)
return "BAD_BUCKET", str(rel)
return None, str(rel)
def load_beets_items(db_path: Path, root: Path) -> dict[str, BeetsItem]:
db_path = db_path.expanduser().resolve(strict=False)
root = root.expanduser().resolve(strict=False)
if not db_path.exists():
raise FileNotFoundError(f"Beets database does not exist: {db_path}")
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"""
SELECT
id,
path,
albumartist,
artist,
album,
title
FROM items
ORDER BY path
"""
).fetchall()
finally:
conn.close()
items: dict[str, BeetsItem] = {}
for row in rows:
raw_path = Path(decode_beets_path(row["path"]))
# Critical fix:
# Beets may store item paths relative to the configured music directory.
# Those must be resolved relative to --root, not relative to cwd.
if raw_path.is_absolute():
path = raw_path
else:
path = root / raw_path
key = normalize_path(path)
items[key] = BeetsItem(
item_id=int(row["id"]),
path=path,
albumartist=row["albumartist"] or "",
artist=row["artist"] or "",
album=row["album"] or "",
title=row["title"] or "",
)
return items
def scan_disk_audio_files(root: Path, audio_exts: set[str]) -> set[str]:
root = root.expanduser().resolve(strict=False)
found: set[str] = set()
skip_dirs = {
".zfs",
".snapshot",
"@eaDir",
}
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [
d for d in dirnames
if d not in skip_dirs
]
for filename in filenames:
path = Path(dirpath) / filename
if path.suffix.lower() not in audio_exts:
continue
found.add(normalize_path(path))
return found
def make_finding(
*,
path_key: str,
root: Path,
beets_items: dict[str, BeetsItem],
disk_paths: set[str],
) -> Optional[dict[str, str]]:
path = Path(path_key)
reason, relative_path = validate_path(path, root)
if reason is None:
return None
beets_item = beets_items.get(path_key)
in_beets = beets_item is not None
on_disk = path_key in disk_paths or path.exists()
if in_beets and path_key in disk_paths:
source = "beets+disk"
elif in_beets:
source = "beets"
else:
source = "disk"
return {
"source": source,
"reason": reason,
"relative_path": relative_path,
"path": str(path),
"in_beets": "yes" if in_beets else "no",
"exists_on_disk": "yes" if on_disk else "no",
"beets_id": str(beets_item.item_id) if beets_item else "",
"albumartist": beets_item.albumartist if beets_item else "",
"artist": beets_item.artist if beets_item else "",
"album": beets_item.album if beets_item else "",
"title": beets_item.title if beets_item else "",
}
def parse_audio_exts(raw_exts: Optional[str]) -> set[str]:
if not raw_exts:
return set(DEFAULT_AUDIO_EXTS)
exts: set[str] = set()
for ext in raw_exts.split(","):
ext = ext.strip().lower()
if not ext:
continue
if not ext.startswith("."):
ext = "." + ext
exts.add(ext)
return exts
def main() -> int:
parser = argparse.ArgumentParser(
description="Audit music library paths against the required layout."
)
parser.add_argument(
"--root",
default="/data/Music/Library/originals",
help="Music library root. Default: /data/Music/Library/originals",
)
parser.add_argument(
"--db",
default="/data/Music/Library/musiclibrary.db",
help="Beets sqlite database path. Default: /data/Music/Library/musiclibrary.db",
)
parser.add_argument(
"--disk",
action="store_true",
help="Also scan audio files physically present under --root.",
)
parser.add_argument(
"--csv",
help="Write findings to this CSV file instead of stdout.",
)
parser.add_argument(
"--audio-exts",
help=(
"Comma-separated audio extensions for --disk scan. "
"Default includes common formats like flac, mp3, m4a, opus, wav."
),
)
args = parser.parse_args()
root = Path(args.root)
db_path = Path(args.db)
audio_exts = parse_audio_exts(args.audio_exts)
beets_items = load_beets_items(db_path, root)
disk_paths: set[str] = set()
if args.disk:
disk_paths = scan_disk_audio_files(root, audio_exts)
paths_to_check = set(beets_items.keys())
if args.disk:
paths_to_check |= disk_paths
findings: list[dict[str, str]] = []
for path_key in sorted(paths_to_check):
finding = make_finding(
path_key=path_key,
root=root,
beets_items=beets_items,
disk_paths=disk_paths,
)
if finding is not None:
findings.append(finding)
fields = [
"source",
"reason",
"relative_path",
"path",
"in_beets",
"exists_on_disk",
"beets_id",
"albumartist",
"artist",
"album",
"title",
]
if args.csv:
out_path = Path(args.csv)
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fields)
writer.writeheader()
writer.writerows(findings)
print(f"Wrote {len(findings)} finding(s) to {out_path}")
else:
if findings:
writer = csv.DictWriter(sys.stdout, fieldnames=fields, delimiter="\t")
writer.writeheader()
writer.writerows(findings)
else:
print("No invalid music paths found.")
return 1 if findings else 0
if __name__ == "__main__":
raise SystemExit(main())