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.
Files changed (80) hide show
  1. m4bmaker/__init__.py +3 -0
  2. m4bmaker/__main__.py +351 -0
  3. m4bmaker/chapters.py +210 -0
  4. m4bmaker/chapters_file.py +129 -0
  5. m4bmaker/cli.py +156 -0
  6. m4bmaker/cover.py +205 -0
  7. m4bmaker/encoder.py +287 -0
  8. m4bmaker/errors.py +17 -0
  9. m4bmaker/filter/__init__.py +7 -0
  10. m4bmaker/filter/catalog.py +631 -0
  11. m4bmaker/filter/catalog_seed.py +71 -0
  12. m4bmaker/filter/catalog_store.py +273 -0
  13. m4bmaker/filter/chunking.py +325 -0
  14. m4bmaker/filter/filter_report.py +170 -0
  15. m4bmaker/filter/interval_planner.py +84 -0
  16. m4bmaker/filter/job_store.py +306 -0
  17. m4bmaker/filter/jobs.py +136 -0
  18. m4bmaker/filter/matcher.py +106 -0
  19. m4bmaker/filter/media_inspector.py +263 -0
  20. m4bmaker/filter/model_manager.py +219 -0
  21. m4bmaker/filter/models.py +369 -0
  22. m4bmaker/filter/renderer.py +704 -0
  23. m4bmaker/filter/scan.py +201 -0
  24. m4bmaker/filter/settings.py +82 -0
  25. m4bmaker/filter/storage.py +147 -0
  26. m4bmaker/filter/transcript.py +311 -0
  27. m4bmaker/filter/transcript_engine.py +380 -0
  28. m4bmaker/filter/transcript_text.py +42 -0
  29. m4bmaker/filter/transcription_orchestrator.py +369 -0
  30. m4bmaker/filter/validator.py +351 -0
  31. m4bmaker/filter/variation_scan.py +218 -0
  32. m4bmaker/gui/__init__.py +1 -0
  33. m4bmaker/gui/__main__.py +3 -0
  34. m4bmaker/gui/app.py +25 -0
  35. m4bmaker/gui/filter/__init__.py +8 -0
  36. m4bmaker/gui/filter/about_dialog.py +251 -0
  37. m4bmaker/gui/filter/catalog_window.py +978 -0
  38. m4bmaker/gui/filter/model_manager_window.py +406 -0
  39. m4bmaker/gui/filter/profile_editor_dialog.py +358 -0
  40. m4bmaker/gui/filter/settings_window.py +245 -0
  41. m4bmaker/gui/filter/wizard/__init__.py +12 -0
  42. m4bmaker/gui/filter/wizard/file_card.py +173 -0
  43. m4bmaker/gui/filter/wizard/profile_step.py +280 -0
  44. m4bmaker/gui/filter/wizard/render_step.py +749 -0
  45. m4bmaker/gui/filter/wizard/review_step.py +1480 -0
  46. m4bmaker/gui/filter/wizard/scan_step.py +387 -0
  47. m4bmaker/gui/filter/wizard/source_step.py +644 -0
  48. m4bmaker/gui/filter/wizard/step_base.py +36 -0
  49. m4bmaker/gui/filter/wizard/stepper.py +182 -0
  50. m4bmaker/gui/filter/wizard/transcribe_step.py +743 -0
  51. m4bmaker/gui/filter/wizard/transcript_step.py +379 -0
  52. m4bmaker/gui/filter/wizard/transcript_view.py +382 -0
  53. m4bmaker/gui/filter/wizard/wizard_window.py +528 -0
  54. m4bmaker/gui/filter/word_variation_dialog.py +261 -0
  55. m4bmaker/gui/filter/workers.py +499 -0
  56. m4bmaker/gui/icons.py +111 -0
  57. m4bmaker/gui/job.py +85 -0
  58. m4bmaker/gui/player.py +342 -0
  59. m4bmaker/gui/prefs.py +70 -0
  60. m4bmaker/gui/queue_manager.py +307 -0
  61. m4bmaker/gui/queue_window.py +259 -0
  62. m4bmaker/gui/styles.py +1434 -0
  63. m4bmaker/gui/updater.py +111 -0
  64. m4bmaker/gui/widgets.py +985 -0
  65. m4bmaker/gui/window.py +2008 -0
  66. m4bmaker/gui/worker.py +362 -0
  67. m4bmaker/m4b_editor.py +192 -0
  68. m4bmaker/metadata.py +151 -0
  69. m4bmaker/models.py +56 -0
  70. m4bmaker/pipeline.py +291 -0
  71. m4bmaker/preflight.py +282 -0
  72. m4bmaker/repair.py +238 -0
  73. m4bmaker/scanner.py +45 -0
  74. m4bmaker/utils.py +262 -0
  75. m4bmaker_filter-1.1.1.dist-info/METADATA +222 -0
  76. m4bmaker_filter-1.1.1.dist-info/RECORD +80 -0
  77. m4bmaker_filter-1.1.1.dist-info/WHEEL +5 -0
  78. m4bmaker_filter-1.1.1.dist-info/entry_points.txt +3 -0
  79. m4bmaker_filter-1.1.1.dist-info/licenses/LICENSE +682 -0
  80. m4bmaker_filter-1.1.1.dist-info/top_level.txt +1 -0
m4bmaker/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """m4bmaker — convert a directory of audio files into a single .m4b audiobook."""
2
+
3
+ __version__ = "1.1.1"
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