Files
Server-scripts/audiobook_metadata/metadata_rank_from_list.py
T
2026-09-14 12:43:38 +02:00

204 lines
4.9 KiB
Python

#!/usr/bin/env python3
import argparse
import re
import subprocess
from pathlib import Path
from difflib import SequenceMatcher
AUDIO_EXTENSIONS = {
".mp3",
".m4b",
".m4a",
}
RED = "\033[31m"
GREEN = "\033[32m"
RESET = "\033[0m"
USE_KID3 = True
def normalize(text: str) -> str:
return re.sub(r"[^a-z0-9]", "", text.lower())
def get_tag(path, tag):
if USE_KID3:
result = subprocess.run(
["kid3-cli", "-c", f"get {tag}", str(path)],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
# id3v2 frame mapping
frames = {
"album": "TALB",
"artist": "TPE1",
"title": "TIT2",
"year": "TYER",
"grouping": "TIT1", # Content group description
"work": "TIT1", # No separate WORK frame in id3v2
}
frame = frames[tag]
result = subprocess.run(
["id3v2", "-R", str(path)],
capture_output=True,
text=True,
check=True,
)
for line in result.stdout.splitlines():
if line.startswith(frame):
return line.split(":", 1)[1].strip()
return ""
def tag_file(path: Path, rank: int, year: str, dry_run: bool, replace_pattern: str=r"Top 100 SciFi Books" )
work = get_tag(path, "work")
grouping = get_tag(path, "grouping")
existing_year = get_tag(path, "year")
if work:
tag = "work"
existing = work
elif grouping:
tag = "grouping"
existing = grouping
else:
tag = "grouping"
existing = ""
pattern = f"{replace_pattern} #\d+(?:\.\d+)?"
if existing:
if re.search(pattern, existing):
new_group = re.sub(
pattern,
f"{replace} #{rank};",
existing,
)
else:
new_group = f"{existing}; {replace} #{rank};"
else:
new_group = f"{replace} #{rank};"
new_group = re.sub(r";+", ";", new_group)
# Skip if nothing changes
if existing == new_group and existing_year == str(year):
print("Skipping (already tagged)")
return "skip"
print(f"Would tag {path.name}: {new_group} {year}")
if dry_run:
return
subprocess.run(
[
"kid3-cli",
"-c", f'set {tag} "{new_group}"',
"-c", f"set year {year}",
str(path),
],
check=True,
)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("library")
parser.add_argument("booklist")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--find-missing", action="store_true")
args = parser.parse_args()
library = Path(args.library)
replace_pattern=Path(args.booklist).stem
found = set()
with open(args.booklist, encoding="utf-8") as f:
books = [
line.strip()
for line in f
if line.strip() and not line.startswith("#")
]
author_dirs = {
normalize(p.name): p
for p in library.iterdir()
if p.is_dir()
}
rank_list=[]
# print(books)
for line in books:
line = line.strip()
if not line:
continue # skip empty lines
parts = line.split(" - ")
if len(parts) == 4:
rank, title, author, year = parts
elif len(parts) == 3:
rank, title, author = parts
year = None
else:
raise ValueError(f"Invalid line: {line}")
author_key = normalize(author)
title_key = normalize(title)
rank_list.append(rank)
author_dir = author_dirs.get(author_key)
if author_dir is None:
print(f"{RED}AUTHOR{RESET} {author} ")
continue
book_dir = None
best_score = 0.0
for subdir in author_dir.rglob("*"):
if not subdir.is_dir():
continue
score = SequenceMatcher(
None,
title_key,
normalize(subdir.name),
).ratio()
if score > best_score:
best_score = score
book_dir = subdir
if best_score < 0.6:
book_dir = None
print(f"{RED}TITLE{RESET} {title}, Score {best_score} ")
for subdir in author_dir.rglob("*"):
if not subdir.is_dir():
continue
else:
print(subdir)
continue
found.add(rank)
print(f"{GREEN}FOUND{RESET} #{rank:3} {title} in {book_dir}")
if args.find_missing:
continue
for file in book_dir.iterdir():
if file.suffix.lower() not in AUDIO_EXTENSIONS:
continue
ret=tag_file(file, rank, year, args.dry_run, replace_pattern)
if ret == "skip":
break
#print("next")
if __name__ == "__main__":
main()