#!/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 = rf"{replace_pattern} #\d+(?:\.\d+)?" if existing: if re.search(pattern, existing): new_group = re.sub( pattern, f"{replace_pattern} #{rank};", existing, ) else: new_group = f"{existing}; {replace_pattern} #{rank};" else: new_group = f"{replace_pattern} #{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"New tag {path.name}: {new_group}, Year: {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()