add rename audiobook chapters to ffmpeg handling
This commit is contained in:
1 parent
d87d87b5a3
commit
7a4e422bd5
2 files changed
+153
-13
No files matched your search
+152
-13
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user