From 5611f709367677b77a482aa8e396fd3bc6a0ab26 Mon Sep 17 00:00:00 2001 From: historia Date: Thu, 27 Aug 2026 19:00:44 -0400 Subject: feat: flags for converting individual book files --input-book --output-book --- README.md | 4 +- app/converter/converter.py | 54 ++++--- app/tests/test_audiobook_cli.py | 312 ++++++++++++++++++++++++++++++++++++++++ audiobook.py | 124 +++++++++++++++- 4 files changed, 474 insertions(+), 20 deletions(-) create mode 100644 app/tests/test_audiobook_cli.py diff --git a/README.md b/README.md index dd51735..032bdb7 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ python audiobook.py ## CLI Options -Everything the TUI does can also be scripted with flags: `python audiobook.py --backend audiocpp --model higgs --voice narrator`. +Everything the TUI does can also be scripted with flags: `python audiobook.py --backend audiocpp --model higgs --voice narrator`. To convert a single book file instead of the whole input directory, pass `--input-file` (and optionally `--output-file`): `python audiobook.py --input-file books/dune.epub --output-file out/dune.mp3`. The directory flags (`--input`/`--output`) and the single-book flags (`--input-file`/`--output-file`) are mutually exclusive pairs — mixing one stops with an error explaining both. ## Options @@ -68,6 +68,8 @@ Everything the TUI does can also be scripted with flags: `python audiobook.py -- | `--format {mp3,m4b,ogg,flac}` | Output format (default: the `AUDIO_FORMAT` setting in `app/converter/config.py`, `m4b`). | | `--input ` | Directory containing the books to convert (default: the `INPUT_DIR` setting in `app/converter/config.py`, `./input`; relative paths resolve against the project root). | | `--output ` | Directory to write finished audiobooks to (default: the `OUTPUT_DIR` setting in `app/converter/config.py`, `./output`). | +| `--input-file ` | Convert one specific book (`.txt`/`.pdf`/`.epub`) instead of scanning a directory; cannot be combined with `--input`. Without `--output-file` the audiobook goes to the output directory under its usual narrator-tagged name. | +| `--output-file ` | Base path for the audiobook from `--input-file` (requires it; cannot be combined with `--output`): the file's parent folder receives the audio and its stem is the base output name, without the narrator tag (chapters as `STEM_NN_Title.ext`). The extension must match the output format (`--format` / `AUDIO_FORMAT`) or the run stops without converting. | | `--speed ` | Playback speed, pitch-preserving (`1.0` = normal). A normal-speed copy is also output. Defaults to the `SPEED` setting in `app/converter/config.py`. | | `--single-file` | Merge all chapters into a single file. `m4b` is always one file. | | `--language ` | Output language for the synthesized speech. Can add an accent even if the text is English. | diff --git a/app/converter/converter.py b/app/converter/converter.py index d2d06b9..e1649c2 100644 --- a/app/converter/converter.py +++ b/app/converter/converter.py @@ -788,6 +788,8 @@ class AudiobookConverter: output_format: str, instructions: Optional[str] = None, confirm: Optional[Callable[[str, bool], bool]] = None, + book_files: Optional[List[Path]] = None, + output_name: Optional[str] = None, ) -> Tuple[List[Path], List[Tuple[Path, str]]]: """Discover books and ask every overwrite question up front. @@ -801,35 +803,55 @@ class AudiobookConverter: nothing to convert) never waits on a slow server handshake. CONFIRM replaces the console ``input()`` prompt (the hub passes a TUI yes/no dialog). + + BOOK_FILES overrides the books-folder scan with an explicit list + (a single --input-file book; still filtered to supported formats), + and OUTPUT_NAME overrides the computed output name with a verbatim + base name (--output-file's stem, no narrator tag or stem-collision + suffix). Both default to the directory-scan behavior. """ - book_files = sorted( - f for f in BOOKS_FOLDER.iterdir() - if f.is_file() and f.suffix.lower() in SUPPORTED_FORMATS - ) + if book_files is None: + book_files = sorted( + f for f in BOOKS_FOLDER.iterdir() + if f.is_file() and f.suffix.lower() in SUPPORTED_FORMATS + ) + else: + book_files = sorted( + f for f in book_files + if f.is_file() and f.suffix.lower() in SUPPORTED_FORMATS + ) if not book_files: return [], [] print(f"[INFO] Found {len(book_files)} books to convert") - # Avoid output collisions when two books share a stem (e.g. dune.txt + dune.epub). - stem_counts: Dict[str, int] = Counter(book_file.stem for book_file in book_files) + # Compute the output name each book would produce. An explicit + # name (--output-file) is used verbatim for the single book; + # otherwise names carry the narrator tag and a stem-collision + # suffix when two books share a stem (e.g. dune.txt + dune.epub). + if output_name is not None: + names = [(book_files[0], output_name)] + else: + stem_counts: Dict[str, int] = Counter(book_file.stem for book_file in book_files) + narrator_tag = AudiobookConverter.compute_narrator_tag( + backend, voice, voice_mode, voice_clone_ref_audio, instructions) + names = [] + for book_file in book_files: + name = book_file.stem + if stem_counts[book_file.stem] > 1: + name = f"{book_file.stem}_{book_file.suffix.lstrip('.')}" + names.append((book_file, f"{name}_{narrator_tag}")) # Ask every overwrite question up front, before any conversion # starts, so the rest of the run is unattended. planned: List[Tuple[Path, str]] = [] - narrator_tag = AudiobookConverter.compute_narrator_tag( - backend, voice, voice_mode, voice_clone_ref_audio, instructions) - for book_file in book_files: - output_name = book_file.stem - if stem_counts[book_file.stem] > 1: - output_name = f"{book_file.stem}_{book_file.suffix.lstrip('.')}" - output_name = f"{output_name}_{narrator_tag}" - existing = find_existing_outputs(output_name, output_format) - if existing and not prompt_overwrite(existing, output_name, + for book_file, name in names: + existing = find_existing_outputs(name, output_format) + if existing and not prompt_overwrite(existing, name, confirm=confirm): print(f"[INFO] Skipping {book_file.name} (existing output kept)") continue - planned.append((book_file, output_name)) + planned.append((book_file, name)) return book_files, planned # ------------------------------------------------------------------ diff --git a/app/tests/test_audiobook_cli.py b/app/tests/test_audiobook_cli.py new file mode 100644 index 0000000..e4b558b --- /dev/null +++ b/app/tests/test_audiobook_cli.py @@ -0,0 +1,312 @@ +"""Tests for the audiobook.py CLI — single-book flags and arg validation. + +audiobook.py lives at the repo root (one level above app/), so the tests +bootstrap the root onto sys.path to import it. main() runs with the +managed-environment bootstrap stubbed (it would otherwise re-exec the +process into envs/tts) and convert() mocked, asserting only argparse +behavior and what reaches convert(); convert()'s single-book wiring and +the pre-flight overrides are tested against the real functions with +temporary directories. +""" + +import contextlib +import io +import shutil +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +# audiobook.py sits at the repo root, two levels above this test module. +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +import audiobook # noqa: E402 +from converter import config # noqa: E402 +from converter import converter as converter_mod # noqa: E402 +from converter.converter import AudiobookConverter # noqa: E402 + + +def _make_book(tmp: Path, name: str = "dune.txt") -> Path: + book = tmp / name + book.write_text("A beginning is a very delicate time.", encoding="utf-8") + return book + + +class MainTestCase(unittest.TestCase): + """Base: run audiobook.main() with argv, bootstrap stubbed, convert mocked.""" + + def setUp(self): + self.tmp = Path(tempfile.mkdtemp(prefix="audiobook_cli_")) + self.addCleanup(shutil.rmtree, self.tmp, True) + + def run_main(self, argv): + """Run main() with the given argv; returns (code, stderr, convert mock). + + The envs bootstrap (which re-execs into the managed venv via + os.execv when active) and convert() are stubbed, so no TTS work + happens and the process survives. + """ + err = io.StringIO() + convert = MagicMock(return_value=0) + with patch.object(sys, "argv", ["audiobook.py", *argv]), \ + contextlib.redirect_stderr(err), \ + patch.object(audiobook._envs, "bootstrap"), \ + patch.object(audiobook, "convert", convert): + try: + audiobook.main() + code = None + except SystemExit as exc: + code = exc.code + return code, err.getvalue(), convert + + +class MainFlagConflictTests(MainTestCase): + """Mixing the directory and single-book flag pairs stops with an error.""" + + def test_input_and_input_file_conflict(self): + code, err, convert = self.run_main( + ["--input", str(self.tmp), "--input-file", str(self.tmp / "dune.txt")]) + self.assertEqual(code, 2) + self.assertIn("--input", err) + self.assertIn("--input-file", err) + self.assertIn("cannot be used together", err) + convert.assert_not_called() + + def test_output_and_output_file_conflict(self): + code, err, convert = self.run_main( + ["--output", str(self.tmp / "out"), + "--output-file", str(self.tmp / "out" / "dune.mp3")]) + self.assertEqual(code, 2) + self.assertIn("--output", err) + self.assertIn("--output-file", err) + self.assertIn("cannot be used together", err) + convert.assert_not_called() + + def test_output_file_requires_input_file(self): + code, err, convert = self.run_main( + ["--output-file", str(self.tmp / "dune.mp3")]) + self.assertEqual(code, 2) + self.assertIn("--output-file", err) + self.assertIn("--input-file", err) + convert.assert_not_called() + + def test_conflict_wins_over_bad_directory(self): + # The flag explanation fires even when --input is also invalid. + code, err, convert = self.run_main( + ["--input", str(self.tmp / "nope"), + "--input-file", str(self.tmp / "dune.txt")]) + self.assertEqual(code, 2) + self.assertIn("cannot be used together", err) + convert.assert_not_called() + + +class MainPathValidationTests(MainTestCase): + """--input-file/--output-file values are validated before converting.""" + + def test_missing_input_file(self): + code, err, convert = self.run_main( + ["--input-file", str(self.tmp / "nope.txt")]) + self.assertEqual(code, 2) + self.assertIn("no such book file", err) + convert.assert_not_called() + + def test_unsupported_input_file_format(self): + book = _make_book(self.tmp, "dune.docx") + code, err, convert = self.run_main(["--input-file", str(book)]) + self.assertEqual(code, 2) + self.assertIn("unsupported book format", err) + self.assertIn(".docx", err) + convert.assert_not_called() + + def test_output_file_extension_mismatch_stops_the_run(self): + book = _make_book(self.tmp) + code, err, convert = self.run_main( + ["--input-file", str(book), + "--output-file", str(self.tmp / "dune.mp3"), + "--format", "m4b"]) + self.assertEqual(code, 2) + self.assertIn("does not match the output format", err) + self.assertIn("--format mp3", err) + convert.assert_not_called() + + def test_output_file_unsupported_extension(self): + book = _make_book(self.tmp) + code, err, convert = self.run_main( + ["--input-file", str(book), + "--output-file", str(self.tmp / "dune.xyz")]) + self.assertEqual(code, 2) + self.assertIn("unsupported extension", err) + convert.assert_not_called() + + +class MainHappyPathTests(MainTestCase): + """Valid single-book flags reach convert() resolved and typed.""" + + def test_input_and_output_file_forwarded(self): + book = _make_book(self.tmp) + target = self.tmp / "out" / "dune.mp3" + code, _, convert = self.run_main( + ["--input-file", str(book), "--output-file", str(target), + "--format", "mp3"]) + self.assertEqual(code, 0) + convert.assert_called_once() + kwargs = convert.call_args.kwargs + self.assertEqual(kwargs["input_file"], book) + self.assertEqual(kwargs["output_file"], target) + self.assertEqual(kwargs["output_format"], "mp3") + self.assertIsNone(kwargs["input_dir"]) + self.assertIsNone(kwargs["output_dir"]) + + def test_input_file_alone_keeps_output_defaults(self): + book = _make_book(self.tmp) + code, _, convert = self.run_main(["--input-file", str(book)]) + self.assertEqual(code, 0) + kwargs = convert.call_args.kwargs + self.assertEqual(kwargs["input_file"], book) + self.assertIsNone(kwargs["output_file"]) + self.assertEqual(kwargs["output_format"], config.AUDIO_FORMAT) + + def test_directory_flags_still_forwarded(self): + out = self.tmp / "out" + code, _, convert = self.run_main( + ["--input", str(self.tmp), "--output", str(out)]) + self.assertEqual(code, 0) + kwargs = convert.call_args.kwargs + self.assertEqual(kwargs["input_dir"], self.tmp) + self.assertEqual(kwargs["output_dir"], out) + self.assertIsNone(kwargs["input_file"]) + self.assertIsNone(kwargs["output_file"]) + + +class ConvertWiringTests(unittest.TestCase): + """convert() turns the single-book flags into the pre-flight overrides.""" + + def setUp(self): + self.tmp = Path(tempfile.mkdtemp(prefix="audiobook_wiring_")) + self.addCleanup(shutil.rmtree, self.tmp, True) + self.book = _make_book(self.tmp) + # convert() repoints the converter module's folder globals; restore + # them so other tests keep seeing the configured folders. + self._old_folders = (converter_mod.BOOKS_FOLDER, + converter_mod.AUDIOBOOKS_FOLDER) + self.addCleanup(self._restore_folders) + + def _restore_folders(self): + converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER = \ + self._old_folders + + def _convert(self, **kwargs): + preflight = MagicMock( + return_value=([self.book], [(self.book, "dune")])) + fake_instance = MagicMock() + fake_instance.run.return_value = True + fake_class = MagicMock(return_value=fake_instance) + fake_class.preflight_overwrites = preflight + with patch.object(audiobook, "setup_logging"), \ + patch.object(audiobook, "setup_directories"), \ + patch.object(audiobook, "AudiobookConverter", fake_class): + code = audiobook.convert(**kwargs) + return code, preflight, fake_class, fake_instance + + def test_output_file_redirects_folder_and_names_the_book(self): + out = self.tmp / "out" + code, preflight, _, fake_instance = self._convert( + input_file=self.book, output_file=out / "dune.mp3", + output_format="mp3") + self.assertEqual(code, 0) + self.assertEqual(converter_mod.AUDIOBOOKS_FOLDER, out) + self.assertEqual(preflight.call_args.kwargs["book_files"], [self.book]) + self.assertEqual(preflight.call_args.kwargs["output_name"], "dune") + self.assertEqual(fake_instance._book_files, [self.book]) + self.assertEqual(fake_instance._planned, [(self.book, "dune")]) + + def test_output_file_without_extension_uses_stem(self): + out = self.tmp / "out" + _, preflight, _, _ = self._convert( + input_file=self.book, output_file=out / "dune") + self.assertEqual(preflight.call_args.kwargs["output_name"], "dune") + + def test_input_file_alone_keeps_output_folder_and_tagged_name(self): + _, preflight, _, _ = self._convert(input_file=self.book) + self.assertEqual(preflight.call_args.kwargs["book_files"], + [self.book]) + self.assertIsNone(preflight.call_args.kwargs["output_name"]) + self.assertEqual( + converter_mod.AUDIOBOOKS_FOLDER, + converter_mod.resolve_dir(config.OUTPUT_DIR, "output")) + + def test_output_file_requires_input_file(self): + with self.assertRaises(ValueError): + self._convert(output_file=self.tmp / "dune.mp3") + + +class PreflightOverrideTests(unittest.TestCase): + """preflight_overwrites honors the explicit book list and output name.""" + + def setUp(self): + self.tmp = Path(tempfile.mkdtemp(prefix="audiobook_preflight_")) + self.addCleanup(shutil.rmtree, self.tmp, True) + self.book = _make_book(self.tmp) + # The overwrite check globs AUDIOBOOKS_FOLDER; point it at the + # temporary folder so the repo's real output dir stays untouched. + patcher = patch.object(converter_mod, "AUDIOBOOKS_FOLDER", self.tmp) + patcher.start() + self.addCleanup(patcher.stop) + + def _preflight(self, **kwargs): + options = dict(backend="audiocpp", voice="Vivian", + voice_mode="custom", voice_clone_ref_audio=None, + output_format="mp3") + options.update(kwargs) + return AudiobookConverter.preflight_overwrites(**options) + + def test_explicit_book_and_output_name_used_verbatim(self): + book_files, planned = self._preflight(book_files=[self.book], + output_name="dune") + self.assertEqual(book_files, [self.book]) + self.assertEqual(planned, [(self.book, "dune")]) + + def test_explicit_output_name_skips_narrator_tag(self): + _, planned = self._preflight(book_files=[self.book], + output_name="dune") + # A directory scan would append the narrator tag (dune_Vivian). + self.assertNotIn("Vivian", planned[0][1]) + + def test_unsupported_books_filtered_from_explicit_list(self): + stray = self.tmp / "notes.docx" + stray.write_text("nope", encoding="utf-8") + book_files, planned = self._preflight( + book_files=[self.book, stray], output_name="dune") + self.assertEqual(book_files, [self.book]) + self.assertEqual(planned, [(self.book, "dune")]) + + def test_declined_overwrite_yields_empty_planned(self): + existing = self.tmp / "dune.mp3" + existing.write_bytes(b"prior audio") + with patch.object(converter_mod, "prompt_overwrite", + return_value=False): + book_files, planned = self._preflight(book_files=[self.book], + output_name="dune") + self.assertEqual(book_files, [self.book]) + self.assertEqual(planned, []) + + def test_accepted_overwrite_plans_the_book(self): + existing = self.tmp / "dune.mp3" + existing.write_bytes(b"prior audio") + with patch.object(converter_mod, "prompt_overwrite", + return_value=True): + book_files, planned = self._preflight(book_files=[self.book], + output_name="dune") + self.assertEqual(planned, [(self.book, "dune")]) + + def test_empty_explicit_list_nothing_to_convert(self): + book_files, planned = self._preflight(book_files=[], + output_name="dune") + self.assertEqual((book_files, planned), ([], [])) + + +if __name__ == "__main__": + unittest.main() diff --git a/audiobook.py b/audiobook.py index 03ba7c9..7bdd989 100755 --- a/audiobook.py +++ b/audiobook.py @@ -5,7 +5,9 @@ Converts TXT, PDF and EPUB files into audiobooks using a local TTS server. Run with no arguments in a terminal for the full TUI (set up backends, process the input directory); pass flags to script a conversion directly. -Edit app/converter/config.py to change voice and processing settings. +--input-file/--output-file convert one individual book file instead of a +directory (the two flag pairs are mutually exclusive). Edit +app/converter/config.py to change voice and processing settings. """ import argparse @@ -48,9 +50,23 @@ from converter.clients import ( from converter.converter import ( AUDIO_FORMATS, AudiobookConverter, + SUPPORTED_FORMATS, setup_directories, setup_logging, ) +from converter.converter import BASE_DIR as _BASE_DIR + + +def resolve_book_path(path: Path) -> Path: + """Resolve a CLI book/output-file path argument. + + "~" expands to the home directory and relative paths resolve against + the project root, mirroring how the --input/--output directory flags + are resolved, so the converter behaves the same from any working + directory. + """ + resolved = Path(path).expanduser() + return resolved if resolved.is_absolute() else _BASE_DIR / resolved def convert(backend: str = None, voice: str = None, clone: str = None, @@ -60,6 +76,7 @@ def convert(backend: str = None, voice: str = None, clone: str = None, model_id: str = None, instructions: str = None, request_options: dict = None, input_dir: Path = None, output_dir: Path = None, api_url: str = None, + input_file: Path = None, output_file: Path = None, progress=None, cancel=None, confirm=None, book_files=None, planned=None) -> int: """Run one conversion pass with explicit options (used by the CLI and hub). @@ -74,6 +91,14 @@ def convert(backend: str = None, voice: str = None, clone: str = None, for the selected backend (used by the hub's "[remote]" entries and --api-url). + INPUT_FILE converts a single book file instead of scanning the + input folder (the CLI validates it and resolves relative paths + against the project root). OUTPUT_FILE, which requires INPUT_FILE, + redirects that book's audio to an explicit base path: its parent + folder receives the files and its stem is the base output name + (chapter files gain _NN_Title suffixes), used verbatim without the + narrator tag. + PROGRESS (a callback taking an event dict), CANCEL (a threading.Event the caller sets to stop between requests), CONFIRM (a (message, default) -> bool callback replacing the console @@ -88,6 +113,22 @@ def convert(backend: str = None, voice: str = None, clone: str = None, input_dir = config.INPUT_DIR if input_dir is None else input_dir output_dir = config.OUTPUT_DIR if output_dir is None else output_dir request_options = request_options or {} + + # --input-file converts one book instead of scanning the input + # folder; --output-file (requires it) sends that book's audio to an + # explicit base path: the parent folder receives the files and the + # stem is the base name, so redirect the output folder there. + single_book: Path = None + output_name_override: str = None + if input_file is not None or output_file is not None: + if output_file is not None and input_file is None: + raise ValueError("output_file requires input_file") + single_book = resolve_book_path(input_file) + if output_file is not None: + output_file = resolve_book_path(output_file) + output_dir = output_file.parent + output_name_override = output_file.stem + _converter_mod.BOOKS_FOLDER = _converter_mod.resolve_dir( input_dir, "input") _converter_mod.AUDIOBOOKS_FOLDER = _converter_mod.resolve_dir( @@ -114,6 +155,8 @@ def convert(backend: str = None, voice: str = None, clone: str = None, backend=backend, voice=voice, voice_mode=voice_mode, voice_clone_ref_audio=clone, output_format=output_format, instructions=instructions, confirm=confirm, + book_files=[single_book] if single_book is not None else None, + output_name=output_name_override, ) if not book_files: print("[INFO] Nothing to convert. Add a .txt, .pdf, or .epub file " @@ -199,6 +242,9 @@ Examples: # Use the faster-qwen3-tts server (voice cloning, configured server-side) python audiobook.py --backend faster [--voice NAME] + + # Convert one specific book file instead of scanning the input directory + python audiobook.py --input-file books/dune.epub --output-file out/dune.mp3 """ ) @@ -234,7 +280,7 @@ Examples: "app/converter/config.py.") ) parser.add_argument( - "--format", choices=list(AUDIO_FORMATS), default=config.AUDIO_FORMAT, + "--format", choices=list(AUDIO_FORMATS), default=None, help=f"Output container format (default: {config.AUDIO_FORMAT}). m4b uses AAC audio." ) parser.add_argument( @@ -250,6 +296,24 @@ Examples: "OUTPUT_DIR setting in app/converter/config.py (./output); " "relative paths resolve against the project root.") ) + parser.add_argument( + "--input-file", type=Path, metavar="FILE", default=None, + help=("Convert one specific book file (.txt/.pdf/.epub) instead of " + "scanning a directory for books; cannot be combined with " + "--input. Relative paths resolve against the project root. " + "Without --output-file, the audiobook goes to the output " + "directory under its usual narrator-tagged name.") + ) + parser.add_argument( + "--output-file", type=Path, metavar="FILE", default=None, + help=("Base path for the audiobook produced from --input-file " + "(requires it; cannot be combined with --output): the file's " + "parent folder receives the audio and its stem is the base " + "output name, without the narrator tag. Chapter files are " + "written as STEM_NN_Title.ext. The extension must match the " + "output format (--format or the AUDIO_FORMAT config setting) " + "or the run stops without converting.") + ) parser.add_argument( "--single-file", action="store_true", help=("Combine all chapters into a single audio file. By default books with " @@ -337,9 +401,62 @@ Examples: if bad_speed: parser.error(f"--speed must be a positive number (got {speed!r})") + # The directory flags and the single-book flags are two different + # ways to choose what to convert and where it goes; mixing a pair + # is always a mistake, so stop here and explain both flags. + if args.input is not None and args.input_file is not None: + parser.error( + "--input and --input-file cannot be used together: --input " + "converts every supported book found in a directory, while " + "--input-file converts one specific book file. Pass only one " + "of the two.") + if args.output is not None and args.output_file is not None: + parser.error( + "--output and --output-file cannot be used together: --output " + "names the directory that receives finished audiobooks, while " + "--output-file names the file produced from --input-file (its " + "parent folder + stem). Pass only one of the two.") + if args.output_file is not None and args.input_file is None: + parser.error( + "--output-file requires --input-file: it names the output of " + "one specific book, and there is nothing to attach it to when " + "converting a whole directory (use --output instead).") + if args.input is not None and not args.input.is_dir(): parser.error(f"--input: no such directory: {args.input}") + # An explicit --format overrides the config AUDIO_FORMAT setting; + # the resolved format is what --output-file's extension must match. + output_format = args.format or config.AUDIO_FORMAT + + input_file = None + if args.input_file is not None: + input_file = resolve_book_path(args.input_file) + if not input_file.is_file(): + parser.error(f"--input-file: no such book file: {input_file}") + if input_file.suffix.lower() not in SUPPORTED_FORMATS: + parser.error( + f"--input-file: unsupported book format " + f"{input_file.suffix or '(no extension)'} - want one of: " + f"{', '.join(SUPPORTED_FORMATS)}") + + output_file = None + if args.output_file is not None: + output_file = resolve_book_path(args.output_file) + extension = output_file.suffix.lower().lstrip(".") + if extension and extension not in AUDIO_FORMATS: + parser.error( + f"--output-file: unsupported extension .{extension} - want " + f"one of: .{', .'.join(AUDIO_FORMATS)} (or drop the " + "extension to use the output format)") + if extension and extension != output_format: + parser.error( + f"--output-file: extension .{extension} does not match the " + f"output format {output_format} (from --format or the " + "AUDIO_FORMAT setting in app/converter/config.py) - pass " + f"--format {extension} or name the file " + f"{output_file.stem}.{output_format}") + if args.backend == BACKEND_FASTER: if args.clone: print("[WARNING] --clone is ignored with --backend faster: that backend " @@ -425,11 +542,12 @@ Examples: backend=args.backend, voice=args.voice, clone=args.clone, transcription=args.transcription, no_transcription=args.no_transcription, language=args.language, speed=args.speed, single_file=args.single_file, - output_format=args.format, debug=args.debug or None, + output_format=output_format, debug=args.debug or None, model_id=args.model, instructions=args.instructions, request_options=request_options, input_dir=args.input, output_dir=args.output, api_url=api_url, + input_file=input_file, output_file=output_file, )) -- cgit v1.2.3