2026-10-12 16:33:40 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
|
|
|
|
import subprocess
|
|
|
|
|
import tempfile
|
|
|
|
|
import argparse
|
|
|
|
|
import logging
|
|
|
|
|
import re
|
2026-10-16 17:44:37 +02:00
|
|
|
import json
|
2026-10-12 16:33:40 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
from collections import defaultdict
|
2026-10-16 22:30:57 +02:00
|
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
2026-10-12 16:33:40 +02:00
|
|
|
|
|
|
|
|
RED = "\033[91m"
|
|
|
|
|
GREEN = "\033[92m"
|
|
|
|
|
YELLOW = "\033[93m"
|
|
|
|
|
BLUE = "\033[94m"
|
|
|
|
|
RESET = "\033[0m"
|
2026-10-16 22:05:09 +02:00
|
|
|
TRACE = 5
|
|
|
|
|
class CustomLogger(logging.Logger):
|
|
|
|
|
def trace(self, msg: str, *args, **kwargs) -> None:
|
|
|
|
|
if self.isEnabledFor(TRACE):
|
|
|
|
|
self._log(TRACE, msg, args, **kwargs)
|
|
|
|
|
|
|
|
|
|
logging.setLoggerClass(CustomLogger)
|
|
|
|
|
logger: CustomLogger = logging.getLogger(__name__) # type: ignore[assignment]
|
|
|
|
|
LOG_LEVELS = {
|
|
|
|
|
"TRACE": TRACE,
|
|
|
|
|
"DEBUG": logging.DEBUG,
|
|
|
|
|
"INFO": logging.INFO,
|
|
|
|
|
"WARNING": logging.WARNING,
|
|
|
|
|
"ERROR": logging.ERROR,
|
|
|
|
|
"CRITICAL": logging.CRITICAL,
|
|
|
|
|
}
|
2026-10-12 16:33:40 +02:00
|
|
|
|
|
|
|
|
audio_extensions = {
|
|
|
|
|
"*.m4b",
|
|
|
|
|
"*.m4a",
|
|
|
|
|
"*.mp3",
|
|
|
|
|
"*.flac",
|
|
|
|
|
"*.ogg",
|
|
|
|
|
"*.opus"
|
|
|
|
|
}
|
2026-10-16 17:44:37 +02:00
|
|
|
def ffmetadata_escape(value: str) -> str:
|
|
|
|
|
return (
|
|
|
|
|
value
|
|
|
|
|
.replace("\\", "\\\\")
|
|
|
|
|
.replace("=", "\\=")
|
|
|
|
|
.replace(";", "\\;")
|
|
|
|
|
.replace("#", "\\#")
|
|
|
|
|
.replace("\n", "\\\n")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_chapters(media: Path) -> list[dict]:
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
[
|
|
|
|
|
"ffprobe",
|
|
|
|
|
"-v", "error",
|
|
|
|
|
"-show_chapters",
|
|
|
|
|
"-of", "json",
|
|
|
|
|
str(media),
|
|
|
|
|
],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
check=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
data = json.loads(result.stdout)
|
|
|
|
|
return data.get("chapters", [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_metadata(file, fields):
|
|
|
|
|
metadata=dict.fromkeys(fields, "")
|
|
|
|
|
for field in fields:
|
|
|
|
|
cmd= (["kid3-cli", "-c", f"get {field}", str(file)])
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
cmd,
|
|
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
stderr=subprocess.DEVNULL,
|
|
|
|
|
text=True,
|
|
|
|
|
check=True,
|
|
|
|
|
)
|
|
|
|
|
metadata[field] = result.stdout.strip()
|
|
|
|
|
except (OSError, subprocess.SubprocessError) as error:
|
|
|
|
|
print(f"Error getting metadata for {file}: {error}")
|
|
|
|
|
return metadata
|
|
|
|
|
return metadata
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_duration_ms(media: Path) -> int:
|
|
|
|
|
duration = subprocess.check_output(
|
|
|
|
|
[
|
|
|
|
|
"ffprobe",
|
|
|
|
|
"-v",
|
|
|
|
|
"error",
|
|
|
|
|
"-show_entries",
|
|
|
|
|
"format=duration",
|
|
|
|
|
"-of",
|
|
|
|
|
"default=noprint_wrappers=1:nokey=1",
|
|
|
|
|
str(media),
|
|
|
|
|
],
|
|
|
|
|
text=True,
|
|
|
|
|
).strip()
|
|
|
|
|
|
|
|
|
|
return int(float(duration) * 1000)
|
|
|
|
|
|
|
|
|
|
|
2026-10-12 16:33:40 +02:00
|
|
|
class Audiobook():
|
2026-10-16 22:05:09 +02:00
|
|
|
def __init__(self, book_path: Path) -> None:
|
|
|
|
|
self.metadata = None
|
|
|
|
|
self.artist = None
|
|
|
|
|
self.genre = None
|
|
|
|
|
self.composer = None
|
|
|
|
|
self.name = None
|
|
|
|
|
self.comment = None
|
|
|
|
|
self.bookPath = book_path
|
2026-10-12 16:33:40 +02:00
|
|
|
self.files = [file for pattern in audio_extensions for file in
|
2026-10-16 22:05:09 +02:00
|
|
|
book_path.glob(pattern)]
|
2026-10-12 16:33:40 +02:00
|
|
|
self.firstFile = self.get_first_file()
|
|
|
|
|
self.size = sum(file.stat().st_size for pattern in audio_extensions for file in
|
2026-10-16 22:05:09 +02:00
|
|
|
book_path.glob(pattern))
|
2026-10-12 16:33:40 +02:00
|
|
|
self.fileConunt = len(self.files)
|
2026-10-16 22:05:09 +02:00
|
|
|
self.singleFile = self.fileConunt == 1
|
2026-10-12 16:33:40 +02:00
|
|
|
# Metadata
|
2026-10-16 17:44:37 +02:00
|
|
|
self.load_metadata()
|
2026-10-12 16:33:40 +02:00
|
|
|
logger.info(f"Found {self.bookPath.name}, {self.fileConunt} files")
|
2026-10-16 22:05:09 +02:00
|
|
|
self.changed = False
|
2026-10-12 16:33:40 +02:00
|
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
|
return (
|
|
|
|
|
f"Audiobook("
|
|
|
|
|
f"name={self.name!r}, "
|
|
|
|
|
f"artist={self.artist!r}, "
|
|
|
|
|
f"composer={self.composer!r}, "
|
|
|
|
|
f"path={str(self.bookPath)!r}),"
|
|
|
|
|
f"size={self.size!r}, "
|
|
|
|
|
f"fileConunt={self.fileConunt!r}, "
|
|
|
|
|
)
|
|
|
|
|
def get_first_file(self):
|
|
|
|
|
return self.files[0]
|
|
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
def load_metadata(self):
|
2026-10-12 16:33:40 +02:00
|
|
|
fields = ["artist", "album", "composer", "comment", "genre"]
|
|
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
metadata = get_metadata(self.get_first_file(), fields )
|
2026-10-12 16:33:40 +02:00
|
|
|
self.artist = metadata["artist"]
|
|
|
|
|
self.name = metadata["album"]
|
|
|
|
|
self.composer = metadata["composer"]
|
|
|
|
|
self.comment = metadata["comment"]
|
|
|
|
|
self.genre = metadata["genre"]
|
2026-10-16 22:05:09 +02:00
|
|
|
self.metadata= metadata
|
|
|
|
|
logger.trace(f"Load metadata {self.artist}, {self.name}, {self.composer}, {self.comment}, {self.genre}")
|
2026-10-12 16:33:40 +02:00
|
|
|
|
|
|
|
|
def write_metadata(self, file, fields, values):
|
|
|
|
|
for field,value in fields,values:
|
|
|
|
|
cmd= (["kid3-cli", "-c" , "tag 2","-c", f"set {field} \"{value}\"", "-c" "save", str(file)])
|
|
|
|
|
try:
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
cmd,
|
|
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
stderr=subprocess.DEVNULL,
|
|
|
|
|
text=True,
|
|
|
|
|
check=True,
|
|
|
|
|
)
|
|
|
|
|
except (OSError, subprocess.SubprocessError) as error:
|
|
|
|
|
print(f"Error writing metadata for {file}: {error}")
|
|
|
|
|
return
|
|
|
|
|
return
|
|
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
def fix_composer(self):
|
2026-10-16 22:05:09 +02:00
|
|
|
m = re.search(
|
|
|
|
|
r"(?:read\s+by|spoken\s+by|narrated\s+by|gesprochen\s+von|gelesen\s+von)\s+([^\n.,;]+)",
|
|
|
|
|
self.comment,
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
if not m:
|
|
|
|
|
logger.debug(f"No composer found in comment for {self.bookPath}")
|
|
|
|
|
return
|
|
|
|
|
self.changed=True
|
|
|
|
|
self.comment = (self.comment[:m.start()] + self.comment[m.end():] ).strip(" .,;\n")
|
|
|
|
|
self.comment = re.sub(r"\s+"," ",self.comment)
|
2026-10-12 16:33:40 +02:00
|
|
|
if not self.composer:
|
|
|
|
|
logger.info(f"[Updated] composer found for {self.bookPath}")
|
|
|
|
|
self.composer = m.group(1).strip()
|
2026-10-16 22:05:09 +02:00
|
|
|
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
def fix_name(self):
|
2026-10-12 16:33:40 +02:00
|
|
|
if not self.name:
|
|
|
|
|
self.name = self.bookPath.name
|
|
|
|
|
logger.info(f"[Updated] name found for {self.bookPath}")
|
|
|
|
|
|
|
|
|
|
def clean_genre(self,genre):
|
|
|
|
|
if not self.genre:
|
|
|
|
|
return
|
|
|
|
|
genre = self.genre.lower()
|
|
|
|
|
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()
|
|
|
|
|
self.genre = genre.lstrip()
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
def create_m4b_from_book(self, library_root_path, creat_m4b_no_delete: bool=False) -> None:
|
2026-10-12 16:33:40 +02:00
|
|
|
output_file_final = self.bookPath / f"{self.bookPath.name}.m4b"
|
|
|
|
|
|
|
|
|
|
if output_file_final.exists():
|
|
|
|
|
logger.info(f"{RED}[SKIP]{RESET} {output_file_final}")
|
|
|
|
|
logger.info("Book already present")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
logger.info(f"[CREATE] {output_file_final}")
|
|
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
with tempfile.TemporaryDirectory(dir=library_root_path.parent) as tmp:
|
2026-10-12 16:33:40 +02:00
|
|
|
tmpdir = Path(tmp)
|
|
|
|
|
|
|
|
|
|
output_file_tmp = tmpdir / f"{self.bookPath.name}.m4b"
|
|
|
|
|
concat_file = tmpdir / "concat.txt"
|
|
|
|
|
metadata_file = tmpdir / "chapters.txt"
|
|
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
# Inspect all inputs before deciding how to create chapters.
|
|
|
|
|
file_info = []
|
|
|
|
|
has_existing_chapters = False
|
|
|
|
|
|
|
|
|
|
for media in self.files:
|
|
|
|
|
chapters = get_chapters(media)
|
|
|
|
|
duration_ms = get_duration_ms(media)
|
|
|
|
|
|
|
|
|
|
file_info.append({
|
|
|
|
|
"media": media,
|
|
|
|
|
"chapters": chapters,
|
|
|
|
|
"duration_ms": duration_ms,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if chapters:
|
|
|
|
|
has_existing_chapters = True
|
|
|
|
|
|
|
|
|
|
if has_existing_chapters:
|
|
|
|
|
logger.info(
|
|
|
|
|
"Existing chapter tracks found; preserving chapter metadata"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
logger.info(
|
|
|
|
|
"No existing chapters found; generating chapters from filenames"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-12 16:33:40 +02:00
|
|
|
current_ms = 0
|
|
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
with (
|
|
|
|
|
concat_file.open("w", encoding="utf-8") as cf,
|
|
|
|
|
metadata_file.open("w", encoding="utf-8") as mf,
|
|
|
|
|
):
|
2026-10-12 16:33:40 +02:00
|
|
|
mf.write(";FFMETADATA1\n")
|
2026-10-16 22:05:09 +02:00
|
|
|
|
|
|
|
|
for key, value in self.metadata.items():
|
|
|
|
|
mf.write(
|
|
|
|
|
f"{ffmetadata_escape(key)}="
|
|
|
|
|
f"{ffmetadata_escape(value)}\n"
|
|
|
|
|
)
|
2026-10-16 17:44:37 +02:00
|
|
|
for info in file_info:
|
|
|
|
|
media = info["media"]
|
|
|
|
|
chapters = info["chapters"]
|
|
|
|
|
duration_ms = info["duration_ms"]
|
|
|
|
|
|
2026-10-12 16:33:40 +02:00
|
|
|
cf.write(f"file '{ffmpeg_escape(media)}'\n")
|
|
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
if has_existing_chapters:
|
|
|
|
|
# Preserve actual chapter boundaries and titles.
|
|
|
|
|
for chapter in chapters:
|
|
|
|
|
start_ms = round(
|
|
|
|
|
float(chapter["start_time"]) * 1000
|
|
|
|
|
)
|
|
|
|
|
end_ms = round(
|
|
|
|
|
float(chapter["end_time"]) * 1000
|
|
|
|
|
)
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
tags = chapter.get("tags", {})
|
|
|
|
|
title = tags.get("title", media.stem)
|
|
|
|
|
|
|
|
|
|
mf.write("[CHAPTER]\n")
|
|
|
|
|
mf.write("TIMEBASE=1/1000\n")
|
|
|
|
|
mf.write(f"START={current_ms + start_ms}\n")
|
|
|
|
|
mf.write(f"END={current_ms + end_ms}\n")
|
|
|
|
|
mf.write(
|
|
|
|
|
f"title={ffmetadata_escape(title)}\n"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# No input contains chapters: one chapter per file.
|
|
|
|
|
mf.write("[CHAPTER]\n")
|
|
|
|
|
mf.write("TIMEBASE=1/1000\n")
|
|
|
|
|
mf.write(f"START={current_ms}\n")
|
|
|
|
|
mf.write(f"END={current_ms + duration_ms}\n")
|
|
|
|
|
mf.write(
|
|
|
|
|
f"title={ffmetadata_escape(media.stem)}\n"
|
|
|
|
|
)
|
2026-10-12 16:33:40 +02:00
|
|
|
|
|
|
|
|
current_ms += duration_ms
|
|
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
cmd_backup = [
|
|
|
|
|
"ffmpeg", "-y",
|
|
|
|
|
"-f", "concat", "-safe", "0",
|
|
|
|
|
"-i", str(concat_file),
|
|
|
|
|
"-i", str(metadata_file),
|
|
|
|
|
"-i", str(self.firstFile),
|
2026-10-16 22:05:09 +02:00
|
|
|
# audio from concat
|
2026-10-16 17:44:37 +02:00
|
|
|
"-map", "0:a",
|
2026-10-16 22:05:09 +02:00
|
|
|
# cover image from first media
|
2026-10-16 17:44:37 +02:00
|
|
|
"-map", "2:v?",
|
2026-10-16 22:05:09 +02:00
|
|
|
# Metadata and chapters from our generated FFMETADATA file
|
|
|
|
|
"-map_metadata", "1",
|
2026-10-16 17:44:37 +02:00
|
|
|
"-map_chapters", "1",
|
|
|
|
|
|
|
|
|
|
"-c:a", "aac",
|
|
|
|
|
"-b:a", "128k",
|
|
|
|
|
|
|
|
|
|
"-c:v", "copy",
|
|
|
|
|
"-disposition:v:0", "attached_pic",
|
|
|
|
|
|
|
|
|
|
str(output_file_tmp),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
cmd_default = [
|
|
|
|
|
"ffmpeg", "-y",
|
|
|
|
|
"-f", "concat", "-safe", "0",
|
|
|
|
|
"-i", str(concat_file),
|
|
|
|
|
"-i", str(metadata_file),
|
|
|
|
|
"-i", str(self.firstFile),
|
|
|
|
|
|
|
|
|
|
"-map", "0:a",
|
|
|
|
|
"-map", "2:v?",
|
2026-10-16 22:05:09 +02:00
|
|
|
# Metadata and chapters from our generated FFMETADATA file
|
|
|
|
|
"-map_metadata", "1",
|
2026-10-16 17:44:37 +02:00
|
|
|
"-map_chapters", "1",
|
|
|
|
|
|
|
|
|
|
"-c:a", "copy",
|
|
|
|
|
|
|
|
|
|
"-c:v", "copy",
|
|
|
|
|
"-disposition:v:0", "attached_pic",
|
|
|
|
|
|
|
|
|
|
str(output_file_tmp),
|
|
|
|
|
]
|
2026-10-12 16:33:40 +02:00
|
|
|
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
cmd_default,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
)
|
2026-10-16 17:44:37 +02:00
|
|
|
|
2026-10-12 16:33:40 +02:00
|
|
|
logger.info(result.stdout)
|
2026-10-16 17:44:37 +02:00
|
|
|
|
2026-10-12 16:33:40 +02:00
|
|
|
if result.returncode == 234:
|
|
|
|
|
logger.warning(
|
|
|
|
|
f"{YELLOW}[WARNING]{RESET} - {self.bookPath.name} - {result.returncode} retrying with aac encoder")
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
cmd_backup,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
)
|
|
|
|
|
logger.info(result.stdout)
|
|
|
|
|
|
|
|
|
|
if result.returncode == 0:
|
|
|
|
|
output_file_tmp.rename(output_file_final)
|
|
|
|
|
for media in self.files:
|
2026-10-16 17:44:37 +02:00
|
|
|
if creat_m4b_no_delete:
|
|
|
|
|
continue
|
2026-10-12 16:33:40 +02:00
|
|
|
media.unlink()
|
|
|
|
|
self.files = Path(output_file_final)
|
|
|
|
|
self.firstFile = Path(output_file_final)
|
|
|
|
|
self.fileConunt = 1
|
|
|
|
|
self.singleFile = True
|
2026-10-16 22:05:09 +02:00
|
|
|
self.changed = False
|
2026-10-12 16:33:40 +02:00
|
|
|
self.size = Path(output_file_final).stat().st_size
|
|
|
|
|
logger.info(f"{GREEN}[SUCCESS]{RESET} - {self.bookPath.name} - delete media files")
|
|
|
|
|
else:
|
|
|
|
|
logger.error(
|
|
|
|
|
f"{RED}[ERROR]{RESET} - {self.bookPath.name} - Code: {result.returncode}, Stderr: {result.stderr}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AudiobookLibrary:
|
2026-10-16 22:05:09 +02:00
|
|
|
def __init__(self, libraryPath,dry_run,logfilename: str="audiobook_library.log"):
|
|
|
|
|
|
|
|
|
|
self.dry_run = dry_run
|
|
|
|
|
logging.setLoggerClass(CustomLogger)
|
|
|
|
|
logger: CustomLogger = logging.getLogger(__name__) # type: ignore[assignment]
|
2026-10-12 16:33:40 +02:00
|
|
|
logging.basicConfig(
|
|
|
|
|
level=logging.INFO,
|
|
|
|
|
format="%(asctime)s %(levelname)s %(message)s",
|
|
|
|
|
handlers=[
|
|
|
|
|
logging.FileHandler(logfilename),
|
|
|
|
|
logging.StreamHandler()
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
logger.info('Started')
|
|
|
|
|
self.libraryPath = Path(libraryPath)
|
|
|
|
|
self.audiobooks = []
|
|
|
|
|
|
|
|
|
|
self._by_path = {}
|
|
|
|
|
self._by_name = defaultdict(list)
|
|
|
|
|
self._by_artist = defaultdict(list)
|
|
|
|
|
self._by_composer = defaultdict(list)
|
|
|
|
|
|
2026-10-16 22:05:09 +02:00
|
|
|
self.load_audiobooks_from_dir()
|
2026-10-12 16:33:40 +02:00
|
|
|
self.size = sum( audiobook.size for audiobook in self.audiobooks ) # size in bytes
|
|
|
|
|
|
|
|
|
|
def add(self, book: Audiobook):
|
|
|
|
|
self.audiobooks.append(book)
|
|
|
|
|
|
|
|
|
|
self._by_path[book.bookPath] = book
|
|
|
|
|
self._by_name[book.name].append(book)
|
|
|
|
|
|
|
|
|
|
if book.artist:
|
|
|
|
|
self._by_artist[book.artist].append(book)
|
|
|
|
|
if book.composer:
|
|
|
|
|
self._by_composer[book.composer].append(book)
|
|
|
|
|
|
2026-10-16 22:05:09 +02:00
|
|
|
def print_name(self, books):
|
2026-10-12 16:33:40 +02:00
|
|
|
if not isinstance(books, list):
|
|
|
|
|
books = [books]
|
|
|
|
|
for book in books:
|
|
|
|
|
if book.name:
|
|
|
|
|
logger.info(book.name)
|
|
|
|
|
else:
|
|
|
|
|
logger.info(f"No name in {book.bookPath.name}")
|
|
|
|
|
|
|
|
|
|
def get_by_path(self, path):
|
|
|
|
|
return self._by_path.get(Path(path))
|
|
|
|
|
|
|
|
|
|
def get_by_name(self, name):
|
|
|
|
|
return self._by_name.get(name, [])
|
|
|
|
|
|
|
|
|
|
def get_by_artist(self, artist):
|
|
|
|
|
return self._by_artist.get(artist, [])
|
|
|
|
|
|
|
|
|
|
def get_by_composer(self, composer):
|
|
|
|
|
return self._by_artist.get(composer, [])
|
|
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
def find_path_contains(self, path) -> list[Audiobook]:
|
|
|
|
|
return [
|
|
|
|
|
book
|
|
|
|
|
for book in self.audiobooks
|
|
|
|
|
if book.bookPath == path or book.bookPath.is_relative_to(path)
|
|
|
|
|
]
|
|
|
|
|
|
2026-10-12 16:33:40 +02:00
|
|
|
def find_name_contains(self, text):
|
|
|
|
|
text = text.lower()
|
|
|
|
|
return [
|
|
|
|
|
book
|
|
|
|
|
for book in self.audiobooks
|
|
|
|
|
if text in book.name.lower()
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def find_artist_contains(self, text):
|
|
|
|
|
text = text.lower()
|
|
|
|
|
return [
|
|
|
|
|
book
|
|
|
|
|
for book in self.audiobooks
|
|
|
|
|
if book.artist and text in book.artist.lower()
|
|
|
|
|
]
|
|
|
|
|
|
2026-10-16 22:05:09 +02:00
|
|
|
def load_audiobooks_from_dir(self):
|
2026-10-12 16:33:40 +02:00
|
|
|
"""
|
|
|
|
|
A book directory is any directory containing more than one
|
|
|
|
|
audiobook file (.mp3, .m4a, .m4b)
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
media_files = []
|
|
|
|
|
for ext in audio_extensions:
|
|
|
|
|
media_files.extend(list(self.libraryPath.rglob( ext)) )
|
|
|
|
|
dirs = {file.parent for file in media_files}
|
|
|
|
|
for directory in dirs:
|
|
|
|
|
self.add(Audiobook(directory))
|
|
|
|
|
|
2026-10-16 22:05:09 +02:00
|
|
|
def load_metadata(self):
|
2026-10-12 16:33:40 +02:00
|
|
|
for audiobook in self.audiobooks:
|
2026-10-16 17:44:37 +02:00
|
|
|
audiobook.load_metadata()
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-16 22:05:09 +02:00
|
|
|
def write_metadata(self):
|
|
|
|
|
for audiobook in self.audiobooks:
|
|
|
|
|
if audiobook.changed:
|
|
|
|
|
logger.trace(f"Writing metadata: {audiobook.name}")
|
|
|
|
|
if self.dry_run:
|
|
|
|
|
continue
|
|
|
|
|
audiobook.write_metadata()
|
|
|
|
|
|
|
|
|
|
def fix_composers(self):
|
2026-10-12 16:33:40 +02:00
|
|
|
for book in self.audiobooks:
|
2026-10-16 17:44:37 +02:00
|
|
|
book.fix_composer()
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-16 22:05:09 +02:00
|
|
|
def fix_names(self):
|
2026-10-12 16:33:40 +02:00
|
|
|
for book in self.audiobooks:
|
2026-10-16 17:44:37 +02:00
|
|
|
book.fix_name()
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-16 22:05:09 +02:00
|
|
|
def fix_comments(self):
|
2026-10-12 16:33:40 +02:00
|
|
|
for book in self.audiobooks:
|
2026-10-16 22:05:09 +02:00
|
|
|
if book.comment and len(book.comment) < 40:
|
2026-10-12 16:33:40 +02:00
|
|
|
logger.info(f"Remove short comment: {book.comment} of {book.name}")
|
|
|
|
|
book.comment = ""
|
2026-10-16 22:05:09 +02:00
|
|
|
book.changed=True
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
def create_m4b_from_dir(self,path: Path="", name: str="",create_m4b_no_delete: bool=False) -> None:
|
2026-10-16 22:05:09 +02:00
|
|
|
audiobooks = []
|
|
|
|
|
if path:
|
|
|
|
|
audiobooks = self.find_path_contains(path)
|
|
|
|
|
if name:
|
|
|
|
|
audiobooks.extend(self.find_name_contains(name))
|
|
|
|
|
if not name and not path:
|
|
|
|
|
audiobooks = self.audiobooks
|
2026-10-16 22:30:57 +02:00
|
|
|
|
|
|
|
|
jobs = [
|
|
|
|
|
audiobook
|
|
|
|
|
for audiobook in audiobooks
|
|
|
|
|
if not audiobook.singleFile
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for audiobook in jobs:
|
|
|
|
|
logger.debug(f"Create m4b file for {audiobook.name}")
|
|
|
|
|
|
|
|
|
|
if not self.dry_run:
|
|
|
|
|
with ThreadPoolExecutor(max_workers=8) as executor:
|
|
|
|
|
futures = {
|
|
|
|
|
executor.submit(
|
|
|
|
|
audiobook.create_m4b_from_book,
|
|
|
|
|
self.libraryPath,
|
|
|
|
|
create_m4b_no_delete,
|
|
|
|
|
): audiobook
|
|
|
|
|
for audiobook in jobs
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for future in as_completed(futures):
|
|
|
|
|
audiobook = futures[future]
|
|
|
|
|
try:
|
|
|
|
|
future.result()
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.exception(f"Failed to create m4b for {audiobook.name}")
|
2026-10-16 17:44:37 +02:00
|
|
|
|
2026-10-12 16:33:40 +02:00
|
|
|
def ffmpeg_escape(path: Path) -> str:
|
|
|
|
|
return str(path.resolve()).replace("'", r"'\''")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
description="Audiobook Directory Processing"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"library",
|
|
|
|
|
type=Path,
|
|
|
|
|
help="Library path"
|
|
|
|
|
)
|
2026-10-16 22:05:09 +02:00
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--create-m4b-no-delete",
|
|
|
|
|
action="store_true",
|
|
|
|
|
help="create m4b without deleting"
|
|
|
|
|
)
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-16 22:05:09 +02:00
|
|
|
parser.add_argument(
|
|
|
|
|
"-ll",
|
|
|
|
|
"--log-level",
|
|
|
|
|
type=str.upper,
|
|
|
|
|
default="INFO",
|
|
|
|
|
choices=["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
|
|
|
help="Set log level",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"-cmfn",
|
|
|
|
|
"--create-m4b-filter-name",
|
|
|
|
|
type=str,
|
|
|
|
|
default="",
|
|
|
|
|
help="Only create m4b from multiple files matching <name>",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"-cmfp",
|
|
|
|
|
"--create-m4b-filter-path",
|
|
|
|
|
type=str,
|
|
|
|
|
default="",
|
|
|
|
|
help="Only create m4b from multiple files matching <path>",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--dry-run",
|
|
|
|
|
action="store_true",
|
|
|
|
|
help="Dry run only",
|
|
|
|
|
)
|
2026-10-12 16:33:40 +02:00
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
2026-10-16 22:05:09 +02:00
|
|
|
logging.basicConfig(
|
|
|
|
|
level=LOG_LEVELS[args.log_level.upper()],
|
|
|
|
|
format="%(asctime)s %(levelname)s %(message)s",
|
|
|
|
|
)
|
|
|
|
|
library_dir = args.library.resolve()
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-16 22:05:09 +02:00
|
|
|
library = AudiobookLibrary(library_dir,args.dry_run)
|
|
|
|
|
|
|
|
|
|
library.fix_composers()
|
|
|
|
|
library.fix_names()
|
|
|
|
|
library.fix_comments()
|
|
|
|
|
library.create_m4b_from_dir(name=args.create_m4b_filter_name,
|
|
|
|
|
path=args.create_m4b_filter_path,
|
|
|
|
|
create_m4b_no_delete=args.create_m4b_no_delete)
|
|
|
|
|
library.write_metadata()
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
#library.printName(library.get_by_artist("Tolkien"))
|
|
|
|
|
#library.printName(library.find_artist_contains("Tolkien"))
|
|
|
|
|
#library.printName(library.find_name_contains(""))
|
|
|
|
|
#library.printName(library.get_by_path("/home/lucas/Nextcloud/Syncfolder/"))
|
|
|
|
|
print("Fin")
|
2026-10-12 16:33:40 +02:00
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|