m4bmaker-filter 1.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- m4bmaker/__init__.py +3 -0
- m4bmaker/__main__.py +351 -0
- m4bmaker/chapters.py +210 -0
- m4bmaker/chapters_file.py +129 -0
- m4bmaker/cli.py +156 -0
- m4bmaker/cover.py +205 -0
- m4bmaker/encoder.py +287 -0
- m4bmaker/errors.py +17 -0
- m4bmaker/filter/__init__.py +7 -0
- m4bmaker/filter/catalog.py +631 -0
- m4bmaker/filter/catalog_seed.py +71 -0
- m4bmaker/filter/catalog_store.py +273 -0
- m4bmaker/filter/chunking.py +325 -0
- m4bmaker/filter/filter_report.py +170 -0
- m4bmaker/filter/interval_planner.py +84 -0
- m4bmaker/filter/job_store.py +306 -0
- m4bmaker/filter/jobs.py +136 -0
- m4bmaker/filter/matcher.py +106 -0
- m4bmaker/filter/media_inspector.py +263 -0
- m4bmaker/filter/model_manager.py +219 -0
- m4bmaker/filter/models.py +369 -0
- m4bmaker/filter/renderer.py +704 -0
- m4bmaker/filter/scan.py +201 -0
- m4bmaker/filter/settings.py +82 -0
- m4bmaker/filter/storage.py +147 -0
- m4bmaker/filter/transcript.py +311 -0
- m4bmaker/filter/transcript_engine.py +380 -0
- m4bmaker/filter/transcript_text.py +42 -0
- m4bmaker/filter/transcription_orchestrator.py +369 -0
- m4bmaker/filter/validator.py +351 -0
- m4bmaker/filter/variation_scan.py +218 -0
- m4bmaker/gui/__init__.py +1 -0
- m4bmaker/gui/__main__.py +3 -0
- m4bmaker/gui/app.py +25 -0
- m4bmaker/gui/filter/__init__.py +8 -0
- m4bmaker/gui/filter/about_dialog.py +251 -0
- m4bmaker/gui/filter/catalog_window.py +978 -0
- m4bmaker/gui/filter/model_manager_window.py +406 -0
- m4bmaker/gui/filter/profile_editor_dialog.py +358 -0
- m4bmaker/gui/filter/settings_window.py +245 -0
- m4bmaker/gui/filter/wizard/__init__.py +12 -0
- m4bmaker/gui/filter/wizard/file_card.py +173 -0
- m4bmaker/gui/filter/wizard/profile_step.py +280 -0
- m4bmaker/gui/filter/wizard/render_step.py +749 -0
- m4bmaker/gui/filter/wizard/review_step.py +1480 -0
- m4bmaker/gui/filter/wizard/scan_step.py +387 -0
- m4bmaker/gui/filter/wizard/source_step.py +644 -0
- m4bmaker/gui/filter/wizard/step_base.py +36 -0
- m4bmaker/gui/filter/wizard/stepper.py +182 -0
- m4bmaker/gui/filter/wizard/transcribe_step.py +743 -0
- m4bmaker/gui/filter/wizard/transcript_step.py +379 -0
- m4bmaker/gui/filter/wizard/transcript_view.py +382 -0
- m4bmaker/gui/filter/wizard/wizard_window.py +528 -0
- m4bmaker/gui/filter/word_variation_dialog.py +261 -0
- m4bmaker/gui/filter/workers.py +499 -0
- m4bmaker/gui/icons.py +111 -0
- m4bmaker/gui/job.py +85 -0
- m4bmaker/gui/player.py +342 -0
- m4bmaker/gui/prefs.py +70 -0
- m4bmaker/gui/queue_manager.py +307 -0
- m4bmaker/gui/queue_window.py +259 -0
- m4bmaker/gui/styles.py +1434 -0
- m4bmaker/gui/updater.py +111 -0
- m4bmaker/gui/widgets.py +985 -0
- m4bmaker/gui/window.py +2008 -0
- m4bmaker/gui/worker.py +362 -0
- m4bmaker/m4b_editor.py +192 -0
- m4bmaker/metadata.py +151 -0
- m4bmaker/models.py +56 -0
- m4bmaker/pipeline.py +291 -0
- m4bmaker/preflight.py +282 -0
- m4bmaker/repair.py +238 -0
- m4bmaker/scanner.py +45 -0
- m4bmaker/utils.py +262 -0
- m4bmaker_filter-1.1.1.dist-info/METADATA +222 -0
- m4bmaker_filter-1.1.1.dist-info/RECORD +80 -0
- m4bmaker_filter-1.1.1.dist-info/WHEEL +5 -0
- m4bmaker_filter-1.1.1.dist-info/entry_points.txt +3 -0
- m4bmaker_filter-1.1.1.dist-info/licenses/LICENSE +682 -0
- m4bmaker_filter-1.1.1.dist-info/top_level.txt +1 -0
m4bmaker/__init__.py
ADDED
m4bmaker/__main__.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""m4bmaker — entry point: wire all modules and drive the conversion pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from argparse import Namespace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from m4bmaker import __version__
|
|
11
|
+
from m4bmaker.chapters import format_chapter_table
|
|
12
|
+
from m4bmaker.chapters_file import load_chapters_file
|
|
13
|
+
from m4bmaker.cli import parse_args
|
|
14
|
+
from m4bmaker.cover import download_cover, find_cover, is_url
|
|
15
|
+
from m4bmaker.encoder import _render_bar
|
|
16
|
+
from m4bmaker.errors import EncodeCancelled, M4BError
|
|
17
|
+
from m4bmaker.metadata import extract_metadata, prompt_missing
|
|
18
|
+
from m4bmaker.models import BookMetadata, Chapter
|
|
19
|
+
from m4bmaker.pipeline import load_audiobook, run_pipeline
|
|
20
|
+
from m4bmaker.preflight import format_preflight_report, run_preflight
|
|
21
|
+
from m4bmaker.repair import apply_repair, format_repair_report, run_repair
|
|
22
|
+
from m4bmaker.utils import find_ffmpeg, find_ffprobe, log, safe_input
|
|
23
|
+
from m4bmaker.utils import sanitize_filename_component as _safe
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _output_path(base_dir: Path, meta: dict[str, str], flat: bool = False) -> Path:
|
|
27
|
+
"""Derive the output .m4b path from metadata.
|
|
28
|
+
|
|
29
|
+
Default (organized): ``base_dir/Author/Title/Author - Title.m4b``
|
|
30
|
+
With *flat*: ``base_dir/Author - Title.m4b``
|
|
31
|
+
"""
|
|
32
|
+
title = meta.get("title", "").strip()
|
|
33
|
+
author = meta.get("author", "").strip()
|
|
34
|
+
|
|
35
|
+
if title and author:
|
|
36
|
+
stem = f"{_safe(author)} - {_safe(title)}"
|
|
37
|
+
if flat:
|
|
38
|
+
return base_dir / f"{stem}.m4b"
|
|
39
|
+
return base_dir / _safe(author) / _safe(title) / f"{stem}.m4b"
|
|
40
|
+
elif title:
|
|
41
|
+
stem = _safe(title)
|
|
42
|
+
if flat:
|
|
43
|
+
return base_dir / f"{stem}.m4b"
|
|
44
|
+
return base_dir / stem / f"{stem}.m4b"
|
|
45
|
+
else:
|
|
46
|
+
return base_dir / "audiobook.m4b"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _confirm_output(proposed: Path, interactive: bool) -> Path:
|
|
50
|
+
"""Confirm or override the output path interactively."""
|
|
51
|
+
if not interactive:
|
|
52
|
+
return proposed
|
|
53
|
+
value = safe_input(f"Output [{proposed}]: ").strip()
|
|
54
|
+
if not value:
|
|
55
|
+
return proposed
|
|
56
|
+
return Path(value).expanduser().resolve()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _print_chapter_table(chapters: list[Chapter]) -> None:
|
|
60
|
+
"""Print the chapter preview table to stdout (TTY + interactive only)."""
|
|
61
|
+
print(f"\n Chapters ({len(chapters)})")
|
|
62
|
+
print(format_chapter_table(chapters))
|
|
63
|
+
print()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _edit_chapters_inline(chapters: list[Chapter]) -> list[Chapter]:
|
|
67
|
+
"""Interactively edit chapter titles one by one.
|
|
68
|
+
|
|
69
|
+
For each chapter the current title is shown as a prefill; pressing Enter
|
|
70
|
+
keeps it, typing a new value replaces it.
|
|
71
|
+
"""
|
|
72
|
+
edited: list[Chapter] = []
|
|
73
|
+
for ch in chapters:
|
|
74
|
+
value = safe_input(f" Chapter {ch.index} [{ch.title}]: ").strip()
|
|
75
|
+
edited.append(
|
|
76
|
+
Chapter(
|
|
77
|
+
index=ch.index,
|
|
78
|
+
start_time=ch.start_time,
|
|
79
|
+
title=value if value else ch.title,
|
|
80
|
+
source_file=ch.source_file,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return edited
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _hints_from_dirname(directory: Path) -> dict[str, str]:
|
|
87
|
+
"""Extract title/author hints from an 'Author - Title' directory name."""
|
|
88
|
+
name = directory.name
|
|
89
|
+
if " - " in name:
|
|
90
|
+
author, _, title = name.partition(" - ")
|
|
91
|
+
return {"author": author.strip(), "title": title.strip()}
|
|
92
|
+
return {"title": name.strip()}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _probe_progress(i: int, n: int, name: str) -> None:
|
|
96
|
+
"""Display a progress bar while probing file durations."""
|
|
97
|
+
if not sys.stdout.isatty():
|
|
98
|
+
log(f" [{i}/{n}] {name}")
|
|
99
|
+
return
|
|
100
|
+
bar = _render_bar(i / n, width=30)
|
|
101
|
+
max_name = 35
|
|
102
|
+
disp = (name[: max_name - 1] + "\u2026") if len(name) > max_name else name
|
|
103
|
+
sys.stdout.write(f"\r Probing {bar} {i}/{n} {disp:<{max_name}}\033[K")
|
|
104
|
+
sys.stdout.flush()
|
|
105
|
+
if i == n:
|
|
106
|
+
sys.stdout.write("\n")
|
|
107
|
+
sys.stdout.flush()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _resolve_cover(
|
|
111
|
+
cover_arg: str | None,
|
|
112
|
+
directory: Path,
|
|
113
|
+
tmp_dir: Path,
|
|
114
|
+
interactive: bool,
|
|
115
|
+
) -> tuple[Path | None, bool]:
|
|
116
|
+
"""Resolve the cover image from CLI argument, directory scan, or user prompt.
|
|
117
|
+
|
|
118
|
+
Returns ``(cover, user_specified)`` where *user_specified* is ``True`` when
|
|
119
|
+
the user explicitly provided a URL or path (via CLI or interactive prompt),
|
|
120
|
+
indicating that no further confirmation is needed.
|
|
121
|
+
|
|
122
|
+
Resolution order:
|
|
123
|
+
1. *cover_arg* provided — URL: download (retry interactively on failure);
|
|
124
|
+
local path: passed directly to :func:`find_cover`.
|
|
125
|
+
2. Auto-detect a single image file in *directory*.
|
|
126
|
+
3. If *interactive*, prompt the user for a URL or local path.
|
|
127
|
+
"""
|
|
128
|
+
if cover_arg is not None:
|
|
129
|
+
if is_url(cover_arg):
|
|
130
|
+
return _fetch_cover_url(cover_arg, tmp_dir, interactive), True
|
|
131
|
+
return find_cover(directory, cli_override=Path(cover_arg).expanduser()), True
|
|
132
|
+
|
|
133
|
+
cover = find_cover(directory)
|
|
134
|
+
if cover is not None:
|
|
135
|
+
return cover, False # auto-detected — needs confirmation
|
|
136
|
+
|
|
137
|
+
if interactive:
|
|
138
|
+
return _prompt_cover(tmp_dir), True # user explicitly typed URL/path
|
|
139
|
+
return None, False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _fetch_cover_url(url: str, tmp_dir: Path, interactive: bool) -> Path | None:
|
|
143
|
+
"""Download a cover image URL.
|
|
144
|
+
|
|
145
|
+
On failure, prompt for retry if *interactive*; otherwise raise.
|
|
146
|
+
"""
|
|
147
|
+
pending: str | None = url
|
|
148
|
+
while True:
|
|
149
|
+
if pending is not None:
|
|
150
|
+
try:
|
|
151
|
+
return download_cover(pending, tmp_dir)
|
|
152
|
+
except Exception as exc:
|
|
153
|
+
log(f"Cover download failed: {exc}")
|
|
154
|
+
if not interactive:
|
|
155
|
+
raise M4BError("Error: cover download failed.")
|
|
156
|
+
source = safe_input(
|
|
157
|
+
"Enter a different URL or local path (or press Enter to skip): "
|
|
158
|
+
).strip()
|
|
159
|
+
if not source:
|
|
160
|
+
return None
|
|
161
|
+
if is_url(source):
|
|
162
|
+
pending = source
|
|
163
|
+
else:
|
|
164
|
+
path = Path(source).expanduser()
|
|
165
|
+
if path.is_file():
|
|
166
|
+
return path
|
|
167
|
+
log(f"File not found: {path} — please try again.")
|
|
168
|
+
pending = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _prompt_cover(tmp_dir: Path) -> Path | None:
|
|
172
|
+
"""Interactively prompt for a cover image URL or local path.
|
|
173
|
+
|
|
174
|
+
Loops until a valid source is provided or the user presses Enter to skip.
|
|
175
|
+
"""
|
|
176
|
+
while True:
|
|
177
|
+
source = safe_input(
|
|
178
|
+
"Enter URL or local path for cover art (or press Enter to skip): "
|
|
179
|
+
).strip()
|
|
180
|
+
if not source:
|
|
181
|
+
return None
|
|
182
|
+
try:
|
|
183
|
+
if is_url(source):
|
|
184
|
+
return download_cover(source, tmp_dir)
|
|
185
|
+
path = Path(source).expanduser()
|
|
186
|
+
if path.is_file():
|
|
187
|
+
return path
|
|
188
|
+
log(f"File not found: {path} — please try again.")
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
log(f"Cover error: {exc} — please try again.")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _confirm_cover(
|
|
194
|
+
cover: Path | None,
|
|
195
|
+
tmp_dir: Path,
|
|
196
|
+
interactive: bool,
|
|
197
|
+
) -> Path | None:
|
|
198
|
+
"""Confirm or replace the selected cover image interactively."""
|
|
199
|
+
if not interactive:
|
|
200
|
+
return cover
|
|
201
|
+
while True:
|
|
202
|
+
display = str(cover) if cover else "none"
|
|
203
|
+
value = safe_input(f"Cover image [{display}]: ").strip()
|
|
204
|
+
if not value:
|
|
205
|
+
return cover # confirmed as-is
|
|
206
|
+
if value.lower() in ("none", "skip"):
|
|
207
|
+
return None
|
|
208
|
+
try:
|
|
209
|
+
if is_url(value):
|
|
210
|
+
return download_cover(value, tmp_dir)
|
|
211
|
+
path = Path(value).expanduser()
|
|
212
|
+
if path.is_file():
|
|
213
|
+
return path
|
|
214
|
+
log(f"File not found: {path} — please try again.")
|
|
215
|
+
except Exception as exc:
|
|
216
|
+
log(f"Cover error: {exc} — please try again.")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _run(args: Namespace) -> None:
|
|
220
|
+
"""Drive the conversion pipeline. Raises M4BError/EncodeCancelled on failure."""
|
|
221
|
+
directory: Path = args.directory.resolve()
|
|
222
|
+
interactive = not args.no_prompt
|
|
223
|
+
|
|
224
|
+
log(f"m4bmaker {__version__}")
|
|
225
|
+
log(f"Working directory: {directory}")
|
|
226
|
+
|
|
227
|
+
# 1. Detect tool locations (raises M4BError if missing).
|
|
228
|
+
ffmpeg = find_ffmpeg()
|
|
229
|
+
ffprobe = find_ffprobe()
|
|
230
|
+
|
|
231
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
232
|
+
tmp_dir = Path(tmp)
|
|
233
|
+
|
|
234
|
+
# 2. Scan the directory first, so a bad path surfaces the scanner's
|
|
235
|
+
# clean error instead of find_cover's raw FileNotFoundError.
|
|
236
|
+
log("Scanning audio files…")
|
|
237
|
+
book = load_audiobook(directory, ffprobe, progress_fn=_probe_progress)
|
|
238
|
+
log(f"Found {len(book.files)} audio file(s)")
|
|
239
|
+
|
|
240
|
+
# 2b. Locate cover image (URL download, auto-detect, or interactive prompt).
|
|
241
|
+
log("Looking for cover art...")
|
|
242
|
+
cover, cover_user_specified = _resolve_cover(
|
|
243
|
+
args.cover, directory, tmp_dir, interactive
|
|
244
|
+
)
|
|
245
|
+
if cover:
|
|
246
|
+
log(f"Cover art: {cover.name}")
|
|
247
|
+
else:
|
|
248
|
+
log("No cover art found — skipping")
|
|
249
|
+
|
|
250
|
+
# 2c. Confirm or replace cover interactively.
|
|
251
|
+
if not cover_user_specified:
|
|
252
|
+
cover = _confirm_cover(cover, tmp_dir, interactive)
|
|
253
|
+
|
|
254
|
+
# 3b. Audio preflight analysis.
|
|
255
|
+
log("Analysing audio formats…")
|
|
256
|
+
analysis = run_preflight(book.files, ffprobe)
|
|
257
|
+
print(format_preflight_report(analysis))
|
|
258
|
+
|
|
259
|
+
# 3c. Repair damaged / non-standard input files. The result is
|
|
260
|
+
# passed into run_pipeline below so repair does not run a second
|
|
261
|
+
# time inside the pipeline.
|
|
262
|
+
log("Checking for damaged audio files…")
|
|
263
|
+
repair_result = run_repair(book.files, tmp_dir, ffmpeg, ffprobe)
|
|
264
|
+
if repair_result.needed_repair:
|
|
265
|
+
print(format_repair_report(repair_result))
|
|
266
|
+
book.files = apply_repair(book.files, repair_result)
|
|
267
|
+
|
|
268
|
+
# 3d. Override chapters from --chapters-file if supplied.
|
|
269
|
+
if args.chapters_file:
|
|
270
|
+
book.chapters = load_chapters_file(args.chapters_file)
|
|
271
|
+
log(
|
|
272
|
+
f"Loaded {len(book.chapters)} chapter(s) from {args.chapters_file.name}"
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
log(f"Generated {len(book.chapters)} chapter(s)")
|
|
276
|
+
|
|
277
|
+
# Override cover with resolved value (interactive or CLI-supplied).
|
|
278
|
+
book.cover = cover
|
|
279
|
+
|
|
280
|
+
# 4. Complete metadata interactively.
|
|
281
|
+
log("Reading metadata...")
|
|
282
|
+
raw_meta = extract_metadata(book.files[0])
|
|
283
|
+
hints = _hints_from_dirname(directory)
|
|
284
|
+
filled = prompt_missing(raw_meta, args, hints=hints)
|
|
285
|
+
book.metadata = BookMetadata(
|
|
286
|
+
title=filled["title"],
|
|
287
|
+
author=filled["author"],
|
|
288
|
+
narrator=filled["narrator"],
|
|
289
|
+
genre=filled.get("genre", ""),
|
|
290
|
+
)
|
|
291
|
+
log(
|
|
292
|
+
f"Title: {book.metadata.title} | Author: {book.metadata.author} "
|
|
293
|
+
f"| Narrator: {book.metadata.narrator}"
|
|
294
|
+
+ (f" | Genre: {book.metadata.genre}" if book.metadata.genre else "")
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# 5. Resolve output path.
|
|
298
|
+
meta_dict = {
|
|
299
|
+
"title": book.metadata.title,
|
|
300
|
+
"author": book.metadata.author,
|
|
301
|
+
}
|
|
302
|
+
if args.output:
|
|
303
|
+
output = args.output.resolve()
|
|
304
|
+
else:
|
|
305
|
+
base_dir = args.output_dir.resolve() if args.output_dir else directory
|
|
306
|
+
output = _output_path(base_dir, meta_dict, flat=args.flat)
|
|
307
|
+
output = _confirm_output(output, interactive)
|
|
308
|
+
log(f"Output: {output}")
|
|
309
|
+
|
|
310
|
+
# 6. Chapter preview + optional inline editing (interactive only).
|
|
311
|
+
if interactive and sys.stdout.isatty():
|
|
312
|
+
_print_chapter_table(book.chapters)
|
|
313
|
+
answer = safe_input(" Edit chapter titles? [y/N]: ").strip().lower()
|
|
314
|
+
if answer == "y":
|
|
315
|
+
book.chapters = _edit_chapters_inline(book.chapters)
|
|
316
|
+
|
|
317
|
+
# 7. Encode via shared pipeline.
|
|
318
|
+
channels = 2 if args.stereo else 1
|
|
319
|
+
log(
|
|
320
|
+
f"Encoding audiobook "
|
|
321
|
+
f"(codec=aac, bitrate={args.bitrate}, "
|
|
322
|
+
f"channels={'stereo' if channels == 2 else 'mono'})..."
|
|
323
|
+
)
|
|
324
|
+
run_pipeline(
|
|
325
|
+
book=book,
|
|
326
|
+
output_path=output,
|
|
327
|
+
bitrate=args.bitrate,
|
|
328
|
+
stereo=args.stereo,
|
|
329
|
+
ffmpeg=ffmpeg,
|
|
330
|
+
ffprobe=ffprobe,
|
|
331
|
+
repair_result=repair_result,
|
|
332
|
+
)
|
|
333
|
+
log(f"Done. Created: {output}")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main() -> None:
|
|
337
|
+
args = parse_args()
|
|
338
|
+
try:
|
|
339
|
+
_run(args)
|
|
340
|
+
except EncodeCancelled:
|
|
341
|
+
log("Cancelled.")
|
|
342
|
+
sys.exit(130)
|
|
343
|
+
except KeyboardInterrupt:
|
|
344
|
+
log("Cancelled.")
|
|
345
|
+
sys.exit(130)
|
|
346
|
+
except M4BError as exc:
|
|
347
|
+
sys.exit(str(exc))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
if __name__ == "__main__": # pragma: no cover
|
|
351
|
+
main()
|
m4bmaker/chapters.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""ffprobe-based duration probing and FFMETADATA chapter file generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from m4bmaker.errors import M4BError
|
|
12
|
+
from m4bmaker.models import BookMetadata, Chapter
|
|
13
|
+
from m4bmaker.utils import subprocess_flags
|
|
14
|
+
|
|
15
|
+
# Matches leading digits optionally followed by separators (space/dash/dot/underscore).
|
|
16
|
+
# Examples stripped: "01 - ", "1.", "02_", "003 "
|
|
17
|
+
_CHAPTER_TITLE_RE = re.compile(r"^\d+[\s.\-_]*")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _strip_chapter_prefix(stem: str) -> str:
|
|
21
|
+
"""Remove leading numeric prefix and separators from a filename stem."""
|
|
22
|
+
cleaned = _CHAPTER_TITLE_RE.sub("", stem).strip()
|
|
23
|
+
return cleaned if cleaned else stem
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _probe_file(file: Path, ffprobe: str) -> tuple[float, str | None]:
|
|
27
|
+
"""Run ffprobe on *file* and return (duration_seconds, title_tag_or_None)."""
|
|
28
|
+
cmd = [
|
|
29
|
+
ffprobe,
|
|
30
|
+
"-v",
|
|
31
|
+
"error", # "-v quiet" would guarantee an empty stderr on failure
|
|
32
|
+
"-print_format",
|
|
33
|
+
"json",
|
|
34
|
+
"-show_format",
|
|
35
|
+
str(file),
|
|
36
|
+
]
|
|
37
|
+
try:
|
|
38
|
+
result = subprocess.run(
|
|
39
|
+
cmd,
|
|
40
|
+
capture_output=True,
|
|
41
|
+
encoding="utf-8",
|
|
42
|
+
check=True,
|
|
43
|
+
timeout=120,
|
|
44
|
+
**subprocess_flags(),
|
|
45
|
+
)
|
|
46
|
+
except subprocess.CalledProcessError as exc:
|
|
47
|
+
raise M4BError(
|
|
48
|
+
f"Error: ffprobe failed for '{file}'.\n"
|
|
49
|
+
f"The file may be corrupt or in an unsupported format.\n"
|
|
50
|
+
f"ffprobe stderr: {exc.stderr.strip()}"
|
|
51
|
+
) from exc
|
|
52
|
+
except subprocess.TimeoutExpired as exc:
|
|
53
|
+
raise M4BError(f"Error: ffprobe timed out probing '{file}'.") from exc
|
|
54
|
+
except (FileNotFoundError, OSError) as exc:
|
|
55
|
+
raise M4BError(f"Error: ffprobe executable not found at '{ffprobe}'.") from exc
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
data = json.loads(result.stdout)
|
|
59
|
+
duration = float(data["format"]["duration"])
|
|
60
|
+
except (KeyError, ValueError, json.JSONDecodeError) as exc:
|
|
61
|
+
raise M4BError(
|
|
62
|
+
f"Error: could not parse ffprobe output for '{file}': {exc}"
|
|
63
|
+
) from exc
|
|
64
|
+
|
|
65
|
+
title = data.get("format", {}).get("tags", {}).get("title") or None
|
|
66
|
+
return duration, title
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_duration(file: Path, ffprobe: str) -> float:
|
|
70
|
+
"""Return the duration of *file* in seconds using ffprobe JSON output."""
|
|
71
|
+
return _probe_file(file, ffprobe)[0]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_chapters(
|
|
75
|
+
files: list[Path],
|
|
76
|
+
ffprobe: str,
|
|
77
|
+
progress_fn: Callable[[int, int, str], None] | None = None,
|
|
78
|
+
) -> list[Chapter]:
|
|
79
|
+
"""Build a Chapter list from *files* using ffprobe for durations.
|
|
80
|
+
|
|
81
|
+
Chapters are indexed sequentially starting at 1, with *start_time* in
|
|
82
|
+
seconds (float). *source_file* is set to the corresponding input path.
|
|
83
|
+
"""
|
|
84
|
+
chapters: list[Chapter] = []
|
|
85
|
+
cursor_s: float = 0.0
|
|
86
|
+
total = len(files)
|
|
87
|
+
|
|
88
|
+
for i, path in enumerate(files, 1):
|
|
89
|
+
if progress_fn is not None:
|
|
90
|
+
progress_fn(i, total, path.name)
|
|
91
|
+
duration_sec, tag_title = _probe_file(path, ffprobe)
|
|
92
|
+
title = tag_title if tag_title else _strip_chapter_prefix(path.stem)
|
|
93
|
+
chapters.append(
|
|
94
|
+
Chapter(
|
|
95
|
+
index=i,
|
|
96
|
+
start_time=cursor_s,
|
|
97
|
+
title=title,
|
|
98
|
+
source_file=path,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
cursor_s += duration_sec
|
|
102
|
+
|
|
103
|
+
return chapters
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _format_time(seconds: float) -> str:
|
|
107
|
+
"""Format a duration in seconds as H:MM:SS."""
|
|
108
|
+
s = int(seconds)
|
|
109
|
+
h, rem = divmod(s, 3600)
|
|
110
|
+
m, sec = divmod(rem, 60)
|
|
111
|
+
return f"{h}:{m:02d}:{sec:02d}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def format_chapter_table(chapters: list[Chapter]) -> str:
|
|
115
|
+
"""Return a Unicode box-drawing table of chapters as a multi-line string."""
|
|
116
|
+
if not chapters:
|
|
117
|
+
return " (no chapters)"
|
|
118
|
+
|
|
119
|
+
title_width = min(max(len(c.title) for c in chapters), 40)
|
|
120
|
+
title_width = max(title_width, 5) # minimum column width
|
|
121
|
+
|
|
122
|
+
num_w = max(len(str(len(chapters))), 1)
|
|
123
|
+
time_w = 8 # "H:MM:SS" is at most 8 chars (e.g. "9:59:59")
|
|
124
|
+
|
|
125
|
+
_h = "\u2500"
|
|
126
|
+
_cols = [num_w, time_w, title_width]
|
|
127
|
+
|
|
128
|
+
def _hline(left: str, join: str, right: str) -> str:
|
|
129
|
+
return " " + left + join.join(_h * (w + 2) for w in _cols) + right
|
|
130
|
+
|
|
131
|
+
top = _hline("\u250c", "\u252c", "\u2510")
|
|
132
|
+
sep = _hline("\u251c", "\u253c", "\u2524")
|
|
133
|
+
bot = _hline("\u2514", "\u2534", "\u2518")
|
|
134
|
+
hdr = (
|
|
135
|
+
f" \u2502 {'#':>{num_w}} \u2502"
|
|
136
|
+
f" {'Start':<{time_w}} \u2502"
|
|
137
|
+
f" {'Title':<{title_width}} \u2502"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
rows = [top, hdr, sep]
|
|
141
|
+
for ch in chapters:
|
|
142
|
+
start = _format_time(ch.start_time)
|
|
143
|
+
title = ch.title
|
|
144
|
+
if len(title) > title_width:
|
|
145
|
+
title = title[: title_width - 1] + "\u2026"
|
|
146
|
+
rows.append(
|
|
147
|
+
f" \u2502 {ch.index:>{num_w}} \u2502"
|
|
148
|
+
f" {start:<{time_w}} \u2502"
|
|
149
|
+
f" {title:<{title_width}} \u2502"
|
|
150
|
+
)
|
|
151
|
+
rows.append(bot)
|
|
152
|
+
return "\n".join(rows)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _escape_ffmeta(value: str) -> str:
|
|
156
|
+
"""Escape a value for use on the right-hand side of an FFMETADATA1 line.
|
|
157
|
+
|
|
158
|
+
Per ffmpeg's ffmetadata spec, the characters ``=``, ``;``, ``#``, ``\\``,
|
|
159
|
+
and newline are special and must be backslash-escaped. Backslash is
|
|
160
|
+
escaped first so its escape sequence isn't itself re-escaped.
|
|
161
|
+
"""
|
|
162
|
+
value = value.replace("\\", "\\\\")
|
|
163
|
+
value = value.replace("=", "\\=")
|
|
164
|
+
value = value.replace(";", "\\;")
|
|
165
|
+
value = value.replace("#", "\\#")
|
|
166
|
+
value = value.replace("\n", "\\\n")
|
|
167
|
+
return value
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def write_ffmetadata(
|
|
171
|
+
chapters: list[Chapter],
|
|
172
|
+
meta: BookMetadata,
|
|
173
|
+
dest: Path,
|
|
174
|
+
total_duration_s: float,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Write an FFMETADATA1 file with global tags and chapter markers to *dest*.
|
|
177
|
+
|
|
178
|
+
Chapter END timestamps are derived from the next chapter's *start_time*;
|
|
179
|
+
the final chapter ends at *total_duration_s*. Values are backslash-escaped
|
|
180
|
+
per the FFMETADATA1 spec (see :func:`_escape_ffmeta`).
|
|
181
|
+
"""
|
|
182
|
+
lines: list[str] = [";FFMETADATA1\n"]
|
|
183
|
+
|
|
184
|
+
# Global metadata tags
|
|
185
|
+
if meta.title:
|
|
186
|
+
lines.append(f"title={_escape_ffmeta(meta.title)}\n")
|
|
187
|
+
if meta.author:
|
|
188
|
+
lines.append(f"artist={_escape_ffmeta(meta.author)}\n")
|
|
189
|
+
if meta.narrator:
|
|
190
|
+
lines.append(f"composer={_escape_ffmeta(meta.narrator)}\n")
|
|
191
|
+
if meta.genre:
|
|
192
|
+
lines.append(f"genre={_escape_ffmeta(meta.genre)}\n")
|
|
193
|
+
|
|
194
|
+
lines.append("\n")
|
|
195
|
+
|
|
196
|
+
for i, chapter in enumerate(chapters):
|
|
197
|
+
if i + 1 < len(chapters):
|
|
198
|
+
end_ms = int(chapters[i + 1].start_time * 1000)
|
|
199
|
+
else:
|
|
200
|
+
end_ms = int(total_duration_s * 1000)
|
|
201
|
+
start_ms = int(chapter.start_time * 1000)
|
|
202
|
+
|
|
203
|
+
lines.append("[CHAPTER]\n")
|
|
204
|
+
lines.append("TIMEBASE=1/1000\n")
|
|
205
|
+
lines.append(f"START={start_ms}\n")
|
|
206
|
+
lines.append(f"END={end_ms}\n")
|
|
207
|
+
lines.append(f"title={_escape_ffmeta(chapter.title)}\n")
|
|
208
|
+
lines.append("\n")
|
|
209
|
+
|
|
210
|
+
dest.write_text("".join(lines), encoding="utf-8")
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Parser for external chapter list files (--chapters-file).
|
|
2
|
+
|
|
3
|
+
File format
|
|
4
|
+
-----------
|
|
5
|
+
Each non-blank, non-comment line must be::
|
|
6
|
+
|
|
7
|
+
TIMESTAMP TITLE
|
|
8
|
+
|
|
9
|
+
where *TIMESTAMP* is either ``MM:SS`` or ``H:MM:SS`` and *TITLE* is any
|
|
10
|
+
non-empty string. Lines beginning with ``#`` are treated as comments and
|
|
11
|
+
ignored.
|
|
12
|
+
|
|
13
|
+
Examples::
|
|
14
|
+
|
|
15
|
+
00:00 Opening Engagement
|
|
16
|
+
10:37 The Surprise
|
|
17
|
+
1:19:17 Port Mahon
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from m4bmaker.errors import M4BError
|
|
26
|
+
from m4bmaker.models import Chapter
|
|
27
|
+
|
|
28
|
+
# Matches either MM:SS or H:MM:SS (H may be more than one digit).
|
|
29
|
+
_TIMESTAMP_RE = re.compile(r"^(\d+:\d{2}(?::\d{2})?)\s+(.+)$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_timestamp(ts: str) -> float:
|
|
33
|
+
"""Convert a *MM:SS* or *H:MM:SS* string to seconds (float).
|
|
34
|
+
|
|
35
|
+
Raises :class:`ValueError` for any string that doesn't match those patterns.
|
|
36
|
+
"""
|
|
37
|
+
parts = ts.split(":")
|
|
38
|
+
try:
|
|
39
|
+
int_parts = [int(p) for p in parts]
|
|
40
|
+
except ValueError:
|
|
41
|
+
raise ValueError(f"non-integer in timestamp {ts!r}")
|
|
42
|
+
|
|
43
|
+
if len(int_parts) == 2:
|
|
44
|
+
m, s = int_parts
|
|
45
|
+
if not (0 <= s < 60):
|
|
46
|
+
raise ValueError(f"seconds out of range in {ts!r}")
|
|
47
|
+
return m * 60.0 + s
|
|
48
|
+
elif len(int_parts) == 3:
|
|
49
|
+
h, m, s = int_parts
|
|
50
|
+
if not (0 <= m < 60 and 0 <= s < 60):
|
|
51
|
+
raise ValueError(f"minutes or seconds out of range in {ts!r}")
|
|
52
|
+
return h * 3600.0 + m * 60.0 + s
|
|
53
|
+
raise ValueError(f"unrecognised timestamp format {ts!r}") # pragma: no cover
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_chapters_file(path: Path) -> list[Chapter]:
|
|
57
|
+
"""Parse *path* and return a :class:`~m4bmaker.models.Chapter` list.
|
|
58
|
+
|
|
59
|
+
Raises :class:`~m4bmaker.errors.M4BError` if:
|
|
60
|
+
|
|
61
|
+
- the file cannot be read
|
|
62
|
+
- any non-blank, non-comment line is malformed
|
|
63
|
+
- the file contains no chapters after filtering
|
|
64
|
+
- the first chapter does not start at or after 0, or timestamps are not
|
|
65
|
+
strictly increasing
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
text = path.read_text(encoding="utf-8")
|
|
69
|
+
except OSError as exc:
|
|
70
|
+
raise M4BError(f"Error: cannot read chapters file '{path}': {exc}") from exc
|
|
71
|
+
|
|
72
|
+
chapters: list[Chapter] = []
|
|
73
|
+
# Track the source line number for each parsed chapter, for validation
|
|
74
|
+
# error messages below.
|
|
75
|
+
linenos: list[int] = []
|
|
76
|
+
for lineno, raw_line in enumerate(text.splitlines(), 1):
|
|
77
|
+
line = raw_line.strip()
|
|
78
|
+
if not line or line.startswith("#"):
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
m = _TIMESTAMP_RE.match(line)
|
|
82
|
+
if not m:
|
|
83
|
+
raise M4BError(
|
|
84
|
+
f"Error: malformed line {lineno} in '{path.name}':\n"
|
|
85
|
+
f" {raw_line!r}\n"
|
|
86
|
+
f"Expected format: MM:SS TITLE or H:MM:SS TITLE"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
ts_str, title = m.group(1), m.group(2).strip()
|
|
90
|
+
try:
|
|
91
|
+
start_time = _parse_timestamp(ts_str)
|
|
92
|
+
except ValueError as exc:
|
|
93
|
+
raise M4BError(
|
|
94
|
+
f"Error: invalid timestamp on line {lineno} of '{path.name}': {exc}"
|
|
95
|
+
) from exc
|
|
96
|
+
|
|
97
|
+
chapters.append(
|
|
98
|
+
Chapter(
|
|
99
|
+
index=len(chapters) + 1,
|
|
100
|
+
start_time=start_time,
|
|
101
|
+
title=title,
|
|
102
|
+
source_file=None,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
linenos.append(lineno)
|
|
106
|
+
|
|
107
|
+
if not chapters:
|
|
108
|
+
raise M4BError(f"Error: no chapters found in '{path.name}'")
|
|
109
|
+
|
|
110
|
+
if chapters[0].start_time < 0:
|
|
111
|
+
raise M4BError(
|
|
112
|
+
f"Error: line {linenos[0]} of '{path.name}' has a negative start "
|
|
113
|
+
f"time ({chapters[0].start_time}s) — the first chapter must start "
|
|
114
|
+
f"at or after 0:00"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
for i in range(1, len(chapters)):
|
|
118
|
+
if chapters[i].start_time <= chapters[i - 1].start_time:
|
|
119
|
+
raise M4BError(
|
|
120
|
+
f"Error: line {linenos[i]} of '{path.name}' is not after the "
|
|
121
|
+
f"previous chapter's timestamp — chapter timestamps must be "
|
|
122
|
+
f"strictly increasing:\n"
|
|
123
|
+
f" chapter {chapters[i - 1].index} starts at "
|
|
124
|
+
f"{chapters[i - 1].start_time}s, "
|
|
125
|
+
f"chapter {chapters[i].index} starts at "
|
|
126
|
+
f"{chapters[i].start_time}s"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return chapters
|