2026-10-12 16:33:40 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
|
|
|
|
import argparse
|
2026-10-25 14:22:43 +01:00
|
|
|
import json
|
2026-10-12 16:33:40 +02:00
|
|
|
import logging
|
|
|
|
|
import re
|
2026-10-25 14:22:43 +01:00
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
import tempfile
|
|
|
|
|
import sqlite3
|
2026-10-12 16:33:40 +02:00
|
|
|
from collections import defaultdict
|
2026-10-16 22:30:57 +02:00
|
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
2026-10-25 14:22:43 +01:00
|
|
|
from pathlib import Path
|
2026-10-27 19:47:51 +01:00
|
|
|
from serverlibrary import *
|
2026-10-25 14:22:43 +01:00
|
|
|
|
2026-10-16 22:05:09 +02:00
|
|
|
logging.setLoggerClass(CustomLogger)
|
|
|
|
|
logger: CustomLogger = logging.getLogger(__name__) # type: ignore[assignment]
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
metadata_fields = ["artist", "album", "composer", "comment", "genre", "grouping", "album artist", "description"]
|
|
|
|
|
|
|
|
|
|
def ffmpeg_escape(path: Path) -> str:
|
|
|
|
|
return str(path.resolve()).replace("'", r"'\''")
|
|
|
|
|
|
|
|
|
|
def clean_genre(genre):
|
|
|
|
|
if not genre:
|
|
|
|
|
return ""
|
|
|
|
|
genre_old = genre
|
|
|
|
|
#genre = 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()
|
|
|
|
|
genre = genre.lstrip()
|
|
|
|
|
if genre != genre_old:
|
|
|
|
|
logger.debug(f"Update genre from {genre_old} --->>> {genre}")
|
|
|
|
|
return genre
|
|
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
def ffmetadata_escape(value: str) -> str:
|
|
|
|
|
return (
|
2026-10-25 14:22:43 +01:00
|
|
|
value.replace("\\", "\\\\")
|
2026-10-16 17:44:37 +02:00
|
|
|
.replace("=", "\\=")
|
|
|
|
|
.replace(";", "\\;")
|
|
|
|
|
.replace("#", "\\#")
|
|
|
|
|
.replace("\n", "\\\n")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def get_chapters(media: Path) -> list[dict]:
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
[
|
|
|
|
|
"ffprobe",
|
2026-10-25 14:22:43 +01:00
|
|
|
"-v",
|
|
|
|
|
"error",
|
2026-10-16 17:44:37 +02:00
|
|
|
"-show_chapters",
|
2026-10-25 14:22:43 +01:00
|
|
|
"-of",
|
|
|
|
|
"json",
|
2026-10-16 17:44:37 +02:00
|
|
|
str(media),
|
|
|
|
|
],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
check=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
data = json.loads(result.stdout)
|
|
|
|
|
return data.get("chapters", [])
|
|
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
def get_metadata(file, fields) -> dict:
|
|
|
|
|
metadata = dict.fromkeys(fields, "")
|
2026-10-16 17:44:37 +02:00
|
|
|
for field in fields:
|
2026-10-25 14:22:43 +01:00
|
|
|
cmd = ["kid3-cli", "-c", f"get {field}", str(file)]
|
2026-10-16 17:44:37 +02:00
|
|
|
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-25 14:22:43 +01:00
|
|
|
def write_metadata(file, fields, values,dry_run=False):
|
|
|
|
|
cmd_base = [
|
|
|
|
|
"kid3-cli",
|
|
|
|
|
"-c",
|
|
|
|
|
"tag 2"
|
|
|
|
|
]
|
|
|
|
|
for field, value in zip(fields, values):
|
|
|
|
|
cmd_base.extend( ["-c", f'set {field} "{value}"'])
|
|
|
|
|
cmd_base.extend(["-c", "save", str(file)])
|
|
|
|
|
try:
|
|
|
|
|
logger.trace(f"Writing metadata for {file}")
|
|
|
|
|
logger.trace(f"Cmd: {" ".join(cmd_base)}")
|
|
|
|
|
if dry_run:
|
|
|
|
|
return
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
cmd_base,
|
|
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
stderr=subprocess.DEVNULL,
|
|
|
|
|
text=True,
|
|
|
|
|
check=True,
|
|
|
|
|
)
|
|
|
|
|
logger.trace(f"KID3: {result.stdout}")
|
|
|
|
|
except (OSError, subprocess.SubprocessError) as error:
|
|
|
|
|
print(f"Error writing metadata for {file}: {error}")
|
|
|
|
|
return
|
|
|
|
|
return
|
2026-10-16 17:44:37 +02:00
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
def cleanup(text: str) -> str:
|
|
|
|
|
# Collapse repeated punctuation groups like ", ,", ". .", "! !", "? ?"
|
|
|
|
|
while True:
|
|
|
|
|
new_text = re.sub(r'([.,!?;:])(?:\s+\1)+', r'\1', text)
|
|
|
|
|
if new_text == text:
|
|
|
|
|
break
|
|
|
|
|
text = new_text
|
|
|
|
|
|
|
|
|
|
# Optional: normalize multiple spaces
|
|
|
|
|
text = re.sub(r'\s{2,}', ' ', text)
|
|
|
|
|
|
|
|
|
|
return text.strip()
|
|
|
|
|
|
|
|
|
|
class Audiobook:
|
|
|
|
|
def __init__(self, book_path: Path, library_root_path : Path) -> None:
|
|
|
|
|
self.series = ""
|
|
|
|
|
self.singleFile = False
|
|
|
|
|
self.fileConunt = 0
|
|
|
|
|
self.size = 0
|
|
|
|
|
self.firstFile = ""
|
|
|
|
|
self.changed = None
|
|
|
|
|
self.metadata = {}
|
|
|
|
|
self.artist = ""
|
|
|
|
|
self.genre = ""
|
|
|
|
|
self.composer = ""
|
|
|
|
|
self.name = ""
|
|
|
|
|
self.comment = ""
|
|
|
|
|
self.files: list[Path] = []
|
2026-10-16 22:05:09 +02:00
|
|
|
self.bookPath = book_path
|
2026-10-25 14:22:43 +01:00
|
|
|
self.library_root_path = library_root_path
|
|
|
|
|
|
|
|
|
|
def init_book(self) -> None:
|
|
|
|
|
self.files = [
|
2026-10-29 08:58:10 +01:00
|
|
|
file for pattern in AUDIO_SUFFIXES for file in self.bookPath.glob(pattern)
|
2026-10-25 14:22:43 +01:00
|
|
|
]
|
2026-10-12 16:33:40 +02:00
|
|
|
self.firstFile = self.get_first_file()
|
2026-10-25 14:22:43 +01:00
|
|
|
self.size = sum(
|
|
|
|
|
file.stat().st_size
|
2026-10-29 08:58:10 +01:00
|
|
|
for pattern in AUDIO_SUFFIXES
|
2026-10-25 14:22:43 +01:00
|
|
|
for file in self.bookPath.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}, "
|
|
|
|
|
)
|
2026-10-25 14:22:43 +01:00
|
|
|
|
2026-10-12 16:33:40 +02:00
|
|
|
def get_first_file(self):
|
|
|
|
|
return self.files[0]
|
|
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
def write_fields_from_metadata(self) -> None:
|
|
|
|
|
self.name = self.metadata["album"]
|
|
|
|
|
self.composer = self.metadata["composer"]
|
|
|
|
|
self.comment = self.metadata["comment"]
|
|
|
|
|
self.genre = self.metadata["genre"]
|
|
|
|
|
self.series = self.metadata["grouping"]
|
|
|
|
|
artist = self.metadata.get("artist", "").strip()
|
|
|
|
|
self.artist = artist or self.metadata.get("album artist")
|
|
|
|
|
self.description = self.metadata["description"]
|
|
|
|
|
|
|
|
|
|
def write_metadata_from_fields(self):
|
|
|
|
|
self.metadata["album"] = self.name
|
|
|
|
|
self.metadata["composer"] = self.composer
|
|
|
|
|
self.metadata["genre"] = self.genre
|
|
|
|
|
self.metadata["grouping"] = self.series
|
|
|
|
|
self.metadata["comment"] = self.comment
|
|
|
|
|
self.metadata["description"] = self.description
|
|
|
|
|
self.metadata["artist"] = self.artist
|
|
|
|
|
self.metadata["album artist"] = self.artist
|
|
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
def load_metadata(self):
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
self.metadata = get_metadata(self.get_first_file(), metadata_fields)
|
|
|
|
|
self.write_fields_from_metadata()
|
|
|
|
|
logger.trace(
|
|
|
|
|
f"Load metadata {self.artist}, {self.name}, {self.composer}, {self.comment}, {self.genre}"
|
|
|
|
|
)
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
def fix_artist_from_comment(self):
|
2026-10-16 22:05:09 +02:00
|
|
|
m = re.search(
|
2026-10-25 14:22:43 +01:00
|
|
|
r"(?:"
|
|
|
|
|
r"author(?:s)?:?|"
|
|
|
|
|
r"writer(?:s)?:?|"
|
|
|
|
|
r"co[- ]?author(?:s)?:?|"
|
|
|
|
|
r"co[- ]?writer(?:s)?:?|"
|
|
|
|
|
r"creator(?:s)?:?|"
|
|
|
|
|
r"contributor(?:s)?:?|"
|
|
|
|
|
r"composer(?:s)?:?|"
|
|
|
|
|
r"lyricist(?:s)?:?|"
|
|
|
|
|
r"songwriter(?:s)?:?|"
|
|
|
|
|
r"written\s+by:?|"
|
|
|
|
|
r"story\s+by:?|"
|
|
|
|
|
r"autorin(?:nen)?:?|"
|
|
|
|
|
r"autor\(en\):?|"
|
|
|
|
|
r"autor(?:en)?:?|"
|
|
|
|
|
r"text(?:e)?\s+von:?|"
|
|
|
|
|
r"geschrieben\s+von:?|"
|
|
|
|
|
r"verfasst\s+von:?|"
|
|
|
|
|
r"auteur(?:s)?:?|"
|
|
|
|
|
r"autore(?:i)?:?|"
|
|
|
|
|
r"autor(?:es)?:?|"
|
|
|
|
|
r"escritor(?:es|as)?:?"
|
|
|
|
|
r")\s*:?\s*([^\n.,;]+)",
|
2026-10-16 22:05:09 +02:00
|
|
|
self.comment,
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
if not m:
|
2026-10-25 14:22:43 +01:00
|
|
|
logger.debug(f"No artist found in comment for {self.bookPath}")
|
2026-10-16 22:05:09 +02:00
|
|
|
return
|
2026-10-25 14:22:43 +01:00
|
|
|
self.changed = True
|
|
|
|
|
artist = re.sub(r"\s*\([^)]*\)", "", m.group(1).strip()).strip()
|
|
|
|
|
logger.trace(f"Remove {artist} from comment: {self.comment}")
|
|
|
|
|
self.comment = (self.comment[: m.start()] + self.comment[m.end() :]).strip(
|
|
|
|
|
" .,;\n"
|
|
|
|
|
)
|
|
|
|
|
self.comment = cleanup(self.comment)
|
|
|
|
|
if not self.artist:
|
|
|
|
|
self.artist = artist
|
|
|
|
|
elif self.artist != artist:
|
|
|
|
|
logger.warning(
|
|
|
|
|
f"[Action Required] artist {artist} found not matching {self.artist} for {self.name}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
logger.debug(f"Artist {artist} matches file metadata for {self.name}")
|
|
|
|
|
|
|
|
|
|
def fix_artist_from_path(self):
|
|
|
|
|
if not self.artist:
|
|
|
|
|
rel_path = self.bookPath.relative_to(self.library_root_path).parts
|
|
|
|
|
|
|
|
|
|
if len(rel_path) < 2:
|
|
|
|
|
logger.trace(f"Artist not visible in folder structure: {self.bookPath}")
|
|
|
|
|
return
|
|
|
|
|
self.artist = rel_path[0]
|
|
|
|
|
|
|
|
|
|
logger.info(f"[Updated] artist found in path for {self.bookPath}")
|
|
|
|
|
|
|
|
|
|
def fix_composer_from_comment(self):
|
|
|
|
|
m = re.search(
|
|
|
|
|
r"(?:read\s+by:?|"
|
|
|
|
|
r"spoken\s+by:?|"
|
|
|
|
|
r"narrated\s+by:?|"
|
|
|
|
|
r"gesprochen\s+von:?|"
|
|
|
|
|
r"gelesen\s+von)\s+([^\n.,;]+)",
|
|
|
|
|
self.comment,
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
if not m:
|
|
|
|
|
logger.debug(f"No composer found in comment for {self.name}")
|
|
|
|
|
return
|
|
|
|
|
self.changed = True
|
|
|
|
|
self.comment = (self.comment[: m.start()] + self.comment[m.end() :]).strip(
|
|
|
|
|
" .,;\n"
|
|
|
|
|
)
|
|
|
|
|
self.comment = cleanup(self.comment)
|
2026-10-12 16:33:40 +02:00
|
|
|
if not self.composer:
|
2026-10-25 14:22:43 +01:00
|
|
|
logger.info(f"[Updated] composer found in comment for {self.name}")
|
2026-10-12 16:33:40 +02:00
|
|
|
self.composer = m.group(1).strip()
|
2026-10-16 22:05:09 +02:00
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
def fix_composer_from_path(self):
|
|
|
|
|
if not self.composer:
|
|
|
|
|
rel_path = self.bookPath.relative_to(self.library_root_path)
|
|
|
|
|
m = re.search(
|
|
|
|
|
r"(?:read\s+by:?|"
|
|
|
|
|
r"spoken\s+by:?|"
|
|
|
|
|
r"narrated\s+by:?|"
|
|
|
|
|
r"gesprochen\s+von:?|"
|
|
|
|
|
r"gelesen\s+von)\s+([^\n.,;]+)",
|
|
|
|
|
rel_path,
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
if not m:
|
|
|
|
|
logger.debug(f"No composer found in path for {self.bookPath}")
|
|
|
|
|
return
|
|
|
|
|
self.changed = True
|
|
|
|
|
logger.info(f"[Updated] composer found in Path for {self.bookPath}")
|
|
|
|
|
self.composer = m.group(1).strip()
|
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
|
2026-10-25 14:22:43 +01:00
|
|
|
logger.info(f"[Updated] name found in path for {self.bookPath}")
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
def fix_series(self):
|
|
|
|
|
if self.series:
|
|
|
|
|
logger.trace(f"{self.name} already has series: {self.series}")
|
|
|
|
|
rel_path = self.bookPath.relative_to(self.library_root_path).parts
|
|
|
|
|
patterns = [
|
|
|
|
|
r'^(\d+)\s*-\s*(.+)$', # 02 - Bookname
|
|
|
|
|
r'^(.+?)\s*-\s*(\d+)$', # Bookname - 02
|
|
|
|
|
r'^(.+?)\s*-\s*Book\s+(\d+)$', # Bookname - Book 2
|
|
|
|
|
]
|
|
|
|
|
number=""
|
|
|
|
|
if len(rel_path) <= 2:
|
|
|
|
|
logger.trace(f"No series found for {self.name}")
|
2026-10-12 16:33:40 +02:00
|
|
|
return
|
2026-10-25 14:22:43 +01:00
|
|
|
for pattern in patterns:
|
|
|
|
|
match = re.match(pattern, rel_path[2], re.IGNORECASE)
|
|
|
|
|
if match:
|
|
|
|
|
number = next(g for g in match.groups() if g.isdigit())
|
|
|
|
|
break
|
|
|
|
|
if not number:
|
|
|
|
|
logger.trace(f"No series number found for {self.name}")
|
|
|
|
|
return
|
|
|
|
|
self.series = rel_path[1] + "#" + number
|
|
|
|
|
logger.info(f"[Updated] name found for {self.name}")
|
2026-10-12 16:33:40 +02:00
|
|
|
return
|
|
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
def fix_genre(self):
|
|
|
|
|
self.genre = clean_genre(self.genre)
|
|
|
|
|
|
|
|
|
|
def move(self, desiredPath: Path, desired_name: str, dry_run=False):
|
|
|
|
|
logger.trace(f"Moving {self.bookPath} to {desiredPath}")
|
|
|
|
|
if dry_run:
|
|
|
|
|
return
|
|
|
|
|
desiredPath.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
for file in self.files:
|
|
|
|
|
file.rename(desiredPath / file.name)
|
|
|
|
|
|
|
|
|
|
def generate_dir_from_metadata(self):
|
|
|
|
|
composer =""
|
|
|
|
|
name = self.name
|
|
|
|
|
if self.composer:
|
|
|
|
|
composer = f" (by {self.composer})"
|
|
|
|
|
name = self.name + composer
|
|
|
|
|
desired_path = self.library_root_path / self.artist /name
|
|
|
|
|
if self.series:
|
|
|
|
|
desired_path = self.library_root_path / self.artist / self.series / name
|
|
|
|
|
desired_name = self.artist + " - " + self.name + composer + self.bookPath.ext
|
|
|
|
|
return desired_path, desired_name
|
|
|
|
|
|
|
|
|
|
def create_m4b_from_book(self, 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():
|
2026-10-25 18:33:03 +01:00
|
|
|
logger.info(f"{red}[SKIP]{reset} {output_file_final}")
|
2026-10-12 16:33:40 +02:00
|
|
|
logger.info("Book already present")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
logger.info(f"[CREATE] {output_file_final}")
|
|
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
with tempfile.TemporaryDirectory(dir=self.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)
|
|
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
file_info.append(
|
|
|
|
|
{
|
|
|
|
|
"media": media,
|
|
|
|
|
"chapters": chapters,
|
|
|
|
|
"duration_ms": duration_ms,
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-10-16 17:44:37 +02:00
|
|
|
|
|
|
|
|
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-25 14:22:43 +01:00
|
|
|
|
2026-10-16 22:05:09 +02:00
|
|
|
for key, value in self.metadata.items():
|
2026-10-25 14:22:43 +01:00
|
|
|
mf.write(f"{ffmetadata_escape(key)}={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:
|
2026-10-25 14:22:43 +01:00
|
|
|
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")
|
2026-10-25 14:22:43 +01:00
|
|
|
mf.write(f"title={ffmetadata_escape(title)}\n")
|
2026-10-16 17:44:37 +02:00
|
|
|
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")
|
2026-10-25 14:22:43 +01:00
|
|
|
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 = [
|
2026-10-25 14:22:43 +01:00
|
|
|
"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-25 14:22:43 +01:00
|
|
|
"-map",
|
|
|
|
|
"0:a",
|
2026-10-16 22:05:09 +02:00
|
|
|
# cover image from first media
|
2026-10-25 14:22:43 +01:00
|
|
|
"-map",
|
|
|
|
|
"2:v?",
|
2026-10-16 22:05:09 +02:00
|
|
|
# Metadata and chapters from our generated FFMETADATA file
|
2026-10-25 14:22:43 +01:00
|
|
|
"-map_metadata",
|
|
|
|
|
"1",
|
|
|
|
|
"-map_chapters",
|
|
|
|
|
"1",
|
|
|
|
|
"-c:a",
|
|
|
|
|
"aac",
|
|
|
|
|
"-b:a",
|
|
|
|
|
"128k",
|
|
|
|
|
"-c:v",
|
|
|
|
|
"copy",
|
|
|
|
|
"-disposition:v:0",
|
|
|
|
|
"attached_pic",
|
2026-10-16 17:44:37 +02:00
|
|
|
str(output_file_tmp),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
cmd_default = [
|
2026-10-25 14:22:43 +01:00
|
|
|
"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
|
2026-10-25 14:22:43 +01:00
|
|
|
"-map_metadata",
|
|
|
|
|
"1",
|
|
|
|
|
"-map_chapters",
|
|
|
|
|
"1",
|
|
|
|
|
"-c:a",
|
|
|
|
|
"copy",
|
|
|
|
|
"-c:v",
|
|
|
|
|
"copy",
|
|
|
|
|
"-disposition:v:0",
|
|
|
|
|
"attached_pic",
|
2026-10-16 17:44:37 +02:00
|
|
|
str(output_file_tmp),
|
|
|
|
|
]
|
2026-10-12 16:33:40 +02:00
|
|
|
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
cmd_default,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
2026-10-25 14:22:43 +01:00
|
|
|
check=True,
|
2026-10-12 16:33:40 +02:00
|
|
|
)
|
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(
|
2026-10-25 18:33:03 +01:00
|
|
|
f"{yellow}[WARNING]{reset} - {self.bookPath.name} - {result.returncode} retrying with aac encoder"
|
2026-10-25 14:22:43 +01:00
|
|
|
)
|
2026-10-12 16:33:40 +02:00
|
|
|
result = subprocess.run(
|
|
|
|
|
cmd_backup,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
2026-10-25 14:22:43 +01:00
|
|
|
check=True,
|
2026-10-12 16:33:40 +02:00
|
|
|
)
|
|
|
|
|
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()
|
2026-10-25 14:22:43 +01:00
|
|
|
self.files = [output_file_final]
|
|
|
|
|
self.firstFile = output_file_final
|
2026-10-12 16:33:40 +02:00
|
|
|
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
|
2026-10-25 14:22:43 +01:00
|
|
|
logger.info(
|
2026-10-25 18:33:03 +01:00
|
|
|
f"{green}[SUCCESS]{reset} - {self.bookPath.name} - delete media files"
|
2026-10-25 14:22:43 +01:00
|
|
|
)
|
2026-10-12 16:33:40 +02:00
|
|
|
else:
|
|
|
|
|
logger.error(
|
2026-10-25 18:33:03 +01:00
|
|
|
f"{red}[ERROR]{reset} - {self.bookPath.name} - Code: {result.returncode}, Stderr: {result.stderr}"
|
2026-10-25 14:22:43 +01:00
|
|
|
)
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
def write_metadata(self, keys=None, dry_run=False):
|
|
|
|
|
self.write_metadata_from_fields()
|
|
|
|
|
if keys is None:
|
|
|
|
|
keys = self.metadata.keys()
|
|
|
|
|
values = self.metadata.values()
|
|
|
|
|
else:
|
|
|
|
|
values = [self.metadata[key] for key in keys ]
|
|
|
|
|
for file in self.files:
|
|
|
|
|
write_metadata(file, keys, values,dry_run)
|
|
|
|
|
self.changed = False
|
2026-10-12 16:33:40 +02:00
|
|
|
|
|
|
|
|
class AudiobookLibrary:
|
2026-10-25 14:22:43 +01:00
|
|
|
def __init__(
|
|
|
|
|
self, library_path: Path, sql_database_path: Path, dry_run: bool, logfilename: str = "audiobook_library.log"
|
|
|
|
|
):
|
2026-10-16 22:05:09 +02:00
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
self.sql_database_instance = self.SqlDatabase(sql_database_path)
|
2026-10-16 22:05:09 +02:00
|
|
|
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",
|
2026-10-25 14:22:43 +01:00
|
|
|
handlers=[logging.FileHandler(logfilename), logging.StreamHandler()],
|
2026-10-12 16:33:40 +02:00
|
|
|
)
|
2026-10-25 14:22:43 +01:00
|
|
|
logger.info("Started")
|
|
|
|
|
self.libraryPath: Path = Path(library_path)
|
|
|
|
|
self.audiobooks: list[Audiobook] = []
|
2026-10-12 16:33:40 +02:00
|
|
|
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-25 14:22:43 +01:00
|
|
|
self.size = sum(
|
|
|
|
|
audiobook.size for audiobook in self.audiobooks
|
|
|
|
|
) # size in bytes
|
|
|
|
|
|
|
|
|
|
class SqlDatabase:
|
|
|
|
|
def __init__(self, sql_database_path: Path):
|
|
|
|
|
self.db = None
|
|
|
|
|
self.path = sql_database_path
|
|
|
|
|
self.books = []
|
|
|
|
|
|
|
|
|
|
def connect(self):
|
|
|
|
|
if not self.path.is_file() or self.path.suffix != ".db":
|
|
|
|
|
logger.error(f"{self.path} does not exist")
|
|
|
|
|
return False
|
|
|
|
|
self.db = sqlite3.connect(self.path)
|
|
|
|
|
self.db.row_factory = sqlite3.Row
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def cursor(self):
|
|
|
|
|
return self.db.cursor()
|
|
|
|
|
|
|
|
|
|
def commit(self):
|
|
|
|
|
self.db.commit()
|
|
|
|
|
|
|
|
|
|
def close(self):
|
|
|
|
|
self.db.close()
|
|
|
|
|
|
|
|
|
|
def init(self):
|
|
|
|
|
if not self.connect():
|
|
|
|
|
return
|
|
|
|
|
self.load_database_data()
|
|
|
|
|
|
|
|
|
|
def load_database_data(self):
|
|
|
|
|
books = []
|
|
|
|
|
|
|
|
|
|
for row in self.db.execute("SELECT * FROM books"):
|
|
|
|
|
book = dict(row)
|
|
|
|
|
|
|
|
|
|
# auto-decode JSON columns
|
|
|
|
|
for key, value in book.items():
|
|
|
|
|
if isinstance(value, str) and value[:1] in "[{":
|
|
|
|
|
try:
|
|
|
|
|
book[key] = json.loads(value)
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
books.append(book)
|
|
|
|
|
|
|
|
|
|
self.books = books
|
|
|
|
|
|
|
|
|
|
def update_books(self):
|
|
|
|
|
for book in self.books:
|
|
|
|
|
self._save_book(book)
|
|
|
|
|
|
|
|
|
|
def _save_book(self, book):
|
|
|
|
|
book = book.copy()
|
|
|
|
|
|
|
|
|
|
for key, value in book.items():
|
|
|
|
|
if isinstance(value, (list, dict)):
|
|
|
|
|
book[key] = json.dumps(value, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
columns = [c for c in book if c != "id"]
|
|
|
|
|
|
|
|
|
|
sql = f"""
|
|
|
|
|
UPDATE books
|
|
|
|
|
SET {", ".join(f'"{c}" = ?' for c in columns)}
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
values = [book[c] for c in columns]
|
|
|
|
|
values.append(book["id"])
|
|
|
|
|
|
|
|
|
|
self.db.execute(sql, values)
|
|
|
|
|
|
|
|
|
|
# commit once after all updates
|
|
|
|
|
|
|
|
|
|
def fix_sql_database(self, dry_run):
|
|
|
|
|
|
|
|
|
|
for book in self.sql_database_instance.books:
|
|
|
|
|
genres = book.get("genres")
|
|
|
|
|
if not genres or not any(g.strip() for g in genres):
|
|
|
|
|
continue
|
|
|
|
|
new_genres = []
|
|
|
|
|
for genre in genres:
|
|
|
|
|
genre = clean_genre(genre)
|
|
|
|
|
if "," in genre:
|
|
|
|
|
genre = genre.split(",")
|
|
|
|
|
elif " - " in genre:
|
|
|
|
|
genre = genre.split(" - ")
|
|
|
|
|
elif "Historical Adventure Thriller Mystery" in genre:
|
|
|
|
|
genre = genre.split(" ")
|
|
|
|
|
|
|
|
|
|
if isinstance(genre, str):
|
|
|
|
|
if len(genre) > 50 or (5 > len(genre) > 0):
|
|
|
|
|
genre = ""
|
|
|
|
|
logger.trace(f"Too long or too short: {genre}")
|
|
|
|
|
genre = genre.strip()
|
|
|
|
|
genre = genre.lstrip()
|
|
|
|
|
new_genres.append(genre)
|
|
|
|
|
elif isinstance(genre, list):
|
|
|
|
|
if len(genres) > 1:
|
|
|
|
|
genres = [g for g in genres if g]
|
|
|
|
|
new_genres.extend(genre)
|
|
|
|
|
|
|
|
|
|
new_genres = sorted(set(new_genres))
|
|
|
|
|
list_difference = False
|
|
|
|
|
if len(new_genres) < len(genres):
|
|
|
|
|
list_difference = True
|
|
|
|
|
for old, new in zip(genres, new_genres):
|
|
|
|
|
if old != new:
|
|
|
|
|
list_difference = True
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if not list_difference:
|
|
|
|
|
logger.trace("No difference: Dont Commit to Database")
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
logger.trace(f"{genres} -> {new_genres}")
|
|
|
|
|
book["genres"] = new_genres
|
|
|
|
|
if dry_run:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self.sql_database_instance.update_books()
|
|
|
|
|
self.sql_database_instance.commit()
|
|
|
|
|
self.sql_database_instance.close()
|
|
|
|
|
|
|
|
|
|
|
2026-10-12 16:33:40 +02:00
|
|
|
|
|
|
|
|
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()
|
2026-10-25 14:22:43 +01:00
|
|
|
return [book for book in self.audiobooks if text in book.name.lower()]
|
2026-10-12 16:33:40 +02:00
|
|
|
|
|
|
|
|
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 = []
|
2026-10-29 08:58:10 +01:00
|
|
|
for ext in AUDIO_SUFFIXES:
|
2026-10-25 14:22:43 +01:00
|
|
|
media_files.extend(list(self.libraryPath.rglob(ext)))
|
2026-10-12 16:33:40 +02:00
|
|
|
dirs = {file.parent for file in media_files}
|
|
|
|
|
for directory in dirs:
|
2026-10-25 14:22:43 +01:00
|
|
|
self.add(Audiobook(directory,self.libraryPath))
|
|
|
|
|
for audiobook in self.audiobooks:
|
|
|
|
|
audiobook.init_book()
|
2026-10-12 16:33:40 +02:00
|
|
|
|
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-25 14:22:43 +01:00
|
|
|
def write_metadata(self, keys=None):
|
2026-10-16 22:05:09 +02:00
|
|
|
for audiobook in self.audiobooks:
|
|
|
|
|
if audiobook.changed:
|
|
|
|
|
logger.trace(f"Writing metadata: {audiobook.name}")
|
2026-10-25 14:22:43 +01:00
|
|
|
audiobook.write_metadata(keys,dry_run=self.dry_run)
|
|
|
|
|
|
|
|
|
|
def fix_artists(self):
|
|
|
|
|
for book in self.audiobooks:
|
|
|
|
|
book.fix_artist_from_comment()
|
|
|
|
|
book.fix_artist_from_path()
|
2026-10-16 22:05:09 +02:00
|
|
|
|
|
|
|
|
def fix_composers(self):
|
2026-10-12 16:33:40 +02:00
|
|
|
for book in self.audiobooks:
|
2026-10-25 14:22:43 +01:00
|
|
|
book.fix_composer_from_comment()
|
|
|
|
|
book.fix_composer_from_path()
|
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-25 14:22:43 +01:00
|
|
|
logger.info(f"Remove short comment of {book.name}: {book.comment}")
|
2026-10-12 16:33:40 +02:00
|
|
|
book.comment = ""
|
2026-10-25 14:22:43 +01:00
|
|
|
book.changed = True
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
def fix_genres(self):
|
|
|
|
|
for book in self.audiobooks:
|
|
|
|
|
book.fix_genre()
|
|
|
|
|
|
|
|
|
|
def fix_series(self):
|
|
|
|
|
for book in self.audiobooks:
|
|
|
|
|
book.fix_series()
|
|
|
|
|
|
|
|
|
|
def move_library(self):
|
|
|
|
|
for audiobook in self.audiobooks:
|
|
|
|
|
desired_path,desired_name = audiobook.generate_dir_from_metadata()
|
|
|
|
|
audiobook.move(desired_path,desired_name,dry_run=self.dry_run)
|
|
|
|
|
|
|
|
|
|
def generate_folder_structure(self):
|
|
|
|
|
for audiobook in self.audiobooks:
|
|
|
|
|
desired_path = (
|
|
|
|
|
self.libraryPath / Path(audiobook.artist) / Path(audiobook.name)
|
|
|
|
|
)
|
|
|
|
|
audiobook.move(desired_path)
|
|
|
|
|
|
|
|
|
|
def create_m4b_from_dir(
|
|
|
|
|
self, path: Path = Path(""), name: str = "", keep_originals: 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
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
jobs = [audiobook for audiobook in audiobooks if not audiobook.singleFile]
|
2026-10-16 22:30:57 +02:00
|
|
|
|
|
|
|
|
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,
|
2026-10-25 14:22:43 +01:00
|
|
|
keep_originals,
|
2026-10-16 22:30:57 +02:00
|
|
|
): 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 main():
|
2026-10-25 14:22:43 +01:00
|
|
|
parser = argparse.ArgumentParser(description="Audiobook Directory Processing")
|
|
|
|
|
|
|
|
|
|
parser.add_argument("library", type=Path, help="Library path")
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-16 22:05:09 +02:00
|
|
|
|
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",
|
|
|
|
|
)
|
2026-10-25 14:22:43 +01:00
|
|
|
m4b_group = parser.add_argument_group("M4B creation")
|
|
|
|
|
|
|
|
|
|
m4b_group.add_argument(
|
|
|
|
|
"--create-m4b",
|
|
|
|
|
action="store_true",
|
|
|
|
|
help="Create M4B files from multi-file audiobooks",
|
2026-10-16 22:05:09 +02:00
|
|
|
)
|
2026-10-25 14:22:43 +01:00
|
|
|
|
|
|
|
|
m4b_group.add_argument(
|
|
|
|
|
"--keep-originals",
|
|
|
|
|
action="store_true",
|
|
|
|
|
help="Do not delete source files after M4B creation",
|
2026-10-16 22:05:09 +02:00
|
|
|
)
|
2026-10-25 14:22:43 +01:00
|
|
|
|
|
|
|
|
m4b_group.add_argument(
|
|
|
|
|
"--name-filter",
|
|
|
|
|
metavar="NAME",
|
|
|
|
|
default="",
|
|
|
|
|
help="Process only books whose name contains NAME",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
m4b_group.add_argument(
|
|
|
|
|
"--path-filter",
|
|
|
|
|
metavar="PATH",
|
|
|
|
|
default="",
|
|
|
|
|
help="Process only books whose path contains PATH",
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-16 22:05:09 +02:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--dry-run",
|
|
|
|
|
action="store_true",
|
|
|
|
|
help="Dry run only",
|
|
|
|
|
)
|
2026-10-25 14:22:43 +01:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--sql-database",
|
|
|
|
|
type=Path,
|
|
|
|
|
default=Path("/docker/audiobookshelf/config/absdatabase.sqlite"),
|
|
|
|
|
help="SQL Database Path",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--fix-db",
|
|
|
|
|
action="store_true",
|
|
|
|
|
help="Fix the Database",
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-12 16:33:40 +02:00
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
2026-10-27 19:47:51 +01:00
|
|
|
setup_logging(args.log_level)
|
2026-10-25 14:22:43 +01:00
|
|
|
library_dir = args.library.expanduser()
|
|
|
|
|
library_dir = library_dir.resolve()
|
|
|
|
|
if not library_dir.exists():
|
|
|
|
|
logger.error(f"Library directory does not exist: {library_dir}")
|
|
|
|
|
sys.exit(1)
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
database_path = args.sql_database.expanduser()
|
|
|
|
|
database_path = database_path.resolve()
|
|
|
|
|
if args.fix_db and not database_path.is_file():
|
|
|
|
|
logger.error(f"Database is not a file: {database_path}")
|
|
|
|
|
return
|
2026-10-16 22:05:09 +02:00
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
library = AudiobookLibrary(library_dir, database_path, args.dry_run)
|
|
|
|
|
if args.fix_db:
|
|
|
|
|
library.sql_database_instance.init()
|
|
|
|
|
|
|
|
|
|
library.fix_artists()
|
2026-10-16 22:05:09 +02:00
|
|
|
library.fix_composers()
|
|
|
|
|
library.fix_names()
|
2026-10-25 14:22:43 +01:00
|
|
|
library.fix_series()
|
2026-10-16 22:05:09 +02:00
|
|
|
library.fix_comments()
|
2026-10-25 14:22:43 +01:00
|
|
|
library.fix_genres()
|
|
|
|
|
|
|
|
|
|
library.create_m4b_from_dir(
|
|
|
|
|
name=args.name_filter,
|
|
|
|
|
path=args.path_filter,
|
|
|
|
|
keep_originals=args.keep_originals,
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-16 22:05:09 +02:00
|
|
|
library.write_metadata()
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
if args.fix_db:
|
|
|
|
|
library.fix_sql_database(True)
|
|
|
|
|
|
2026-10-16 17:44:37 +02:00
|
|
|
print("Fin")
|
2026-10-12 16:33:40 +02:00
|
|
|
|
2026-10-25 14:22:43 +01:00
|
|
|
|
2026-10-12 16:33:40 +02:00
|
|
|
if __name__ == "__main__":
|
2026-10-25 14:22:43 +01:00
|
|
|
main()
|