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 json
|
||||||
import ffmpeg
|
import ffmpeg
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
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)
|
# OUTPUT ENFORCEMENT (clean + centralized)
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
REQUIRES_OUTPUT = {
|
REQUIRES_OUTPUT = {
|
||||||
|
"chapters",
|
||||||
"convert-av",
|
"convert-av",
|
||||||
"convert-audio",
|
"convert-audio",
|
||||||
"convert-video",
|
"convert-video",
|
||||||
@@ -181,6 +187,41 @@ def get_vcodec(output_path: str):
|
|||||||
|
|
||||||
return "copy"
|
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 ---------- #
|
# ---------- Commands ---------- #
|
||||||
def cmd_info(args):
|
def cmd_info(args):
|
||||||
"""
|
"""
|
||||||
@@ -582,6 +623,92 @@ def cmd_set_primary_audio(args):
|
|||||||
|
|
||||||
return out
|
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):
|
def cmd_test(args):
|
||||||
"""
|
"""
|
||||||
TEST MODE:
|
TEST MODE:
|
||||||
@@ -896,6 +1023,13 @@ def main():
|
|||||||
p_test = sub.add_parser("test", aliases=["t"], help="Run test function")
|
p_test = sub.add_parser("test", aliases=["t"], help="Run test function")
|
||||||
p_test.set_defaults(func=cmd_test, command="test")
|
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
|
# CONVERT AV
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
@@ -1018,6 +1152,8 @@ def main():
|
|||||||
# ----------------------------
|
# ----------------------------
|
||||||
# COMMAND-SPECIFIC REQUIRED FIELDS
|
# 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":
|
if args.command == "convert-av":
|
||||||
args.preset = ensure(getattr(args, "preset", None),
|
args.preset = ensure(getattr(args, "preset", None),
|
||||||
"Encoding speed/quality preset (default: medium, e.g. ultrafast, fast, slow, veryslow)")
|
"Encoding speed/quality preset (default: medium, e.g. ultrafast, fast, slow, veryslow)")
|
||||||
@@ -1056,32 +1192,35 @@ def main():
|
|||||||
input_path = Path(args.input)
|
input_path = Path(args.input)
|
||||||
# ---- DIRECTORY ----
|
# ---- DIRECTORY ----
|
||||||
if input_path.is_dir():
|
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:
|
if file.suffix.lower() not in VIDEO_EXTS:
|
||||||
continue
|
continue
|
||||||
print(f"Processing File: {file}")
|
print(f"Processing File: {file}")
|
||||||
out_path = file.with_name(f"{file.stem}_{args.command}{file.suffix}")
|
out_path = file.with_name(f"{file.stem}_{args.command}{file.suffix}")
|
||||||
|
|
||||||
args.input = str(file)
|
args.input = str(file)
|
||||||
args.output = str(out_path)
|
args.output = str(out_path)
|
||||||
|
args.file_counter = counter
|
||||||
outputs.append(args.func(args))
|
outputs.append(args.func(args))
|
||||||
|
counter+=1
|
||||||
# ---- SINGLE FILE ----
|
# ---- SINGLE FILE ----
|
||||||
elif input_path.is_file():
|
elif input_path.is_file():
|
||||||
outputs.append(args.func(args))
|
outputs.append(args.func(args))
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Invalid input path: {args.input}")
|
raise ValueError(f"Invalid input path: {args.input}")
|
||||||
|
|
||||||
if outputs is None:
|
if any(item is None for item in outputs) or len(outputs) == 0:
|
||||||
raise ValueError(f"No Output: {outputs}")
|
print("Output is NONE")
|
||||||
|
else:
|
||||||
|
if not isinstance(outputs, list):
|
||||||
|
outputs = [outputs]
|
||||||
|
|
||||||
if not isinstance(outputs, list):
|
if args.command != "test" and outputs is not None:
|
||||||
outputs = [outputs]
|
ffmpeg.run(
|
||||||
|
ffmpeg.merge_outputs(*outputs),
|
||||||
if args.command != "test":
|
overwrite_output=True
|
||||||
ffmpeg.run(
|
)
|
||||||
ffmpeg.merge_outputs(*outputs),
|
|
||||||
overwrite_output=True
|
|
||||||
)
|
|
||||||
|
|
||||||
except ffmpeg.Error as e:
|
except ffmpeg.Error as e:
|
||||||
print("FFmpeg failed.\n")
|
print("FFmpeg failed.\n")
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ dependencies = [
|
|||||||
"markdown-it-py==4.0.0",
|
"markdown-it-py==4.0.0",
|
||||||
"matplotlib==3.10.8",
|
"matplotlib==3.10.8",
|
||||||
"mdurl==0.1.2",
|
"mdurl==0.1.2",
|
||||||
|
"mutagen>=1.47.0",
|
||||||
"numpy==2.4.4",
|
"numpy==2.4.4",
|
||||||
"odfpy>=1.4.1",
|
"odfpy>=1.4.1",
|
||||||
"openai==2.31.0",
|
"openai==2.31.0",
|
||||||
|
|||||||
Reference in new issue
Block a user