#!/usr/bin/env python3 import argparse import re import subprocess from pathlib import Path from datetime import datetime def get_genre(file_path): try: result = subprocess.run( ["kid3-cli", "-c", "get genre", str(file_path)], capture_output=True, text=True, check=False, ) return result.stdout.strip() except Exception: return "" def set_genre(file_path, genre): subprocess.run( [ "kid3-cli", "-c", "tag 2", "-c", f"set genre \"{genre}\"", "-c", "save", str(file_path), ], check=False, ) def clean_genre(genre): genre = re.sub(r'(?i)^audio\s*books?$', '', genre) genre = re.sub(r'(?i)^audiobooks?;?$', '', genre) genre = re.sub(r'(?i)\b(hörb(uch|ücher)|hoerb(uch|uecher))\b', '', genre) genre = re.sub(r'(?i)^(hörspiele?|hoerspiele?)$', '', genre) genre = re.sub(r'(?i)^spoken\s*word$', '', genre) genre = re.sub(r'(?i)gesprochene\s*inhalte$', '', genre) genre = re.sub(r'(?i)^speech$', '', genre) genre = re.sub(r'Fanasie', 'Fantasie', genre) genre = re.sub(r'Mit Videospiel-?', 'Videospielbezug', genre) genre = re.sub(r'(?i)nonfiction', 'NonFiction', genre) genre = re.sub(r'(?i)^speech$', '', genre) genre = re.sub(r'(?i)^vocal$', '', genre) genre = re.sub(r'(?i)^retail$', '', genre) genre = re.sub(r'(?i)^other$', '', genre) genre = re.sub(r'(?i)^misc$', '', genre) genre = re.sub(r'(?i)^(sci[- ]?fi|sf)$', 'Science Fiction', genre) genre = re.sub(r'(?i)^\(?science fiction\)?$', 'Science Fiction', genre) genre = re.sub(r'Thrillers', 'Thriller', genre) genre = re.sub(r"(?i)\bchildren'?s audiobooks?\b", "", genre) genre = re.sub(r'^-\s*|\s*-$', '', genre) genre = re.sub(r'(?i)Engelske\s*\sLydbånd', '', genre) genre = re.sub(r'^FIC028000.*$', '', genre) genre = re.sub(r'^800.*$', '', genre) genre = re.sub(r'^Fantastic Beasts.*$', '', genre) genre = re.sub(r'^and thei.*$', '', genre) genre = re.sub(r'^those you.*$', '', genre) # Normalize separators genre = re.sub( r'\s*[,&:/]\s*', ';', genre ) genre = re.sub( r'\s*;\s*', '; ', genre ) # Trim punctuation / whitespace genre = re.sub( r'^[\s,;:\-]+', '', genre ) genre = re.sub( r'[\s,;\-]+$', '', genre ) genre = genre.strip() genre = genre.lstrip() return genre def find_audio_files(library): extensions = {".mp3", ".m4a", ".m4b"} return [ p for p in Path(library).rglob("*") if p.is_file() and p.suffix.lower() in extensions ] def main(): parser = argparse.ArgumentParser( description="Audiobook Genre Cleanup" ) parser.add_argument( "library", help="Library path" ) parser.add_argument( "--delete", action="store_true", help="Update tags instead of reporting only" ) parser.add_argument( "--report", default="/home/lucas/audiobook_genre_cleanup.txt", help="Report file" ) args = parser.parse_args() files = find_audio_files(args.library) total = len(files) processed = 0 matches = 0 modified = 0 with open(args.report, "w", encoding="utf-8") as report: report.write("Audiobook Genre Cleanup Report\n") report.write(f"Generated: {datetime.now()}\n") report.write(f"Library: {args.library}\n") report.write(f"Total files: {total}\n\n") for file in files: processed += 1 if processed % 100 == 0: print(f"Progress: {processed}/{total} files processed") genre = get_genre(file) if not genre: continue cleaned = clean_genre(genre) if genre != cleaned: matches += 1 status = "MATCH" if args.delete: set_genre(file, cleaned) modified += 1 status = "UPDATED" line = ( f'[{processed}/{total}] [{status}] ' f'{file} | OLD="{genre}" | NEW="{cleaned}"' ) print(line) with open(args.report, "a", encoding="utf-8") as report: report.write(line + "\n") with open(args.report, "a", encoding="utf-8") as report: report.write("\n========== SUMMARY ==========\n") report.write(f"Files scanned : {total}\n") report.write(f"Matches found : {matches}\n") report.write(f"Files updated : {modified}\n") print("\n========== SUMMARY ==========") print(f"Files scanned : {total}") print(f"Matches found : {matches}") print(f"Files updated : {modified}") print(f"Report written to: {args.report}") if __name__ == "__main__": main()