From 83e6d7ee22fc0105254b2ca105851d932b6ef9df Mon Sep 17 00:00:00 2001 From: Lucas Vivohost Date: Thu, 23 Jul 2026 10:09:45 +0200 Subject: [PATCH] metadata scripts --- quiethost/metadata_rank_from_list.py | 205 +++++++++++++++++++++++++ quiethost/metadata_write_ranking.sh | 137 +++++++++++++++++ quiethost/metadate_generate_for_pdf.sh | 40 +++++ 3 files changed, 382 insertions(+) create mode 100644 quiethost/metadata_rank_from_list.py create mode 100644 quiethost/metadata_write_ranking.sh create mode 100644 quiethost/metadate_generate_for_pdf.sh diff --git a/quiethost/metadata_rank_from_list.py b/quiethost/metadata_rank_from_list.py new file mode 100644 index 0000000..d04d06a --- /dev/null +++ b/quiethost/metadata_rank_from_list.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 + +import argparse +import re +import subprocess +from pathlib import Path +from difflib import SequenceMatcher +from turtledemo.lindenmayer import replace + +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): + 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 = r"Top 100 SciFi Books #\d+" +# pattern = r"Enderverse" + replace = r"Top 100 SciFi Books" + 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) + + 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, author, title, year = parts + elif len(parts) == 3: + rank, author, title = 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} #{int(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, int(rank), year, args.dry_run) + if ret == "skip": + break + # print("next") + +if __name__ == "__main__": + main() + diff --git a/quiethost/metadata_write_ranking.sh b/quiethost/metadata_write_ranking.sh new file mode 100644 index 0000000..4c756dc --- /dev/null +++ b/quiethost/metadata_write_ranking.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash + +set -euo pipefail +RED=$'\e[31m' +GREEN=$'\e[32m' +RESET=$'\e[0m' +usage() { + cat < [--dry-run|--find-missing] + +Arguments: + library-root Root containing author/*/book/ + book-list Text file containing: + rank|title|author|year + +Options: + --dry-run Show what would be tagged without writing tags + --find-missing List books from the ranking that were not found +EOF + exit 1 +} + +[[ $# -ge 2 ]] || usage + +ROOT="$1" +BOOKLIST="$2" +MODE="write" + +case "${3:-}" in + --dry-run) + MODE="dryrun" + ;; + --find-missing) + MODE="missing" + ;; + "") + ;; + *) + usage + ;; +esac + +[[ -d "$ROOT" ]] || { + echo "Library not found: $ROOT" >&2 + exit 1 +} + +[[ -f "$BOOKLIST" ]] || { + echo "Book list not found: $BOOKLIST" >&2 + exit 1 +} +echo "Using MODE $MODE" +echo "Libary: $ROOT" +echo "File: $BOOKLIST" + +normalize() { + echo "$1" | + tr '[:upper:]' '[:lower:]' | + sed -E "s/[^a-z0-9]+//g" +} + +declare -A FOUND + +while IFS='|' read -r RANK TITLE AUTHOR YEAR; do + + # Skip blank lines and comments + [[ -z "$RANK" ]] && continue + [[ "$RANK" =~ ^# ]] && continue + + NTITLE=$(normalize "$TITLE") + NAUTHOR=$(normalize "$AUTHOR") + + MATCH="" + +AUTHOR_PATH=$(find "$ROOT" -type d -iname "*$AUTHOR*" | head -n1) + +[[ -z "$AUTHOR_PATH" ]] && continue + +BOOK_PATH="$AUTHOR_PATH/book" +[[ -d "$BOOK_PATH" ]] || continue + +while IFS= read -r FILE; do + FILE_NAME="${FILE##*/}" + FILE_BASE="${FILE_NAME%.*}" + + echo "Checking: $FILE_BASE" + + if [[ "$(normalize "$FILE_BASE")" == "$NTITLE" ]]; then + MATCH="$FILE" + break + fi +done < <(find "$BOOK_PATH" -maxdepth 1 -type f) + if [[ -n "$MATCH" ]]; then + FOUND["$RANK"]=1 + + GROUP="Sci Fi Top 100 Books #$RANK" + + case "$MODE" in + dryrun) + echo "Would tag:" + echo " File : $MATCH" + echo " Album: $GROUP" + echo " Year : $YEAR" + echo + ;; + + write) + echo "Tagging: $MATCH" + + id3v2 \ + --TALB "$GROUP" \ + --TYER "$YEAR" \ + "$MATCH" + ;; + + missing) + ;; + esac + fi + +done < "$BOOKLIST" + +if [[ "$MODE" == "missing" ]]; then + while IFS='|' read -r RANK TITLE AUTHOR YEAR; do + [[ -z "$RANK" ]] && continue + [[ "$RANK" =~ ^# ]] && continue + + if [[ -n "${FOUND[$RANK]:-}" ]]; then + printf "${GREEN}FOUND${RESET} #%3d | %s | %s | %s\n" \ + "$RANK" "$TITLE" "$AUTHOR" "$YEAR" + else + printf "${RED}MISSING${RESET} #%3d | %s | %s | %s\n" \ + "$RANK" "$TITLE" "$AUTHOR" "$YEAR" + fi + done < "$BOOKLIST" +fi diff --git a/quiethost/metadate_generate_for_pdf.sh b/quiethost/metadate_generate_for_pdf.sh new file mode 100644 index 0000000..6cc4121 --- /dev/null +++ b/quiethost/metadate_generate_for_pdf.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +BASE="${1:-.}" + +for dir in "$BASE"/*/; do + [ -d "$dir" ] || continue + + title=$(basename "$dir") + + # Skip directories starting with a number + [[ "$title" =~ ^[0-9] ]] && continue + + # Author = parent directory of book directory + author=$(basename "$(dirname "$dir")") + + # Fix author name formatting + author="${author//JRR/J. R. R.}" + + for pdf in "$dir"/*.pdf; do + [ -f "$pdf" ] || continue + + opf="$dir/metadata.opf" + + cat > "$opf" < + + + $title + $author + en + application/pdf + + +EOF + + echo "Created: $opf" + done +done