2026-07-23 10:09:45 +02:00
|
|
|
#!/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 ""
|
|
|
|
|
|
2026-09-14 12:22:45 +02:00
|
|
|
def tag_file(path: Path, rank: int, year: str, dry_run: bool, replace_pattern: str=r"Top 100 SciFi Books" ):
|
2026-07-23 10:09:45 +02:00
|
|
|
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 = ""
|
2026-09-14 12:22:45 +02:00
|
|
|
pattern = rf"{replace_pattern} #\d+(?:\.\d+)?"
|
2026-07-31 13:46:39 +02:00
|
|
|
|
2026-07-23 10:09:45 +02:00
|
|
|
if existing:
|
|
|
|
|
if re.search(pattern, existing):
|
|
|
|
|
new_group = re.sub(
|
|
|
|
|
pattern,
|
2026-09-14 12:22:45 +02:00
|
|
|
f"{replace_pattern} #{rank};",
|
2026-07-23 10:09:45 +02:00
|
|
|
existing,
|
|
|
|
|
)
|
|
|
|
|
else:
|
2026-09-14 12:22:45 +02:00
|
|
|
new_group = f"{existing}; {replace_pattern} #{rank};"
|
2026-07-23 10:09:45 +02:00
|
|
|
else:
|
2026-09-14 12:22:45 +02:00
|
|
|
new_group = f"{replace_pattern} #{rank};"
|
2026-07-23 10:09:45 +02:00
|
|
|
|
|
|
|
|
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"
|
2026-09-14 12:22:45 +02:00
|
|
|
print(f"New tag {path.name}: {new_group}, Year: {year}")
|
2026-07-23 10:09:45 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-08-19 11:23:02 +02:00
|
|
|
replace_pattern=Path(args.booklist).stem
|
2026-07-23 10:09:45 +02:00
|
|
|
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:
|
2026-07-31 13:46:39 +02:00
|
|
|
rank, title, author, year = parts
|
2026-07-23 10:09:45 +02:00
|
|
|
elif len(parts) == 3:
|
2026-07-31 13:46:39 +02:00
|
|
|
rank, title, author = parts
|
2026-07-23 10:09:45 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-07-31 13:46:39 +02:00
|
|
|
print(f"{GREEN}FOUND{RESET} #{rank:3} {title} in {book_dir}")
|
2026-07-23 10:09:45 +02:00
|
|
|
|
|
|
|
|
if args.find_missing:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
for file in book_dir.iterdir():
|
|
|
|
|
if file.suffix.lower() not in AUDIO_EXTENSIONS:
|
|
|
|
|
continue
|
|
|
|
|
|
2026-08-19 11:23:02 +02:00
|
|
|
ret=tag_file(file, rank, year, args.dry_run, replace_pattern)
|
2026-07-23 10:09:45 +02:00
|
|
|
if ret == "skip":
|
|
|
|
|
break
|
2026-07-31 13:46:39 +02:00
|
|
|
#print("next")
|
2026-07-23 10:09:45 +02:00
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|
|
|
|
|
|