add rename audiobook chapters to ffmpeg handling

This commit is contained in:
Lucas Faber authored and Lucas Vivohost committed 2026-09-14 09:20:47 +02:00
1 parent d87d87b5a3
commit 7a4e422bd5
2 files changed
+153 -13

No files matched your search

+152 -13
View File
@@ -13,13 +13,19 @@ import sys
import json
import ffmpeg
import re
import subprocess
import tempfile
from pathlib import Path
VIDEO_EXTS = {".mp4", ".mkv", ".avi", ".mov", ".webm"}
from mutagen.easyid3 import EasyID3
from serverlibary import safe_rename,safe_delete
VIDEO_EXTS = {".mp4", ".mkv", ".avi", ".mov", ".webm",".mp3"}
# ----------------------------
# OUTPUT ENFORCEMENT (clean + centralized)
# ----------------------------
REQUIRES_OUTPUT = {
"chapters",
"convert-av",
"convert-audio",
"convert-video",
@@ -181,6 +187,41 @@ def get_vcodec(output_path: str):
return "copy"
def get_chapter_lengths(file_path):
"""
Returns an array of chapter durations (in seconds).
"""
cmd = [
"ffprobe",
"-v", "error",
"-print_format", "json",
"-show_chapters",
file_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(result.stderr)
data = json.loads(result.stdout)
chapters = []
for ch in data.get("chapters", []):
chapters.append({
"start": float(ch["start_time"]),
"end": float(ch["end_time"])
})
return chapters
def natural_sort_key(path: Path):
s = str(path.name) # force string conversion
return [
int(part) if part.isdigit() else part.lower()
for part in re.split(r'(\d+)', s)
]
# ---------- Commands ---------- #
def cmd_info(args):
"""
@@ -582,6 +623,92 @@ def cmd_set_primary_audio(args):
return out
def cmd_apply_chapters(args):
"""
Apply chapters to an M4B using a simple title-only text file.
Chapters file format:
One title per line:
Introduction
Acknowledgment
The War Begins
Behavior:
- Evenly distributes chapters across media duration
- Generates ffmetadata internally
- Applies chapters using ffmpeg-python
"""
# ----------------------------
# read chapter titles
# ----------------------------
with open(args.chapter_file, "r", encoding="utf-8") as f:
titles = [line.strip() for line in f if line.strip()]
if not titles:
raise ValueError("Chapter file is empty")
if Path(args.input).suffix == ".m4b":
# ----------------------------
# build ffmetadata chapters
# ----------------------------
chapters = get_chapter_lengths(args.input)
for idx, title in enumerate(titles):
chapters[idx].update({
"title": title
})
metadata_text = ";FFMETADATA1\n\n"
for ch in chapters:
start_ms = int(float(ch["start"]) * 1000)
end_ms = int(float(ch["end"]) * 1000)
metadata_text += (
"[CHAPTER]\n"
"TIMEBASE=1/1000\n"
f"START={start_ms}\n"
f"END={end_ms}\n"
f"title={ch['title']}\n\n"
)
# ----------------------------
# run ffmpeg
# ----------------------------
with tempfile.NamedTemporaryFile("w+", suffix=".txt", delete=False) as f:
f.write(metadata_text)
meta_path = f.name
cmd = [
"ffmpeg",
"-y",
"-i", args.input,
"-f", "ffmetadata",
"-i", meta_path,
"-map_chapters", "1",
"-map_metadata", "0",
"-c", "copy",
args.output
]
subprocess.run(cmd, check=True)
elif Path(args.input).suffix == ".mp3":
filename = Path(args.input)
print(f"{args.file_counter}:{filename}")
title = titles[args.file_counter]
safe_title = title.replace("/", "-").replace(":", "-")
new_name = f"{args.file_counter + 1:03d} - {safe_title}.mp3"
new_path = filename.with_name(new_name)
print(f"{filename.name} -> {new_name}")
audio = EasyID3(filename)
audio["title"] = title
audio["tracknumber"] = str(args.file_counter+1)
audio.save()
safe_rename(filename, new_path)
return None
def cmd_test(args):
"""
TEST MODE:
@@ -896,6 +1023,13 @@ def main():
p_test = sub.add_parser("test", aliases=["t"], help="Run test function")
p_test.set_defaults(func=cmd_test, command="test")
# ----------------------------
# CHAPTERS
# ----------------------------
p_ch = sub.add_parser("chapters",aliases=["ch"],parents=[base_p],help="Apply chapter metadata from ffmetadata file")
p_ch.add_argument("-cf","--chapter-file",help="Path to ffmetadata chapters file (e.g. chapters.txt)")
p_ch.set_defaults(func=cmd_apply_chapters, command="chapters")
# ----------------------------
# CONVERT AV
# ----------------------------
@@ -1018,6 +1152,8 @@ def main():
# ----------------------------
# COMMAND-SPECIFIC REQUIRED FIELDS
# ----------------------------
if args.command == "chapters":
args.chapter_file = ensure(getattr(args, "chapter_file", None),"File with chapternames")
if args.command == "convert-av":
args.preset = ensure(getattr(args, "preset", None),
"Encoding speed/quality preset (default: medium, e.g. ultrafast, fast, slow, veryslow)")
@@ -1056,32 +1192,35 @@ def main():
input_path = Path(args.input)
# ---- DIRECTORY ----
if input_path.is_dir():
for file in input_path.iterdir():
counter=0
sorted_files = sorted(input_path.iterdir(), key=natural_sort_key)
for file in sorted_files:
if file.suffix.lower() not in VIDEO_EXTS:
continue
print(f"Processing File: {file}")
out_path = file.with_name(f"{file.stem}_{args.command}{file.suffix}")
args.input = str(file)
args.output = str(out_path)
args.file_counter = counter
outputs.append(args.func(args))
counter+=1
# ---- SINGLE FILE ----
elif input_path.is_file():
outputs.append(args.func(args))
else:
raise ValueError(f"Invalid input path: {args.input}")
if outputs is None:
raise ValueError(f"No Output: {outputs}")
if any(item is None for item in outputs) or len(outputs) == 0:
print("Output is NONE")
else:
if not isinstance(outputs, list):
outputs = [outputs]
if not isinstance(outputs, list):
outputs = [outputs]
if args.command != "test":
ffmpeg.run(
ffmpeg.merge_outputs(*outputs),
overwrite_output=True
)
if args.command != "test" and outputs is not None:
ffmpeg.run(
ffmpeg.merge_outputs(*outputs),
overwrite_output=True
)
except ffmpeg.Error as e:
print("FFmpeg failed.\n")
+1
View File
@@ -33,6 +33,7 @@ dependencies = [
"markdown-it-py==4.0.0",
"matplotlib==3.10.8",
"mdurl==0.1.2",
"mutagen>=1.47.0",
"numpy==2.4.4",
"odfpy>=1.4.1",
"openai==2.31.0",