add create m4b to audiobook library

add find_by path method
ffmetadata_escape, get_chapters
add per file chapter support for create m4b
This commit is contained in:
Lucas Vivo committed 2026-10-16 17:44:44 +02:00
1 parent d1e2f8a3e5
commit 35c2104852
1 file changed
+212 -115
+212 -115
View File
@@ -5,6 +5,7 @@ import tempfile
import argparse import argparse
import logging import logging
import re import re
import json
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from collections import defaultdict from collections import defaultdict
@@ -24,6 +25,73 @@ audio_extensions = {
"*.ogg", "*.ogg",
"*.opus" "*.opus"
} }
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)
class Audiobook(): class Audiobook():
def __init__(self, bookPath: Path) -> None: def __init__(self, bookPath: Path) -> None:
self.bookPath = bookPath self.bookPath = bookPath
@@ -35,7 +103,7 @@ class Audiobook():
self.fileConunt = len(self.files) self.fileConunt = len(self.files)
self.singleFile = self.fileConunt > 1 self.singleFile = self.fileConunt > 1
# Metadata # Metadata
self.loadMetadata() self.load_metadata()
logger.info(f"Found {self.bookPath.name}, {self.fileConunt} files") logger.info(f"Found {self.bookPath.name}, {self.fileConunt} files")
def __repr__(self): def __repr__(self):
@@ -51,36 +119,16 @@ class Audiobook():
def get_first_file(self): def get_first_file(self):
return self.files[0] return self.files[0]
def loadMetadata(self): def load_metadata(self):
fields = ["artist", "album", "composer", "comment", "genre"] fields = ["artist", "album", "composer", "comment", "genre"]
metadata = self.get_metadata(self.get_first_file(), fields ) metadata = get_metadata(self.get_first_file(), fields )
self.artist = metadata["artist"] self.artist = metadata["artist"]
self.name = metadata["album"] self.name = metadata["album"]
self.composer = metadata["composer"] self.composer = metadata["composer"]
self.comment = metadata["comment"] self.comment = metadata["comment"]
self.genre = metadata["genre"] self.genre = metadata["genre"]
def get_metadata(self, 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 write_metadata(self, file, fields, values): def write_metadata(self, file, fields, values):
metadata=dict.fromkeys(fields, "") metadata=dict.fromkeys(fields, "")
for field,value in fields,values: for field,value in fields,values:
@@ -98,7 +146,7 @@ class Audiobook():
return return
return return
def fixComposer(self): def fix_composer(self):
if not self.composer: if not self.composer:
m = re.search( m = re.search(
r"(?:read\s+by|spoken\s+by|narrated\s+by|gesprochen\s+von|gelesen\s+von)\s+([^\n.,;]+)", r"(?:read\s+by|spoken\s+by|narrated\s+by|gesprochen\s+von|gelesen\s+von)\s+([^\n.,;]+)",
@@ -114,7 +162,7 @@ class Audiobook():
self.comment = (self.comment[:m.start()] + self.comment[m.end():] ).strip(" .,;\n") self.comment = (self.comment[:m.start()] + self.comment[m.end():] ).strip(" .,;\n")
self.comment = re.sub(r"\s+"," ",self.comment) self.comment = re.sub(r"\s+"," ",self.comment)
def fixName(self): def fix_name(self):
if not self.name: if not self.name:
self.name = self.bookPath.name self.name = self.bookPath.name
logger.info(f"[Updated] name found for {self.bookPath}") logger.info(f"[Updated] name found for {self.bookPath}")
@@ -166,8 +214,7 @@ class Audiobook():
return return
def create_m4b(self, root_path) -> None: def create_m4b_from_book(self, library_root_path, creat_m4b_no_delete: bool=False) -> None:
output_file_final = self.bookPath / f"{self.bookPath.name}.m4b" output_file_final = self.bookPath / f"{self.bookPath.name}.m4b"
if output_file_final.exists(): if output_file_final.exists():
@@ -177,75 +224,137 @@ class Audiobook():
logger.info(f"[CREATE] {output_file_final}") logger.info(f"[CREATE] {output_file_final}")
with (tempfile.TemporaryDirectory(dir=root_path.parent) as tmp): with tempfile.TemporaryDirectory(dir=library_root_path.parent) as tmp:
tmpdir = Path(tmp) tmpdir = Path(tmp)
output_file_tmp = tmpdir / f"{self.bookPath.name}.m4b" output_file_tmp = tmpdir / f"{self.bookPath.name}.m4b"
concat_file = tmpdir / "concat.txt" concat_file = tmpdir / "concat.txt"
metadata_file = tmpdir / "chapters.txt" metadata_file = tmpdir / "chapters.txt"
# 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"
)
current_ms = 0 current_ms = 0
with concat_file.open("w", encoding="utf-8") as cf, \ with (
metadata_file.open("w", encoding="utf-8") as mf: concat_file.open("w", encoding="utf-8") as cf,
metadata_file.open("w", encoding="utf-8") as mf,
):
mf.write(";FFMETADATA1\n") mf.write(";FFMETADATA1\n")
for media in self.files: for info in file_info:
media = info["media"]
chapters = info["chapters"]
duration_ms = info["duration_ms"]
cf.write(f"file '{ffmpeg_escape(media)}'\n") cf.write(f"file '{ffmpeg_escape(media)}'\n")
duration_ms = self.get_duration_ms(media) 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
)
mf.write("[CHAPTER]\n") tags = chapter.get("tags", {})
mf.write("TIMEBASE=1/1000\n") title = tags.get("title", media.stem)
mf.write(f"START={current_ms}\n")
mf.write(f"END={current_ms + duration_ms}\n") mf.write("[CHAPTER]\n")
mf.write(f"title={media.stem}\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"
)
current_ms += duration_ms current_ms += duration_ms
cmd_backup = ["ffmpeg", "-y", "-f", "concat", "-safe", "0",
"-i", str(concat_file), "-i", str(metadata_file),
# cover from first media
"-i", str(self.firstFile),
# audio from concat
"-map", "0:a",
# cover image from first media
"-map", "2:v?",
# copy artist/album/composer/etc from first media file
"-map_metadata", "2",
# copy chapters from ffmetadata
"-map_chapters", "1",
"-c:a", "copy", cmd_backup = [
"-c:a", "aac", "ffmpeg", "-y",
"-b:a", "128k", "-f", "concat", "-safe", "0",
# copy cover image without re-encoding "-i", str(concat_file),
"-c:v", "copy", "-disposition:v:0", "attached_pic", "-i", str(metadata_file),
str(output_file_tmp), "-i", str(self.firstFile),
]
cmd_default = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-map", "0:a",
"-i", str(concat_file), "-i", str(metadata_file), "-map", "2:v?",
# cover from first media
"-i", str(self.firstFile), "-map_metadata", "2",
# audio from concat "-map_chapters", "1",
"-map", "0:a",
# cover image from first media "-c:a", "aac",
"-map", "2:v?", "-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?",
"-map_metadata", "2",
"-map_chapters", "1",
"-c:a", "copy",
"-c:v", "copy",
"-disposition:v:0", "attached_pic",
str(output_file_tmp),
]
# copy artist/album/composer/etc from first media file
"-map_metadata", "2",
# copy chapters from ffmetadata
"-map_chapters", "1",
"-c:a", "copy",
# copy cover image without re-encoding
"-c:v", "copy", "-disposition:v:0", "attached_pic",
str(output_file_tmp),
]
result = subprocess.run( result = subprocess.run(
cmd_default, cmd_default,
capture_output=True, capture_output=True,
text=True, text=True,
) )
logger.info(result.stdout) logger.info(result.stdout)
if result.returncode == 234: if result.returncode == 234:
logger.warning( logger.warning(
f"{YELLOW}[WARNING]{RESET} - {self.bookPath.name} - {result.returncode} retrying with aac encoder") f"{YELLOW}[WARNING]{RESET} - {self.bookPath.name} - {result.returncode} retrying with aac encoder")
@@ -259,6 +368,8 @@ class Audiobook():
if result.returncode == 0: if result.returncode == 0:
output_file_tmp.rename(output_file_final) output_file_tmp.rename(output_file_final)
for media in self.files: for media in self.files:
if creat_m4b_no_delete:
continue
media.unlink() media.unlink()
self.files = Path(output_file_final) self.files = Path(output_file_final)
self.firstFile = Path(output_file_final) self.firstFile = Path(output_file_final)
@@ -270,22 +381,6 @@ class Audiobook():
logger.error( logger.error(
f"{RED}[ERROR]{RESET} - {self.bookPath.name} - Code: {result.returncode}, Stderr: {result.stderr}") f"{RED}[ERROR]{RESET} - {self.bookPath.name} - Code: {result.returncode}, Stderr: {result.stderr}")
def get_duration_ms(self,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)
class AudiobookLibrary: class AudiobookLibrary:
def __init__(self, libraryPath,logfilename: str="audiobook_library.log"): def __init__(self, libraryPath,logfilename: str="audiobook_library.log"):
@@ -341,6 +436,13 @@ class AudiobookLibrary:
def get_by_composer(self, composer): def get_by_composer(self, composer):
return self._by_artist.get(composer, []) return self._by_artist.get(composer, [])
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)
]
def find_name_contains(self, text): def find_name_contains(self, text):
text = text.lower() text = text.lower()
return [ return [
@@ -372,15 +474,15 @@ class AudiobookLibrary:
def loadMetadata(self): def loadMetadata(self):
for audiobook in self.audiobooks: for audiobook in self.audiobooks:
audiobook.loadMetadata() audiobook.load_metadata()
def fixComposers(self): def fixComposers(self):
for book in self.audiobooks: for book in self.audiobooks:
book.fixComposer() book.fix_composer()
def fixNames(self): def fixNames(self):
for book in self.audiobooks: for book in self.audiobooks:
book.fixName() book.fix_name()
def fixComments(self): def fixComments(self):
for book in self.audiobooks: for book in self.audiobooks:
@@ -388,6 +490,13 @@ class AudiobookLibrary:
logger.info(f"Remove short comment: {book.comment} of {book.name}") logger.info(f"Remove short comment: {book.comment} of {book.name}")
book.comment = "" book.comment = ""
def create_m4b_from_dir(self,path: Path="", name: str="",create_m4b_no_delete: bool=False) -> None:
audiobooks = self.find_path_contains(path)
audiobooks.extend(self.find_name_contains(name))
for audiobook in audiobooks:
self.printName(audiobook)
audiobook.create_m4b_from_book( self.libraryPath,create_m4b_no_delete)
def ffmpeg_escape(path: Path) -> str: def ffmpeg_escape(path: Path) -> str:
return str(path.resolve()).replace("'", r"'\''") return str(path.resolve()).replace("'", r"'\''")
@@ -402,6 +511,11 @@ def main():
type=Path, type=Path,
help="Library path" help="Library path"
) )
parser.add_argument(
"--create-m4b-no-delete",
action="store_true",
help="create m4b without deleting"
)
args = parser.parse_args() args = parser.parse_args()
library_dir = args.library library_dir = args.library
@@ -412,29 +526,12 @@ def main():
library.fixNames() library.fixNames()
library.fixComments() library.fixComments()
library.printName(library.get_by_artist("Astrid Lindgren")) #library.printName(library.get_by_artist("Tolkien"))
library.printName(library.find_artist_contains("Astrid Lindgren")) #library.printName(library.find_artist_contains("Tolkien"))
library.printName(library.get_by_name("Pippi Langstrumpf")) #library.printName(library.find_name_contains(""))
library.printName(library.find_name_contains("Pippi Langstrumpf")) #library.printName(library.get_by_path("/home/lucas/Nextcloud/Syncfolder/"))
library.printName(library.get_by_composer("Heike Maklatsch")) library.create_m4b_from_dir(name="Fellowship", create_m4b_no_delete=args.create_m4b_no_delete)
# library.printName(library.get_by_path("/home/lucas/Nextcloud/Audiobooks/Neu/Astrid Lindgren/Junker Nils von Eka (Hoerfest)")) print("Fin")
for audiobook in library.audiobooks:
first_file = audio_files[0]
artist = get_metadata("artist", first_file)
album = get_metadata("album", first_file)
composer = get_metadata("composer", first_file)
comment = get_metadata("comment", first_file)
print("====≠===============================")
print(f"Folder: {library_dir.name}")
print(f"Album: {album}")
print(f"Artist: {artist}")
print(f"Narrator: {composer}")
print(f"Comment: {comment}")
if __name__ == "__main__": if __name__ == "__main__":
main() main()