readalign 1.2.0__tar.gz → 1.3.0__tar.gz

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 (38) hide show
  1. {readalign-1.2.0 → readalign-1.3.0}/CHANGELOG.md +17 -0
  2. {readalign-1.2.0 → readalign-1.3.0}/PKG-INFO +73 -9
  3. {readalign-1.2.0 → readalign-1.3.0}/README.md +72 -8
  4. {readalign-1.2.0 → readalign-1.3.0}/pyproject.toml +1 -1
  5. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/__init__.py +1 -1
  6. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/audio.py +14 -6
  7. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/cli.py +23 -10
  8. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/fs.py +10 -0
  9. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/members.py +13 -4
  10. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/package.py +4 -2
  11. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/sidecar.py +14 -1
  12. readalign-1.3.0/tests/test_audio.py +145 -0
  13. {readalign-1.2.0 → readalign-1.3.0}/tests/test_cli.py +105 -0
  14. {readalign-1.2.0 → readalign-1.3.0}/tests/test_fs.py +9 -1
  15. {readalign-1.2.0 → readalign-1.3.0}/tests/test_output.py +73 -1
  16. {readalign-1.2.0 → readalign-1.3.0}/tests/test_sidecar_report.py +25 -0
  17. readalign-1.2.0/tests/test_audio.py +0 -77
  18. {readalign-1.2.0 → readalign-1.3.0}/.gitignore +0 -0
  19. {readalign-1.2.0 → readalign-1.3.0}/LICENSE +0 -0
  20. {readalign-1.2.0 → readalign-1.3.0}/scripts/fetch_samples.py +0 -0
  21. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/__main__.py +0 -0
  22. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/align.py +0 -0
  23. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/asr.py +0 -0
  24. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/clock.py +0 -0
  25. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/epub.py +0 -0
  26. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/errors.py +0 -0
  27. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/refine.py +0 -0
  28. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/report.py +0 -0
  29. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/sentences.py +0 -0
  30. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/smil.py +0 -0
  31. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/textnorm.py +0 -0
  32. {readalign-1.2.0 → readalign-1.3.0}/src/readalign/verify.py +0 -0
  33. {readalign-1.2.0 → readalign-1.3.0}/tests/conftest.py +0 -0
  34. {readalign-1.2.0 → readalign-1.3.0}/tests/test_align.py +0 -0
  35. {readalign-1.2.0 → readalign-1.3.0}/tests/test_asr.py +0 -0
  36. {readalign-1.2.0 → readalign-1.3.0}/tests/test_epub.py +0 -0
  37. {readalign-1.2.0 → readalign-1.3.0}/tests/test_text.py +0 -0
  38. {readalign-1.2.0 → readalign-1.3.0}/tests/test_verify.py +0 -0
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0 - 2026-10-25
4
+
5
+ - Fixed: work-directory audio/WAV paths cannot overwrite the input ebook, output EPUB or
6
+ report. Inputs must stay outside the generated sidecar directory when sidecars are enabled.
7
+ - Fixed: chapter-derived subtitle names cannot escape the sidecar directory through Windows
8
+ separators or use reserved device names. Sanitized names still receive collision handling.
9
+ - Added a real surround-AAC to stereo-MP3 regression checking retained chapter marks and
10
+ unchanged source bytes.
11
+ - New: `--audio-format mp3` stream-copies existing MP3 sources and transcodes other codecs
12
+ to MP3 for readers with AAC/Opus decoder limitations. The default `auto` retains the existing
13
+ behavior. Timing uses the prepared audio and the report records the selected format.
14
+ - Fixed: streamed audiobook entries now declare their size before ZIP writing so large files
15
+ select ZIP64 headers instead of failing at the end of the write. Small entries retain their
16
+ existing archive format.
17
+ - Added long-book packaging regressions and evidence-linked reader guidance distinguishing
18
+ codec support, large-track loading, sentence highlighting and narration-position restoration.
19
+
3
20
  ## 1.2.0 - 2026-10-22
4
21
 
5
22
  - New: `--report PATH` selects a separate JSON report for each batch build, with guards
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: readalign
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Turn an audiobook and the matching ebook you own into one EPUB 3 with Media Overlays.
5
5
  Project-URL: Homepage, https://github.com/Booyaka101/readalign
6
6
  Project-URL: Repository, https://github.com/Booyaka101/readalign
@@ -263,6 +263,7 @@ take precedence over this gate when more than one check fails.
263
263
  --beam-size N whisper beam size (default: 5)
264
264
  --no-vad disable the voice-activity filter
265
265
  --audio-bitrate RATE bitrate when audio has to be transcoded (default: 96k)
266
+ --audio-format auto|mp3 preserve supported codecs (default: auto), or use MP3 for compatibility
266
267
  --verify [JAR] validate the result with epubcheck after writing it (needs Java);
267
268
  pass its jar path, or leave the value off to search PATH,
268
269
  EPUBCHECK_HOME and JAVA_HOME for one
@@ -276,6 +277,29 @@ Multiple files are ordered by natural sort, so `part2.mp3` comes before `part10.
276
277
  file covering the whole book is fine, and so is a chapter split over several files; readalign
277
278
  works from a continuous timeline and does not assume one file per chapter.
278
279
 
280
+ For a reader that cannot decode AAC or Opus, select MP3 output:
281
+
282
+ The `--audio-format` option requires readalign 1.3.0 or later.
283
+
284
+ ```
285
+ readalign build --audio book.m4b --epub book.epub --out read-along.epub --audio-format mp3
286
+ ```
287
+
288
+ Existing MP3 sources are stream-copied. Other codecs are re-encoded with ffmpeg's `libmp3lame`
289
+ encoder at `--audio-bitrate` (default 96k). Re-encoding takes time and can reduce quality; it
290
+ does not change your source files. Timings are measured against the prepared MP3, not the
291
+ original AAC/Opus track. Changing the prepared audio also changes the transcript cache key,
292
+ so the first build in this mode may need to transcribe again. The report records the requested
293
+ `audio_format` and which source files were transcoded. This option does not fix reader resume
294
+ bugs or split a long track into chapters.
295
+
296
+ Use a dedicated `--work-dir`: its `audio/partNNN.mp3`, `.m4a`, `.opus` and
297
+ `wav/partNNN.wav` paths are reserved for generated files. Builds refuse aliases of the input
298
+ ebook, source audio, output EPUB or report at those paths before preparing audio. The generated
299
+ `<output-name>-sidecars/` directory must also be separate from your inputs and report; previous
300
+ `align.json` and `.vtt` files there are replaced on a rerun. Chapter-derived VTT filenames are
301
+ sanitized for safe local paths, while `align.json` retains the original document hrefs.
302
+
279
303
  ### The optional refine pass
280
304
 
281
305
  `pip install 'readalign[refine]'` adds torch and torchaudio, and `--refine` then runs a CTC
@@ -304,13 +328,50 @@ it, and any run that uses it records the restriction in the report's warnings.
304
328
 
305
329
  ## Reader support
306
330
 
307
- Media Overlays are part of EPUB 3, but support is uneven. Readium-based readers (Thorium Reader
308
- on desktop, Colibrio-based web readers) and Apple Books implement them. Kindle does not read EPUB
309
- overlays at all, and several popular mobile readers ignore the `media-overlay` attribute and just
310
- show the book. If a reader shows the text but plays no audio, it does not support overlays; the
311
- file is still a valid EPUB and still readable.
312
-
313
- readalign's output is validated against epubcheck 5.2.1, and `readalign check` is validated
331
+ Media Overlays are part of EPUB 3, but support is uneven. Text rendering, audio decoding,
332
+ sentence highlighting and saving the narration position are separate capabilities. A silent
333
+ book can mean an unsupported codec or a reader bug, not just missing overlay support.
334
+
335
+ ### Compatibility evidence (October 25, 2026)
336
+
337
+ This is a record of available evidence, **not a personally tested reader certification table**.
338
+ Reader fixes merged upstream may not yet be in the version you have installed.
339
+
340
+ | Reader / platform | Evidence and limits |
341
+ | --- | --- |
342
+ | Calibre and Thorium, reporter's PC | A [Readest reporter](https://github.com/readest/readest/issues/6989) says their large narrated EPUB plays in both. Versions and complete seek/reopen behavior were not recorded. |
343
+ | Readest, web and desktop | [#7004](https://github.com/readest/readest/pull/7004) fixes loading a large stored audio entry without copying it all into memory. Upstream tested a 921 MB shared MP3, including an eight-hour seek. This is upstream testing, not a readalign reader test. |
344
+ | Readest, Android and iOS | [#7010](https://github.com/readest/readest/pull/7010) and [#7019](https://github.com/readest/readest/pull/7019) extend direct audio access to native players. Upstream tests are described in those PRs; they do not establish playback of every readalign book. |
345
+ | Readest, Linux app | The maintainer [reports no AAC decoder](https://github.com/readest/readest/issues/6989#issuecomment-6357568308). Try `--audio-format mp3`; updating for large-track fixes alone does not resolve that codec limitation. |
346
+ | Episteme, Android | An [independent readalign-output user](https://github.com/Aryan-Raj3112/episteme/issues/579) reports narration restarting at the beginning of a long section after reopening. The same book highlights sentences correctly on PC. No readalign-side fix is established. |
347
+ | Apple Books and other readers | Overlay support alone is not proof of long-track seeking or saved narration position. These behaviors have not been verified here. |
348
+ | Kindle | EPUB Media Overlays are not supported. |
349
+
350
+ ### Long books and playback troubleshooting
351
+
352
+ 1. Run `readalign check read-along.epub` and, if available, epubcheck. A clean result checks
353
+ package structure and clip bounds, not whether a specific reader plays or resumes correctly.
354
+ 2. Check the reader version and audio codec. MP3 output is an option for AAC/Opus decoder
355
+ limitations; an EPUB can be valid while a reader cannot decode its narration.
356
+ 3. For large books, use a reader version containing its large-track fixes. readalign stores
357
+ prepared MP3/M4A/Opus entries without ZIP compression so readers can seek directly. A reader
358
+ may still extract or copy the whole track before playback. One overlay per content document
359
+ does not mean the audio is split into separate files. If you already own chapter-sized
360
+ narration files, pass their directory; they are naturally sorted.
361
+ 4. Test a later chapter, cross a chapter boundary, pause/resume, then close and reopen the book.
362
+ Saving the text position is not the same as saving the narration sentence and audio offset.
363
+ Re-encoding to MP3 is not a guaranteed workaround for narration-position loss.
364
+ 5. Compare the same EPUB in another overlay-capable reader. If only one reader fails, include
365
+ that comparison when reporting it rather than assuming the EPUB generation is defective.
366
+
367
+ When reporting a problem, include readalign's version, reader version, OS/device, audio format,
368
+ approximate track duration/size, the chapter and sentence where it happens, and whether it is
369
+ startup, seeking, highlighting or reopening that fails. Include the output of `readalign check`
370
+ and a short public-domain reproduction if possible. Reports and sidecars can contain local paths
371
+ and quoted book text: inspect them before sharing. Do not upload a copyrighted book or narration
372
+ unless you have permission.
373
+
374
+ readalign's sample output is validated against epubcheck 5.2.1, and `readalign check` is validated
314
375
  against the reference Media Overlays title from the EPUB test suite at
315
376
  <https://epubtest.org/test-books>.
316
377
 
@@ -338,9 +399,12 @@ python -m pytest tests -q
338
399
  python -m ruff check .
339
400
  ```
340
401
 
341
- The suite runs in about a second and needs neither a GPU nor network access; it builds its own
402
+ The suite needs neither a GPU nor network access; it builds its own
342
403
  EPUBs and synthesises word timings. A few cases need ffmpeg and skip without it, and one is
343
404
  skipped unless `READALIGN_MO_TESTBOOK` points at a copy of the epubtest.org Media Overlays book.
405
+ Long-book packaging tests cover shared 16-hour timelines, separate-track clock resets, stored
406
+ audio entries and ZIP64 selection using a lowered size threshold. Synthetic timings and a lowered
407
+ threshold do not substitute for a real multi-gigabyte book or reader playback test.
344
408
 
345
409
  ## Distribution
346
410
 
@@ -230,6 +230,7 @@ take precedence over this gate when more than one check fails.
230
230
  --beam-size N whisper beam size (default: 5)
231
231
  --no-vad disable the voice-activity filter
232
232
  --audio-bitrate RATE bitrate when audio has to be transcoded (default: 96k)
233
+ --audio-format auto|mp3 preserve supported codecs (default: auto), or use MP3 for compatibility
233
234
  --verify [JAR] validate the result with epubcheck after writing it (needs Java);
234
235
  pass its jar path, or leave the value off to search PATH,
235
236
  EPUBCHECK_HOME and JAVA_HOME for one
@@ -243,6 +244,29 @@ Multiple files are ordered by natural sort, so `part2.mp3` comes before `part10.
243
244
  file covering the whole book is fine, and so is a chapter split over several files; readalign
244
245
  works from a continuous timeline and does not assume one file per chapter.
245
246
 
247
+ For a reader that cannot decode AAC or Opus, select MP3 output:
248
+
249
+ The `--audio-format` option requires readalign 1.3.0 or later.
250
+
251
+ ```
252
+ readalign build --audio book.m4b --epub book.epub --out read-along.epub --audio-format mp3
253
+ ```
254
+
255
+ Existing MP3 sources are stream-copied. Other codecs are re-encoded with ffmpeg's `libmp3lame`
256
+ encoder at `--audio-bitrate` (default 96k). Re-encoding takes time and can reduce quality; it
257
+ does not change your source files. Timings are measured against the prepared MP3, not the
258
+ original AAC/Opus track. Changing the prepared audio also changes the transcript cache key,
259
+ so the first build in this mode may need to transcribe again. The report records the requested
260
+ `audio_format` and which source files were transcoded. This option does not fix reader resume
261
+ bugs or split a long track into chapters.
262
+
263
+ Use a dedicated `--work-dir`: its `audio/partNNN.mp3`, `.m4a`, `.opus` and
264
+ `wav/partNNN.wav` paths are reserved for generated files. Builds refuse aliases of the input
265
+ ebook, source audio, output EPUB or report at those paths before preparing audio. The generated
266
+ `<output-name>-sidecars/` directory must also be separate from your inputs and report; previous
267
+ `align.json` and `.vtt` files there are replaced on a rerun. Chapter-derived VTT filenames are
268
+ sanitized for safe local paths, while `align.json` retains the original document hrefs.
269
+
246
270
  ### The optional refine pass
247
271
 
248
272
  `pip install 'readalign[refine]'` adds torch and torchaudio, and `--refine` then runs a CTC
@@ -271,13 +295,50 @@ it, and any run that uses it records the restriction in the report's warnings.
271
295
 
272
296
  ## Reader support
273
297
 
274
- Media Overlays are part of EPUB 3, but support is uneven. Readium-based readers (Thorium Reader
275
- on desktop, Colibrio-based web readers) and Apple Books implement them. Kindle does not read EPUB
276
- overlays at all, and several popular mobile readers ignore the `media-overlay` attribute and just
277
- show the book. If a reader shows the text but plays no audio, it does not support overlays; the
278
- file is still a valid EPUB and still readable.
279
-
280
- readalign's output is validated against epubcheck 5.2.1, and `readalign check` is validated
298
+ Media Overlays are part of EPUB 3, but support is uneven. Text rendering, audio decoding,
299
+ sentence highlighting and saving the narration position are separate capabilities. A silent
300
+ book can mean an unsupported codec or a reader bug, not just missing overlay support.
301
+
302
+ ### Compatibility evidence (October 25, 2026)
303
+
304
+ This is a record of available evidence, **not a personally tested reader certification table**.
305
+ Reader fixes merged upstream may not yet be in the version you have installed.
306
+
307
+ | Reader / platform | Evidence and limits |
308
+ | --- | --- |
309
+ | Calibre and Thorium, reporter's PC | A [Readest reporter](https://github.com/readest/readest/issues/6989) says their large narrated EPUB plays in both. Versions and complete seek/reopen behavior were not recorded. |
310
+ | Readest, web and desktop | [#7004](https://github.com/readest/readest/pull/7004) fixes loading a large stored audio entry without copying it all into memory. Upstream tested a 921 MB shared MP3, including an eight-hour seek. This is upstream testing, not a readalign reader test. |
311
+ | Readest, Android and iOS | [#7010](https://github.com/readest/readest/pull/7010) and [#7019](https://github.com/readest/readest/pull/7019) extend direct audio access to native players. Upstream tests are described in those PRs; they do not establish playback of every readalign book. |
312
+ | Readest, Linux app | The maintainer [reports no AAC decoder](https://github.com/readest/readest/issues/6989#issuecomment-6357568308). Try `--audio-format mp3`; updating for large-track fixes alone does not resolve that codec limitation. |
313
+ | Episteme, Android | An [independent readalign-output user](https://github.com/Aryan-Raj3112/episteme/issues/579) reports narration restarting at the beginning of a long section after reopening. The same book highlights sentences correctly on PC. No readalign-side fix is established. |
314
+ | Apple Books and other readers | Overlay support alone is not proof of long-track seeking or saved narration position. These behaviors have not been verified here. |
315
+ | Kindle | EPUB Media Overlays are not supported. |
316
+
317
+ ### Long books and playback troubleshooting
318
+
319
+ 1. Run `readalign check read-along.epub` and, if available, epubcheck. A clean result checks
320
+ package structure and clip bounds, not whether a specific reader plays or resumes correctly.
321
+ 2. Check the reader version and audio codec. MP3 output is an option for AAC/Opus decoder
322
+ limitations; an EPUB can be valid while a reader cannot decode its narration.
323
+ 3. For large books, use a reader version containing its large-track fixes. readalign stores
324
+ prepared MP3/M4A/Opus entries without ZIP compression so readers can seek directly. A reader
325
+ may still extract or copy the whole track before playback. One overlay per content document
326
+ does not mean the audio is split into separate files. If you already own chapter-sized
327
+ narration files, pass their directory; they are naturally sorted.
328
+ 4. Test a later chapter, cross a chapter boundary, pause/resume, then close and reopen the book.
329
+ Saving the text position is not the same as saving the narration sentence and audio offset.
330
+ Re-encoding to MP3 is not a guaranteed workaround for narration-position loss.
331
+ 5. Compare the same EPUB in another overlay-capable reader. If only one reader fails, include
332
+ that comparison when reporting it rather than assuming the EPUB generation is defective.
333
+
334
+ When reporting a problem, include readalign's version, reader version, OS/device, audio format,
335
+ approximate track duration/size, the chapter and sentence where it happens, and whether it is
336
+ startup, seeking, highlighting or reopening that fails. Include the output of `readalign check`
337
+ and a short public-domain reproduction if possible. Reports and sidecars can contain local paths
338
+ and quoted book text: inspect them before sharing. Do not upload a copyrighted book or narration
339
+ unless you have permission.
340
+
341
+ readalign's sample output is validated against epubcheck 5.2.1, and `readalign check` is validated
281
342
  against the reference Media Overlays title from the EPUB test suite at
282
343
  <https://epubtest.org/test-books>.
283
344
 
@@ -305,9 +366,12 @@ python -m pytest tests -q
305
366
  python -m ruff check .
306
367
  ```
307
368
 
308
- The suite runs in about a second and needs neither a GPU nor network access; it builds its own
369
+ The suite needs neither a GPU nor network access; it builds its own
309
370
  EPUBs and synthesises word timings. A few cases need ffmpeg and skip without it, and one is
310
371
  skipped unless `READALIGN_MO_TESTBOOK` points at a copy of the epubtest.org Media Overlays book.
372
+ Long-book packaging tests cover shared 16-hour timelines, separate-track clock resets, stored
373
+ audio entries and ZIP64 selection using a lowered size threshold. Synthetic timings and a lowered
374
+ threshold do not substitute for a real multi-gigabyte book or reader playback test.
311
375
 
312
376
  ## Distribution
313
377
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "readalign"
7
- version = "1.2.0"
7
+ version = "1.3.0"
8
8
  description = "Turn an audiobook and the matching ebook you own into one EPUB 3 with Media Overlays."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """readalign: build an EPUB 3 with Media Overlays from an audiobook and the matching ebook."""
2
2
 
3
- __version__ = "1.2.0"
3
+ __version__ = "1.3.0"
@@ -199,10 +199,12 @@ def _chapters_from(probe: dict) -> list[Chapter]:
199
199
  return chapters
200
200
 
201
201
 
202
- def _output_plan(path: str, probe: dict) -> tuple[str, str, bool]:
202
+ def _output_plan(path: str, probe: dict, audio_format: str = "auto") -> tuple[str, str, bool]:
203
203
  """Pick the extension, media type and whether a transcode is needed for a source file."""
204
204
  stream = _audio_stream(probe, path)
205
205
  codec = (stream.get("codec_name") or "").lower()
206
+ if audio_format == "mp3":
207
+ return ".mp3", "audio/mpeg", codec != "mp3"
206
208
  names = (probe.get("format", {}).get("format_name") or "").split(",")
207
209
  container = {name.strip() for name in names}
208
210
  if codec == "mp3":
@@ -214,13 +216,17 @@ def _output_plan(path: str, probe: dict) -> tuple[str, str, bool]:
214
216
  return ".m4a", "audio/mp4", True
215
217
 
216
218
 
217
- def prepare_tracks(sources: list[str], workdir: str, bitrate: str, log=print) -> list[AudioTrack]:
219
+ def prepare_tracks(
220
+ sources: list[str], workdir: str, bitrate: str, log=print, *, audio_format: str = "auto",
221
+ ) -> list[AudioTrack]:
218
222
  """Copy or transcode every source into an EPUB core media type and measure the result.
219
223
 
220
224
  MP3, AAC-in-MP4 and Opus-in-Ogg are core media types in EPUB 3, so they are stream-copied.
221
225
  Anything else (FLAC, WAV, ALAC, WMA) is transcoded to AAC, because a reading system is not
222
- required to play it.
226
+ required to play it. Explicit MP3 output avoids AAC/Opus decoder limitations in some readers.
223
227
  """
228
+ if audio_format not in {"auto", "mp3"}:
229
+ raise InputError("audio format must be auto or mp3")
224
230
  audio_dir = os.path.join(workdir, "audio")
225
231
  os.makedirs(audio_dir, exist_ok=True)
226
232
  tracks: list[AudioTrack] = []
@@ -228,15 +234,17 @@ def prepare_tracks(sources: list[str], workdir: str, bitrate: str, log=print) ->
228
234
  for index, source in enumerate(sources, start=1):
229
235
  probe = ffprobe(source)
230
236
  _reject_drm(source, probe)
231
- suffix, media_type, transcode = _output_plan(source, probe)
237
+ suffix, media_type, transcode = _output_plan(source, probe, audio_format)
232
238
  target = os.path.join(audio_dir, f"part{index:03d}{suffix}")
233
239
  if any(same_path(target, original) for original in sources):
234
240
  raise InputError(f"the work directory would overwrite source audio: {target}")
235
241
  label = f"[{index}/{len(sources)}] {os.path.basename(source)}"
236
- log(f" {label} -> {'transcoding to AAC' if transcode else 'stream copy'}")
242
+ codec_label = "MP3" if suffix == ".mp3" else "AAC"
243
+ log(f" {label} -> {'transcoding to ' + codec_label if transcode else 'stream copy'}")
237
244
  command = ["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", source,
238
245
  "-map", "0:a:0", "-map_chapters", "0", "-vn"]
239
- command += ["-c:a", "aac", "-b:a", bitrate] if transcode else ["-c:a", "copy"]
246
+ codec = "libmp3lame" if suffix == ".mp3" else "aac"
247
+ command += ["-c:a", codec, "-b:a", bitrate] if transcode else ["-c:a", "copy"]
240
248
  if suffix == ".m4a":
241
249
  command += ["-movflags", "+faststart"]
242
250
  result = _run([*command, target], f"preparing {source}")
@@ -21,7 +21,7 @@ from urllib.parse import unquote
21
21
 
22
22
  from . import __version__
23
23
  from .errors import InputError, ReadAlignError
24
- from .fs import ensure_directory, same_path
24
+ from .fs import ensure_directory, is_within, same_path
25
25
  from .members import open_archive
26
26
 
27
27
  DEFAULT_MODEL = "large-v3-turbo"
@@ -79,6 +79,9 @@ def build_parser() -> argparse.ArgumentParser:
79
79
  help="disable the voice-activity filter")
80
80
  build.add_argument("--audio-bitrate", default="96k",
81
81
  help="bitrate used when audio has to be transcoded (default: 96k)")
82
+ build.add_argument("--audio-format", choices=["auto", "mp3"], default="auto",
83
+ help="embedded audio format: auto preserves supported codecs; mp3 avoids "
84
+ "AAC/Opus decoder limitations (default: auto)")
82
85
  build.add_argument("--verify", nargs="?", const="auto", default=None, metavar="EPUBCHECK",
83
86
  help="validate the result with epubcheck after writing it (needs Java). "
84
87
  "Pass its jar path, or leave the value off to search PATH, "
@@ -162,11 +165,8 @@ def run_build(args) -> int:
162
165
  raise InputError("--report would overwrite the input or output EPUB")
163
166
  directory = os.path.join(os.path.dirname(out_path),
164
167
  os.path.splitext(os.path.basename(out_path))[0] + "-sidecars")
165
- if args.sidecars:
166
- sidecar_root = os.path.normcase(os.path.realpath(directory))
167
- report_real = os.path.normcase(os.path.realpath(report_path))
168
- if report_real == sidecar_root or report_real.startswith(sidecar_root + os.sep):
169
- raise InputError("--report must be outside the generated sidecar directory")
168
+ if args.sidecars and is_within(report_path, directory):
169
+ raise InputError("--report must be outside the generated sidecar directory")
170
170
 
171
171
  verification_tool = None
172
172
  if args.verify and not args.dry_run:
@@ -190,16 +190,28 @@ def run_build(args) -> int:
190
190
  for source in sources:
191
191
  if same_path(out_path, source) or same_path(report_path, source):
192
192
  raise InputError(f"an output path would overwrite source audio: {source}")
193
+ if args.sidecars:
194
+ for path in [args.epub, out_path, *sources]:
195
+ if is_within(path, directory):
196
+ raise InputError(f"inputs and output EPUB must be outside the generated "
197
+ f"sidecar directory: {path}")
193
198
  log(f"preparing {len(sources)} audio file(s)")
194
199
  work_dir = args.work_dir or tempfile.mkdtemp(prefix="readalign-")
195
200
  temporary = args.work_dir is None
196
201
  ensure_directory(work_dir, "work directory")
197
202
  try:
198
203
  for index in range(1, len(sources) + 1):
199
- wav_path = os.path.join(work_dir, "wav", f"part{index:03d}.wav")
200
- if any(same_path(wav_path, source) for source in [args.epub, *sources]):
201
- raise InputError(f"the work directory would overwrite an input: {wav_path}")
202
- tracks = prepare_tracks(sources, work_dir, args.audio_bitrate, log=log)
204
+ # Reserve all prepared formats before ffmpeg can modify any existing output.
205
+ scratch_paths = [os.path.join(work_dir, "audio", f"part{index:03d}{suffix}")
206
+ for suffix in (".mp3", ".m4a", ".opus")]
207
+ scratch_paths.append(os.path.join(work_dir, "wav", f"part{index:03d}.wav"))
208
+ for path in scratch_paths:
209
+ if any(same_path(path, protected)
210
+ for protected in [args.epub, out_path, report_path, *sources]):
211
+ raise InputError(f"the work directory would overwrite an input or output: "
212
+ f"{path}")
213
+ tracks = prepare_tracks(sources, work_dir, args.audio_bitrate, log=log,
214
+ audio_format=args.audio_format)
203
215
  if args.dry_run:
204
216
  return _dry_run_plan(package, tracks, log)
205
217
 
@@ -279,6 +291,7 @@ def run_build(args) -> int:
279
291
  drift_threshold=args.drift_threshold,
280
292
  warnings=warnings,
281
293
  extras={"elapsed_seconds": round(time.monotonic() - started, 1),
294
+ "audio_format": args.audio_format,
282
295
  "token_match_gate": {
283
296
  "threshold": args.min_token_match,
284
297
  "passed": (result.matched_tokens / result.book_tokens
@@ -24,6 +24,16 @@ def same_path(first: str, second: str) -> bool:
24
24
  and os.path.samefile(first, second)))
25
25
 
26
26
 
27
+ def is_within(path: str, directory: str) -> bool:
28
+ """Include the directory itself and resolve aliases before comparing path components."""
29
+ path = os.path.normcase(os.path.realpath(path))
30
+ directory = os.path.normcase(os.path.realpath(directory))
31
+ try:
32
+ return os.path.commonpath([path, directory]) == directory
33
+ except ValueError: # Windows paths can be on different drives.
34
+ return False
35
+
36
+
27
37
  def write_text(path: str, text: str) -> None:
28
38
  try:
29
39
  with atomic_output(path) as temporary, \
@@ -60,11 +60,20 @@ class MemberReader(AbstractContextManager):
60
60
  def open(self, member: SourceFile | ArchiveMember) -> BinaryIO:
61
61
  if isinstance(member, SourceFile):
62
62
  return open(member.path, "rb")
63
- archive = self._archives.get(member.epub_path)
63
+ return self._archive(member.epub_path).open(member.name)
64
+
65
+ def size(self, member: SourceFile | ArchiveMember) -> int:
66
+ """Read metadata, not content, so large entries can select ZIP64 before streaming."""
67
+ if isinstance(member, SourceFile):
68
+ return os.path.getsize(member.path)
69
+ return self._archive(member.epub_path).getinfo(member.name).file_size
70
+
71
+ def _archive(self, path: str) -> zipfile.ZipFile:
72
+ archive = self._archives.get(path)
64
73
  if archive is None:
65
- archive = zipfile.ZipFile(member.epub_path)
66
- self._archives[member.epub_path] = archive
67
- return archive.open(member.name)
74
+ archive = zipfile.ZipFile(path)
75
+ self._archives[path] = archive
76
+ return archive
68
77
 
69
78
  def __exit__(self, *_) -> None:
70
79
  while self._archives:
@@ -2,7 +2,7 @@
2
2
 
3
3
  The OPF gains the audio and overlay manifest items, the ``media-overlay`` attribute on every
4
4
  narrated content document, and the ``media:`` metadata EPUB 3.3 section 9.3.5 requires. The
5
- archive is written mimetype-first and uncompressed, as OCF demands.
5
+ archive is written mimetype-first, with that entry stored as OCF demands.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -284,7 +284,7 @@ def build_output(
284
284
 
285
285
 
286
286
  def write_epub(path: str, files: Member, original_order: list[str]) -> None:
287
- """Write an OCF archive: mimetype first and stored, everything else deflated."""
287
+ """Write an OCF archive: mimetype first and stored, audio stored for direct seeking."""
288
288
  ordered: list[str] = []
289
289
  seen = set()
290
290
  for name in ["mimetype", "META-INF/container.xml", *original_order]:
@@ -323,6 +323,8 @@ def _write_archive(path: str, files: Member, ordered: list[str]) -> None:
323
323
  if isinstance(data, bytes):
324
324
  archive.writestr(info, data)
325
325
  else:
326
+ # zipfile must reserve a ZIP64 header before streaming a large audiobook.
327
+ info.file_size = read.size(data)
326
328
  with read.open(data) as source, archive.open(info, "w") as target:
327
329
  shutil.copyfileobj(source, target, 1 << 20)
328
330
 
@@ -11,6 +11,7 @@ import contextlib
11
11
  import json
12
12
  import os
13
13
  import posixpath
14
+ import re
14
15
  from collections import defaultdict
15
16
 
16
17
  from .align import AlignmentResult, TimedSentence
@@ -101,6 +102,18 @@ def _clear_previous(directory: str) -> None:
101
102
  os.unlink(os.path.join(directory, name))
102
103
 
103
104
 
105
+ def _sidecar_stem(href: str) -> str:
106
+ # EPUB paths are untrusted and can contain OS separators or Windows device names.
107
+ stem = posixpath.splitext(posixpath.basename(href.replace("\\", "/")))[0]
108
+ stem = re.sub(r'[<>:"|?*\x00-\x1f]', "_", stem).rstrip(" .") or "chapter"
109
+ device = stem.split(".", 1)[0].upper()
110
+ if device in {"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"} or re.fullmatch(
111
+ r"(?:COM|LPT)[1-9¹²³]", device
112
+ ):
113
+ stem = "_" + stem
114
+ return stem
115
+
116
+
104
117
  def write_sidecars(
105
118
  directory: str,
106
119
  result: AlignmentResult,
@@ -128,7 +141,7 @@ def write_sidecars(
128
141
  names = {}
129
142
  for doc_index, track_index in sorted(grouped):
130
143
  href = doc_hrefs.get(doc_index, f"doc{doc_index}")
131
- stem = posixpath.splitext(posixpath.basename(href))[0]
144
+ stem = _sidecar_stem(href)
132
145
  names[(doc_index, track_index)] = (
133
146
  f"{stem}.part{track_index:03d}.vtt" if doc_index in multi_track else f"{stem}.vtt"
134
147
  )
@@ -0,0 +1,145 @@
1
+ """Input discovery: patterns, directories, and the ordering between numbered files."""
2
+
3
+ import shutil
4
+ import subprocess
5
+
6
+ import pytest
7
+
8
+ from readalign.audio import (
9
+ _chapters_from,
10
+ _duration_of,
11
+ _output_plan,
12
+ discover_inputs,
13
+ ffprobe,
14
+ natural_key,
15
+ prepare_tracks,
16
+ )
17
+ from readalign.errors import DRMError, InputError
18
+
19
+
20
+ def test_natural_order_puts_part2_before_part10():
21
+ names = ["part10.mp3", "part2.mp3", "part1.mp3"]
22
+ assert sorted(names, key=natural_key) == ["part1.mp3", "part2.mp3", "part10.mp3"]
23
+
24
+
25
+ def test_a_glob_pattern_is_expanded_in_natural_order(tmp_path):
26
+ """The shell does not expand patterns for a native command on Windows, so readalign must."""
27
+ for name in ("part10.mp3", "part2.mp3", "part1.mp3"):
28
+ (tmp_path / name).write_bytes(b"x")
29
+ assert discover_inputs(str(tmp_path / "part*.mp3")) == [
30
+ str(tmp_path / "part1.mp3"),
31
+ str(tmp_path / "part2.mp3"),
32
+ str(tmp_path / "part10.mp3"),
33
+ ]
34
+
35
+
36
+ def test_a_glob_that_matches_nothing_is_a_clear_error(tmp_path):
37
+ with pytest.raises(InputError, match="matched no audio files"):
38
+ discover_inputs(str(tmp_path / "book-*.mp3"))
39
+
40
+
41
+ def test_a_glob_skips_files_that_are_not_audio(tmp_path):
42
+ (tmp_path / "a.mp3").write_bytes(b"x")
43
+ (tmp_path / "cover.jpg").write_bytes(b"x")
44
+ assert discover_inputs(str(tmp_path / "*")) == [str(tmp_path / "a.mp3")]
45
+
46
+
47
+ def test_a_drm_file_a_glob_matches_is_refused(tmp_path):
48
+ (tmp_path / "a.mp3").write_bytes(b"x")
49
+ (tmp_path / "b.aax").write_bytes(b"x")
50
+ with pytest.raises(DRMError, match=r"b\.aax"):
51
+ discover_inputs(str(tmp_path / "*"))
52
+
53
+
54
+ @pytest.mark.parametrize("duration", ["inf", "nan", "-1", "N/A", {}])
55
+ def test_invalid_audio_duration_is_rejected(duration):
56
+ probe = {"format": {"duration": duration}, "streams": [{"codec_type": "audio"}]}
57
+ with pytest.raises(InputError, match="no readable duration"):
58
+ _duration_of(probe, "audio.mp3")
59
+
60
+
61
+ def test_invalid_chapter_marks_are_ignored():
62
+ probe = {"chapters": [
63
+ {"start_time": "nan", "end_time": "inf"},
64
+ {"start_time": "-1", "end_time": "2"},
65
+ {"start_time": "0", "end_time": "3"},
66
+ ]}
67
+ assert len(_chapters_from(probe)) == 1
68
+
69
+
70
+ def test_work_directory_cannot_overwrite_any_source_audio(tmp_path, monkeypatch):
71
+ from readalign import audio
72
+
73
+ directory = tmp_path / "audio"
74
+ directory.mkdir()
75
+ source = directory / "part001.mp3"
76
+ source.write_bytes(b"original")
77
+ monkeypatch.setattr(audio, "ffprobe", lambda path: {
78
+ "streams": [{"codec_type": "audio", "codec_name": "mp3"}],
79
+ })
80
+ with pytest.raises(InputError, match="overwrite source audio"):
81
+ prepare_tracks([str(source)], str(tmp_path), "96k", log=lambda *_: None)
82
+ assert source.read_bytes() == b"original"
83
+
84
+
85
+ @pytest.mark.parametrize("codec,container,expected", [
86
+ ("mp3", "mp3", (".mp3", "audio/mpeg", False)),
87
+ ("aac", "mov,mp4,m4a,3gp,3g2,mj2", (".m4a", "audio/mp4", False)),
88
+ ("opus", "ogg", (".opus", "audio/ogg; codecs=opus", False)),
89
+ ("flac", "flac", (".m4a", "audio/mp4", True)),
90
+ ])
91
+ def test_output_format_defaults_preserve_existing_behavior(codec, container, expected):
92
+ probe = {"streams": [{"codec_type": "audio", "codec_name": codec}],
93
+ "format": {"format_name": container}}
94
+ assert _output_plan("source", probe) == expected
95
+ assert _output_plan("source", probe, "mp3") == (".mp3", "audio/mpeg", codec != "mp3")
96
+
97
+
98
+ def test_unknown_audio_format_is_rejected(tmp_path):
99
+ with pytest.raises(InputError, match="audio format"):
100
+ prepare_tracks([], str(tmp_path), "96k", audio_format="wav")
101
+
102
+
103
+ @pytest.mark.skipif(not (shutil.which("ffmpeg") and shutil.which("ffprobe")),
104
+ reason="ffmpeg and ffprobe are needed")
105
+ @pytest.mark.parametrize("source_codec,suffix", [("aac", ".m4a"), ("libmp3lame", ".mp3")])
106
+ def test_mp3_mode_prepares_real_audio_without_changing_source(tmp_path, source_codec, suffix):
107
+ source = tmp_path / ("narration" + suffix)
108
+ subprocess.run([
109
+ "ffmpeg", "-nostdin", "-v", "error", "-y", "-f", "lavfi", "-i",
110
+ "sine=frequency=440:duration=2", "-c:a", source_codec, str(source),
111
+ ], check=True)
112
+ original = source.read_bytes()
113
+ tracks = prepare_tracks([str(source)], str(tmp_path / "work"), "96k",
114
+ log=lambda *_: None, audio_format="mp3")
115
+ track, = tracks
116
+ assert track.media_type == "audio/mpeg"
117
+ assert track.path.endswith(".mp3")
118
+ assert track.transcoded == (source_codec != "libmp3lame")
119
+ assert 1.9 < track.duration < 2.2
120
+ assert ffprobe(track.path)["streams"][0]["codec_name"] == "mp3"
121
+ assert source.read_bytes() == original
122
+
123
+
124
+ @pytest.mark.skipif(not (shutil.which("ffmpeg") and shutil.which("ffprobe")),
125
+ reason="ffmpeg and ffprobe are needed")
126
+ def test_mp3_conversion_downmixes_surround_and_retains_chapters(tmp_path):
127
+ source = tmp_path / "surround.m4a"
128
+ metadata = tmp_path / "chapters.txt"
129
+ metadata.write_text(";FFMETADATA1\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000\n"
130
+ "title=First\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=1000\nEND=2000\n"
131
+ "title=Second\n", encoding="utf-8")
132
+ subprocess.run([
133
+ "ffmpeg", "-nostdin", "-v", "error", "-y", "-f", "lavfi", "-i",
134
+ "anullsrc=r=48000:cl=5.1", "-i", str(metadata), "-map_metadata", "1",
135
+ "-t", "2", "-c:a", "aac", str(source),
136
+ ], check=True)
137
+ original = source.read_bytes()
138
+ track, = prepare_tracks([str(source)], str(tmp_path / "work"), "96k",
139
+ log=lambda *_: None, audio_format="mp3")
140
+ probe = ffprobe(track.path)
141
+ assert probe["streams"][0]["codec_name"] == "mp3"
142
+ assert probe["streams"][0]["channels"] == 2
143
+ assert [chapter.title for chapter in track.chapters] == ["First", "Second"]
144
+ assert track.chapters[1].start == pytest.approx(1, abs=0.05)
145
+ assert source.read_bytes() == original
@@ -22,6 +22,7 @@ def test_defaults_match_the_documented_ones():
22
22
  assert args.dry_run is False
23
23
  assert args.language is None
24
24
  assert args.report is None
25
+ assert args.audio_format == "auto"
25
26
 
26
27
 
27
28
  def test_no_sidecars_flag():
@@ -31,6 +32,110 @@ def test_no_sidecars_flag():
31
32
  assert args.sidecars is False
32
33
 
33
34
 
35
+ def test_unknown_audio_format_is_an_argparse_error():
36
+ with pytest.raises(SystemExit) as error:
37
+ build_parser().parse_args(["build", "--audio", "a", "--epub", "b", "--out", "c",
38
+ "--audio-format", "wav"])
39
+ assert error.value.code == 2
40
+
41
+
42
+ @pytest.mark.parametrize("protected", ["ebook", "output", "report"])
43
+ @pytest.mark.parametrize("stage", ["audio", "wav"])
44
+ def test_work_files_cannot_overwrite_protected_paths(tmp_path, monkeypatch, capsys,
45
+ protected, stage):
46
+ from readalign import audio
47
+
48
+ work = tmp_path / "work"
49
+ target = work / stage / ("part001.mp3" if stage == "audio" else "part001.wav")
50
+ target.parent.mkdir(parents=True)
51
+ epub = build_epub(str(target if protected == "ebook" else tmp_path / "book.epub"))
52
+ if protected != "ebook":
53
+ target.write_bytes(b"previous successful output")
54
+ original = target.read_bytes()
55
+ narration = tmp_path / "narration.mp3"
56
+ narration.write_bytes(b"input")
57
+ monkeypatch.setattr(audio, "require_ffmpeg", lambda: None)
58
+ monkeypatch.setattr(audio, "ffprobe", lambda *_: pytest.fail("must refuse before preparing"))
59
+ args = ["build", "--audio", str(narration), "--epub", epub, "--out",
60
+ str(target if protected == "output" else tmp_path / "out.epub"),
61
+ "--work-dir", str(work)]
62
+ if protected == "report":
63
+ args += ["--report", str(target)]
64
+ assert main(args) == 1
65
+ assert "overwrite" in capsys.readouterr().err
66
+ assert target.read_bytes() == original
67
+
68
+
69
+ @pytest.mark.parametrize("protected", ["ebook", "audio"])
70
+ def test_sidecar_directory_cannot_contain_protected_paths(tmp_path, monkeypatch, capsys,
71
+ protected):
72
+ from readalign import audio
73
+
74
+ directory = tmp_path / "out-sidecars"
75
+ directory.mkdir()
76
+ target = directory / ("chapter1.vtt" if protected != "audio" else "narration.mp3")
77
+ epub = build_epub(str(target if protected == "ebook" else tmp_path / "book.epub"))
78
+ narration = target if protected == "audio" else tmp_path / "narration.mp3"
79
+ narration.write_bytes(b"input")
80
+ original = target.read_bytes()
81
+ monkeypatch.setattr(audio, "require_ffmpeg", lambda: None)
82
+ monkeypatch.setattr(audio, "ffprobe", lambda *_: pytest.fail("must refuse before preparing"))
83
+ assert main(["build", "--audio", str(narration), "--epub", epub, "--out",
84
+ str(tmp_path / "out.epub")]) == 1
85
+ assert "sidecar" in capsys.readouterr().err
86
+ assert target.read_bytes() == original
87
+
88
+
89
+ @pytest.mark.skipif(not has_ffmpeg, reason="ffmpeg is needed")
90
+ def test_no_sidecars_allows_input_in_sidecar_directory(tmp_path):
91
+ directory = tmp_path / "out-sidecars"
92
+ directory.mkdir()
93
+ epub = build_epub(str(directory / "book.epub"))
94
+ original = (directory / "book.epub").read_bytes()
95
+ assert main(["build", "--audio", "examples/aesop/aesop-section-37.mp3", "--epub", epub,
96
+ "--out", str(tmp_path / "out.epub"), "--no-sidecars", "--dry-run"]) == 0
97
+ assert (directory / "book.epub").read_bytes() == original
98
+
99
+
100
+ @pytest.mark.skipif(not has_ffmpeg, reason="ffmpeg is needed")
101
+ def test_mp3_build_converts_aac_and_records_requested_format(tmp_path, monkeypatch):
102
+ import subprocess
103
+
104
+ from conftest import speak
105
+ from readalign.audio import ffprobe
106
+ from readalign.cli import check_epub
107
+ from readalign.epub import load_epub
108
+
109
+ epub = build_epub(str(tmp_path / "book.epub"))
110
+ package = load_epub(epub)
111
+ words = speak([sentence for doc in package.docs for sentence in doc.sentences])
112
+ audio = tmp_path / "narration.m4a"
113
+ subprocess.run([
114
+ "ffmpeg", "-nostdin", "-v", "error", "-y", "-f", "lavfi", "-i",
115
+ "anullsrc=r=22050:cl=mono", "-t", str(words[-1].end + 2), "-c:a", "aac", str(audio),
116
+ ], check=True)
117
+ monkeypatch.setattr(
118
+ "readalign.asr.transcribe_tracks",
119
+ lambda *a, **k: (words, {"cached_tracks": 0, "seconds": 0, "device": "test"}),
120
+ )
121
+ out = tmp_path / "mp3.epub"
122
+ assert main(["build", "--audio", str(audio), "--epub", epub, "--out", str(out),
123
+ "--audio-format", "mp3"]) == 0
124
+ assert check_epub(str(out))["ok"]
125
+ report = json.loads((tmp_path / "readalign-report.json").read_text(encoding="utf-8"))
126
+ assert report["audio_format"] == "mp3"
127
+ assert report["input"]["transcoded"] == ["narration.m4a"]
128
+ with zipfile.ZipFile(out) as archive:
129
+ audio_name, = [name for name in archive.namelist() if name.endswith(".mp3")]
130
+ assert archive.getinfo(audio_name).compress_type == zipfile.ZIP_STORED
131
+ prepared = tmp_path / "embedded.mp3"
132
+ prepared.write_bytes(archive.read(audio_name))
133
+ assert ffprobe(str(prepared))["streams"][0]["codec_name"] == "mp3"
134
+ sidecars = json.loads((tmp_path / "mp3-sidecars" / "align.json").read_text(encoding="utf-8"))
135
+ assert sidecars["audio"][0]["href"] == "audio/part001.mp3"
136
+ assert all(row["audio"] == "audio/part001.mp3" for row in sidecars["alignment"])
137
+
138
+
34
139
  def test_missing_subcommand_is_an_argparse_error(capsys):
35
140
  with pytest.raises(SystemExit):
36
141
  main([])
@@ -3,11 +3,19 @@
3
3
  import pytest
4
4
 
5
5
  from readalign.errors import InputError
6
- from readalign.fs import unique_filenames, write_text
6
+ from readalign.fs import is_within, unique_filenames, write_text
7
7
  from readalign.members import SourceFile
8
8
  from readalign.package import write_epub
9
9
 
10
10
 
11
+ def test_directory_containment_compares_components(tmp_path):
12
+ root = tmp_path / "sidecars"
13
+ assert is_within(str(root / "chapter.vtt"), str(root))
14
+ assert is_within(str(root), str(root))
15
+ assert not is_within(str(tmp_path / "sidecars-other" / "chapter.vtt"), str(root))
16
+ assert not is_within(str(root / ".." / "book.epub"), str(root))
17
+
18
+
11
19
  @pytest.mark.parametrize("exists", [False, True])
12
20
  def test_failed_epub_write_leaves_no_partial_output(tmp_path, exists):
13
21
  target = tmp_path / "out.epub"
@@ -13,7 +13,7 @@ from conftest import CHAPTERS, STRUCTURED, build_epub, make_tracks, speak
13
13
  from readalign.align import align
14
14
  from readalign.cli import check_epub
15
15
  from readalign.epub import OPF_NS, load_epub, local_name
16
- from readalign.members import SourceFile
16
+ from readalign.members import ArchiveMember, SourceFile
17
17
  from readalign.package import ACTIVE_CLASS, build_output, ensure_mimetype, write_epub
18
18
  from readalign.smil import EPUB_NS, SMIL_NS
19
19
 
@@ -211,6 +211,78 @@ def test_check_accepts_what_build_wrote(aligned, tmp_path):
211
211
  assert findings["sentences"] == len(result.timed)
212
212
 
213
213
 
214
+ @pytest.mark.parametrize("from_archive", [False, True])
215
+ def test_streamed_audio_selects_zip64_before_writing(tmp_path, monkeypatch, from_archive):
216
+ """Lower the threshold to exercise multi-gigabyte audio without a huge fixture."""
217
+ payload = b"a" * 4096
218
+ if from_archive:
219
+ original = tmp_path / "original.epub"
220
+ with zipfile.ZipFile(original, "w") as archive:
221
+ archive.writestr("audio.mp3", payload)
222
+ member = ArchiveMember(str(original), "audio.mp3")
223
+ else:
224
+ original = tmp_path / "audio.mp3"
225
+ original.write_bytes(payload)
226
+ member = SourceFile(str(original))
227
+ monkeypatch.setattr(zipfile, "ZIP64_LIMIT", 1024)
228
+ out = tmp_path / "large.epub"
229
+ small = tmp_path / "small.mp3"
230
+ small.write_bytes(b"small")
231
+ files = {"audio.mp3": member, "small.mp3": SourceFile(str(small))}
232
+ ensure_mimetype(files)
233
+ write_epub(str(out), files, ["small.mp3"])
234
+ with zipfile.ZipFile(out) as archive:
235
+ assert archive.read("audio.mp3") == payload
236
+ assert archive.getinfo("audio.mp3").extract_version == 45
237
+ assert archive.getinfo("small.mp3").extract_version == 20
238
+
239
+
240
+ @pytest.mark.parametrize("shared_track", [True, False])
241
+ def test_long_book_uses_file_local_clocks_and_stored_audio(tmp_path, shared_track):
242
+ from readalign.align import TimedSentence
243
+ from readalign.clock import parse_clock
244
+ from readalign.sidecar import build_vtt
245
+
246
+ chapters = [(f"Chapter {index}", "".join(
247
+ f"<p>Voyage sentence {n} describes the sea.</p>" for n in range(450)
248
+ )) for index in range(2)]
249
+ package = load_epub(build_epub(str(tmp_path / "long.epub"), chapters=chapters))
250
+ tracks = make_tracks([16 * 3600] if shared_track else [8 * 3600, 8 * 3600])
251
+ # These bytes exercise packaging only, not audio decoding or reader playback.
252
+ for track in tracks:
253
+ path = tmp_path / f"track{track.index}.mp3"
254
+ path.write_bytes(b"synthetic audio")
255
+ track.path = str(path)
256
+ by_doc = {}
257
+ for index, doc in enumerate(package.docs):
258
+ start = index * 8 * 3600 + 15.125
259
+ by_doc[doc.manifest_id] = [
260
+ TimedSentence(sentence, start + n * 2, start + n * 2 + 1.5,
261
+ confidence=1.0, track_index=1 if shared_track else index + 1)
262
+ for n, sentence in enumerate(doc.sentences)
263
+ ]
264
+ files, summary = build_output(package, by_doc, tracks)
265
+ assert summary["audio_files"] == len(tracks)
266
+ out = str(tmp_path / "out.epub")
267
+ ensure_mimetype(files)
268
+ write_epub(out, files, package.order)
269
+ assert check_epub(out, probe_audio=False)["ok"]
270
+ with zipfile.ZipFile(out) as archive:
271
+ for index, doc in enumerate(package.docs):
272
+ root = etree.fromstring(archive.read(f"EPUB/media-overlays/chapter{index + 1}.smil"))
273
+ clips = list(root.iter(f"{{{SMIL_NS}}}audio"))
274
+ expected_start = index * 8 * 3600 + 15.125 if shared_track else 15.125
275
+ assert parse_clock(clips[0].get("clipBegin")) == expected_start
276
+ assert len(clips) == len(doc.sentences) == 450
277
+ track = tracks[0 if shared_track else index]
278
+ vtt = build_vtt(by_doc[doc.manifest_id], track, track.href)
279
+ assert ("08:00:15.125" if shared_track and index else "00:00:15.125") in vtt
280
+ for info in archive.infolist():
281
+ if info.filename.endswith(".mp3"):
282
+ assert info.compress_type == zipfile.ZIP_STORED
283
+ assert archive.read(info) == b"synthetic audio"
284
+
285
+
214
286
  def _clip_past_the_end(root):
215
287
  root.find(f".//{{{SMIL_NS}}}audio").set("clipEnd", "9:59:59.000")
216
288
 
@@ -42,6 +42,31 @@ def test_collision_suffix_does_not_overwrite_an_existing_chapter_name(tmp_path):
42
42
  assert all(Path(path).is_file() for path in paths)
43
43
 
44
44
 
45
+ @pytest.mark.parametrize("href", [
46
+ r"..\escaped.xhtml", r"C:\escaped.xhtml", "CON.xhtml", "NUL.part.xhtml",
47
+ "chapter:one.xhtml", "chapter?.xhtml", "...xhtml", "COM1.xhtml", "LPT².xhtml",
48
+ "CONIN$.xhtml", "control\x01.xhtml",
49
+ ])
50
+ def test_sidecar_names_are_safe_local_leaves(tmp_path, href):
51
+ _, result, tracks = prepared(tmp_path)
52
+ directory = tmp_path / "sidecars"
53
+ paths = write_sidecars(str(directory), result, tracks, {0: href, 1: "chapter2.xhtml"}, {})
54
+ assert all(Path(path).resolve().parent == directory.resolve() for path in paths)
55
+ assert all(Path(path).is_file() for path in paths)
56
+ assert not (tmp_path / "escaped.vtt").exists()
57
+ names = [Path(path).name for path in paths]
58
+ assert all(not any(c in name for c in '<>:"|?*\\') for name in names)
59
+ devices = {"CON", "NUL", "COM1", "LPT²", "CONIN$"}
60
+ assert all(name.split(".", 1)[0].upper() not in devices for name in names)
61
+
62
+
63
+ def test_sanitized_chapter_names_remain_unique(tmp_path):
64
+ _, result, tracks = prepared(tmp_path)
65
+ paths = write_sidecars(str(tmp_path / "sidecars"), result, tracks,
66
+ {0: "chapter?.xhtml", 1: "chapter*.xhtml"}, {})
67
+ assert len(paths) == len({Path(path).name.casefold() for path in paths}) == 3
68
+
69
+
45
70
  def prepared(tmp_path):
46
71
  package = load_epub(build_epub(str(tmp_path / "in.epub")))
47
72
  sentences = [sentence for doc in package.docs for sentence in doc.sentences]
@@ -1,77 +0,0 @@
1
- """Input discovery: patterns, directories, and the ordering between numbered files."""
2
-
3
- import pytest
4
-
5
- from readalign.audio import (
6
- _chapters_from,
7
- _duration_of,
8
- discover_inputs,
9
- natural_key,
10
- prepare_tracks,
11
- )
12
- from readalign.errors import DRMError, InputError
13
-
14
-
15
- def test_natural_order_puts_part2_before_part10():
16
- names = ["part10.mp3", "part2.mp3", "part1.mp3"]
17
- assert sorted(names, key=natural_key) == ["part1.mp3", "part2.mp3", "part10.mp3"]
18
-
19
-
20
- def test_a_glob_pattern_is_expanded_in_natural_order(tmp_path):
21
- """The shell does not expand patterns for a native command on Windows, so readalign must."""
22
- for name in ("part10.mp3", "part2.mp3", "part1.mp3"):
23
- (tmp_path / name).write_bytes(b"x")
24
- assert discover_inputs(str(tmp_path / "part*.mp3")) == [
25
- str(tmp_path / "part1.mp3"),
26
- str(tmp_path / "part2.mp3"),
27
- str(tmp_path / "part10.mp3"),
28
- ]
29
-
30
-
31
- def test_a_glob_that_matches_nothing_is_a_clear_error(tmp_path):
32
- with pytest.raises(InputError, match="matched no audio files"):
33
- discover_inputs(str(tmp_path / "book-*.mp3"))
34
-
35
-
36
- def test_a_glob_skips_files_that_are_not_audio(tmp_path):
37
- (tmp_path / "a.mp3").write_bytes(b"x")
38
- (tmp_path / "cover.jpg").write_bytes(b"x")
39
- assert discover_inputs(str(tmp_path / "*")) == [str(tmp_path / "a.mp3")]
40
-
41
-
42
- def test_a_drm_file_a_glob_matches_is_refused(tmp_path):
43
- (tmp_path / "a.mp3").write_bytes(b"x")
44
- (tmp_path / "b.aax").write_bytes(b"x")
45
- with pytest.raises(DRMError, match=r"b\.aax"):
46
- discover_inputs(str(tmp_path / "*"))
47
-
48
-
49
- @pytest.mark.parametrize("duration", ["inf", "nan", "-1", "N/A", {}])
50
- def test_invalid_audio_duration_is_rejected(duration):
51
- probe = {"format": {"duration": duration}, "streams": [{"codec_type": "audio"}]}
52
- with pytest.raises(InputError, match="no readable duration"):
53
- _duration_of(probe, "audio.mp3")
54
-
55
-
56
- def test_invalid_chapter_marks_are_ignored():
57
- probe = {"chapters": [
58
- {"start_time": "nan", "end_time": "inf"},
59
- {"start_time": "-1", "end_time": "2"},
60
- {"start_time": "0", "end_time": "3"},
61
- ]}
62
- assert len(_chapters_from(probe)) == 1
63
-
64
-
65
- def test_work_directory_cannot_overwrite_any_source_audio(tmp_path, monkeypatch):
66
- from readalign import audio
67
-
68
- directory = tmp_path / "audio"
69
- directory.mkdir()
70
- source = directory / "part001.mp3"
71
- source.write_bytes(b"original")
72
- monkeypatch.setattr(audio, "ffprobe", lambda path: {
73
- "streams": [{"codec_type": "audio", "codec_name": "mp3"}],
74
- })
75
- with pytest.raises(InputError, match="overwrite source audio"):
76
- prepare_tracks([str(source)], str(tmp_path), "96k", log=lambda *_: None)
77
- assert source.read_bytes() == b"original"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes