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:
1 parent
d1e2f8a3e5
commit
35c2104852
1 file changed
+212
-115
@@ -5,6 +5,7 @@ import tempfile
|
||||
import argparse
|
||||
import logging
|
||||
import re
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from collections import defaultdict
|
||||
@@ -24,6 +25,73 @@ audio_extensions = {
|
||||
"*.ogg",
|
||||
"*.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():
|
||||
def __init__(self, bookPath: Path) -> None:
|
||||
self.bookPath = bookPath
|
||||
@@ -35,7 +103,7 @@ class Audiobook():
|
||||
self.fileConunt = len(self.files)
|
||||
self.singleFile = self.fileConunt > 1
|
||||
# Metadata
|
||||
self.loadMetadata()
|
||||
self.load_metadata()
|
||||
logger.info(f"Found {self.bookPath.name}, {self.fileConunt} files")
|
||||
|
||||
def __repr__(self):
|
||||
@@ -51,36 +119,16 @@ class Audiobook():
|
||||
def get_first_file(self):
|
||||
return self.files[0]
|
||||
|
||||
def loadMetadata(self):
|
||||
def load_metadata(self):
|
||||
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.name = metadata["album"]
|
||||
self.composer = metadata["composer"]
|
||||
self.comment = metadata["comment"]
|
||||
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):
|
||||
metadata=dict.fromkeys(fields, "")
|
||||
for field,value in fields,values:
|
||||
@@ -98,7 +146,7 @@ class Audiobook():
|
||||
return
|
||||
return
|
||||
|
||||
def fixComposer(self):
|
||||
def fix_composer(self):
|
||||
if not self.composer:
|
||||
m = re.search(
|
||||
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 = re.sub(r"\s+"," ",self.comment)
|
||||
|
||||
def fixName(self):
|
||||
def fix_name(self):
|
||||
if not self.name:
|
||||
self.name = self.bookPath.name
|
||||
logger.info(f"[Updated] name found for {self.bookPath}")
|
||||
@@ -166,8 +214,7 @@ class Audiobook():
|
||||
|
||||
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"
|
||||
|
||||
if output_file_final.exists():
|
||||
@@ -177,75 +224,137 @@ class Audiobook():
|
||||
|
||||
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)
|
||||
|
||||
output_file_tmp = tmpdir / f"{self.bookPath.name}.m4b"
|
||||
concat_file = tmpdir / "concat.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
|
||||
|
||||
with concat_file.open("w", encoding="utf-8") as cf, \
|
||||
metadata_file.open("w", encoding="utf-8") as mf:
|
||||
with (
|
||||
concat_file.open("w", encoding="utf-8") as cf,
|
||||
metadata_file.open("w", encoding="utf-8") as mf,
|
||||
):
|
||||
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")
|
||||
|
||||
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")
|
||||
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={media.stem}\n")
|
||||
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"
|
||||
)
|
||||
|
||||
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",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
# copy cover image without re-encoding
|
||||
"-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),
|
||||
# cover from first media
|
||||
"-i", str(self.firstFile),
|
||||
# audio from concat
|
||||
"-map", "0:a",
|
||||
# cover image from first media
|
||||
"-map", "2:v?",
|
||||
cmd_backup = [
|
||||
"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", "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?",
|
||||
|
||||
"-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(
|
||||
cmd_default,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
logger.info(result.stdout)
|
||||
|
||||
if result.returncode == 234:
|
||||
logger.warning(
|
||||
f"{YELLOW}[WARNING]{RESET} - {self.bookPath.name} - {result.returncode} retrying with aac encoder")
|
||||
@@ -259,6 +368,8 @@ class Audiobook():
|
||||
if result.returncode == 0:
|
||||
output_file_tmp.rename(output_file_final)
|
||||
for media in self.files:
|
||||
if creat_m4b_no_delete:
|
||||
continue
|
||||
media.unlink()
|
||||
self.files = Path(output_file_final)
|
||||
self.firstFile = Path(output_file_final)
|
||||
@@ -270,22 +381,6 @@ class Audiobook():
|
||||
logger.error(
|
||||
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:
|
||||
def __init__(self, libraryPath,logfilename: str="audiobook_library.log"):
|
||||
@@ -341,6 +436,13 @@ class AudiobookLibrary:
|
||||
def get_by_composer(self, 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):
|
||||
text = text.lower()
|
||||
return [
|
||||
@@ -372,15 +474,15 @@ class AudiobookLibrary:
|
||||
|
||||
def loadMetadata(self):
|
||||
for audiobook in self.audiobooks:
|
||||
audiobook.loadMetadata()
|
||||
audiobook.load_metadata()
|
||||
|
||||
def fixComposers(self):
|
||||
for book in self.audiobooks:
|
||||
book.fixComposer()
|
||||
book.fix_composer()
|
||||
|
||||
def fixNames(self):
|
||||
for book in self.audiobooks:
|
||||
book.fixName()
|
||||
book.fix_name()
|
||||
|
||||
def fixComments(self):
|
||||
for book in self.audiobooks:
|
||||
@@ -388,6 +490,13 @@ class AudiobookLibrary:
|
||||
logger.info(f"Remove short comment: {book.comment} of {book.name}")
|
||||
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:
|
||||
return str(path.resolve()).replace("'", r"'\''")
|
||||
|
||||
@@ -402,6 +511,11 @@ def main():
|
||||
type=Path,
|
||||
help="Library path"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--create-m4b-no-delete",
|
||||
action="store_true",
|
||||
help="create m4b without deleting"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
library_dir = args.library
|
||||
@@ -412,29 +526,12 @@ def main():
|
||||
library.fixNames()
|
||||
library.fixComments()
|
||||
|
||||
library.printName(library.get_by_artist("Astrid Lindgren"))
|
||||
library.printName(library.find_artist_contains("Astrid Lindgren"))
|
||||
library.printName(library.get_by_name("Pippi Langstrumpf"))
|
||||
library.printName(library.find_name_contains("Pippi Langstrumpf"))
|
||||
library.printName(library.get_by_composer("Heike Maklatsch"))
|
||||
# library.printName(library.get_by_path("/home/lucas/Nextcloud/Audiobooks/Neu/Astrid Lindgren/Junker Nils von Eka (Hoerfest)"))
|
||||
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}")
|
||||
#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/"))
|
||||
library.create_m4b_from_dir(name="Fellowship", create_m4b_no_delete=args.create_m4b_no_delete)
|
||||
print("Fin")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user