diff options
Diffstat (limited to 'audiobook.py')
| -rwxr-xr-x | audiobook.py | 124 |
1 files changed, 121 insertions, 3 deletions
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, )) |
