aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md4
-rw-r--r--app/converter/converter.py54
-rw-r--r--app/tests/test_audiobook_cli.py312
-rwxr-xr-xaudiobook.py124
4 files changed, 474 insertions, 20 deletions
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 <dir>` | 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 <dir>` | Directory to write finished audiobooks to (default: the `OUTPUT_DIR` setting in `app/converter/config.py`, `./output`). |
+| `--input-file <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 <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 <n>` | 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 <lang>` | 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(
@@ -251,6 +297,24 @@ Examples:
"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 "
"chapters (e.g. EPUB) are converted to one file per chapter. "
@@ -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,
))