diff --git a/README.md b/README.md index 6be6f2e..4fe81f3 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ which simplifies installing python applications into isolated virtualenvs: $ pipx install git+https://github.com/MawKKe/audiobook-split-ffmpeg -afterwards, the `audiobook-split-ffmpeg` command should be available via your `PATH`. +afterwards, the `audiobook-split-ffmpeg` command should be available via your `PATH`. Next, see Usage below. @@ -72,6 +72,14 @@ the concurrency might not increase the throughput. (We specifically instruct `ff re-encoding, so most of the processing work consists of copying the existing encoded audio data from the input file to the output file(s) - this kind of processing is more I/O bounded than CPU-bounded). +You may specify the desired output file format. +For example, if you need to transcode a video file into a folder containing audio files. + + $ audiobook-split-ffmpeg --infile /path/to/video.mp4 --out-ext mp3 + +This command will create the directory containing audio files: + `/path/to/video/audio_1.mp3`, `/path/to/video/audio_2.mp3`, etc. + # Dependencies This application has no 3rd party library dependencies, as everything is @@ -121,4 +129,3 @@ See file `LICENSE` for more information. This project is hosted at https://github.com/MawKKe/audiobook-split-ffmpeg You are welcome to leave bug reports, fixes and feature requests. Thanks! - diff --git a/pyproject.toml b/pyproject.toml index 8c8bc7f..09c9397 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ Issues = 'https://github.com/MawKKe/audiobook-split-ffmpeg/issues' [project.scripts] audiobook-split-ffmpeg = 'audiobook_split_ffmpeg.cli:main' +audiobook-split-ffmpeg-mp3 = 'audiobook_split_ffmpeg.cli:main_mp3_mode' [tool.pytest.ini_options] pythonpath = [ diff --git a/src/audiobook_split_ffmpeg/__init__.py b/src/audiobook_split_ffmpeg/__init__.py index 84e532a..cec0ba4 100644 --- a/src/audiobook_split_ffmpeg/__init__.py +++ b/src/audiobook_split_ffmpeg/__init__.py @@ -52,4 +52,4 @@ 'compute_workitems', ] __author__ = 'Markus Holmström (MawKKe) ' -__version__ = '0.2.0' +__version__ = '0.2.5' diff --git a/src/audiobook_split_ffmpeg/cli.py b/src/audiobook_split_ffmpeg/cli.py index 544dbd3..21eb266 100644 --- a/src/audiobook_split_ffmpeg/cli.py +++ b/src/audiobook_split_ffmpeg/cli.py @@ -16,6 +16,7 @@ CLI application implementation for audiobook-split-ffmpeg """ +import os import sys import shlex import argparse @@ -28,7 +29,7 @@ from .workers import process_workitems -def parse_args(argv: t.List[str]) -> argparse.Namespace: +def parse_args(argv: t.List[str], mp3_default: bool = False) -> argparse.Namespace: """ Parse argv into argparse.Namespace @@ -37,22 +38,40 @@ def parse_args(argv: t.List[str]) -> argparse.Namespace: argv a list of strings, usually the value of sys.argv + mp3_default + set default value of out-ext to `mp3` + WARNING: If argv is malformed, the process will exit. Avoid using this function in tests. """ parser = argparse.ArgumentParser( description='Split audiobook chapters using ffmpeg', epilog=f'version {__version__}' ) - parser.add_argument( + + infile_excl = parser.add_mutually_exclusive_group(required=True) + infile_excl.add_argument( + 'infile_positional', + nargs='*', + help='Input file. Chapter information must be present in file metadata', + ) + infile_excl.add_argument( '--infile', - required=True, + nargs='*', help='Input file. Chapter information must be present in file metadata', ) + parser.add_argument( '--outdir', - required=True, + required=False, + default=None, help='Output directory. Created if does not exist yet.', ) + parser.add_argument( + '--out-ext', + required=False, + default=("mp3" if mp3_default else None), + help='New file extension. If conversion to another format is required.', + ) parser.add_argument( '--concurrency', required=False, @@ -95,6 +114,7 @@ def parse_args(argv: t.List[str]) -> argparse.Namespace: ) args = parser.parse_args(argv[1:]) + args.infile = (args.infile_positional or args.infile) return args @@ -113,32 +133,54 @@ def _main(args: argparse.Namespace) -> int: if args.verbose: print('args:', args) - work_items = list( - compute_workitems( - args.infile, - args.outdir, - enumerate_files=args.enumerate_files, - use_title_in_filenames=args.use_title, + def parse_path(*pathes): + for _infile in map(os.path.abspath, pathes): + if os.path.isfile(_infile): + yield _infile + elif os.path.isdir(_infile): + yield from parse_path( + *map(lambda p: os.path.join(_infile, p), os.listdir(_infile)) + ) + else: + raise RuntimeError("No path \"{0}\" was found".format(_infile)) + + for infile in parse_path(*args.infile): + + outdir = args.outdir + if outdir is None: + outdir, _ = os.path.splitext(infile) + + work_items = list( + compute_workitems( + infile, + outdir, + enumerate_files=args.enumerate_files, + use_title_in_filenames=args.use_title, + out_ext=args.out_ext + ) ) - ) - if args.verbose: - print('Found: {0} chapters to be processed'.format(len(work_items))) - - if args.dry_run: - print('# NOTE: dry-run requested') - print(shlex.join(['mkdir', '-p', args.outdir])) - commands = (workitem_to_ffmpeg_cmd(wi) for wi in work_items) - escaped_cmds = (shlex.join(cmd) for cmd in commands) - for cmd in escaped_cmds: - print(cmd) - return 0 - - return process_workitems( - work_items, - args.outdir, - args.concurrency, - args.verbose, - ) + if args.verbose: + print('Found: {0} chapters to be processed'.format(len(work_items))) + + if args.dry_run: + print('# NOTE: dry-run requested') + print(shlex.join(['mkdir', '-p', outdir])) + commands = (workitem_to_ffmpeg_cmd(wi) for wi in work_items) + escaped_cmds = (shlex.join(cmd) for cmd in commands) + for cmd in escaped_cmds: + print(cmd) + return 0 + + return_code = process_workitems( + work_items, + outdir, + args.concurrency, + args.verbose, + ) + if return_code != 0: + return return_code + + return 0 def main() -> t.NoReturn: @@ -148,5 +190,9 @@ def main() -> t.NoReturn: sys.exit(_main(parse_args(sys.argv))) +def main_mp3_mode() -> t.NoReturn: + sys.exit(_main(parse_args(sys.argv, True))) + + if __name__ == '__main__': main() diff --git a/src/audiobook_split_ffmpeg/ffmpeg.py b/src/audiobook_split_ffmpeg/ffmpeg.py index 7dd9cc1..44925f3 100644 --- a/src/audiobook_split_ffmpeg/ffmpeg.py +++ b/src/audiobook_split_ffmpeg/ffmpeg.py @@ -77,6 +77,9 @@ def workitem_to_ffmpeg_cmd(w_item: WorkItem) -> t.List[str]: # and/or 'stty sane'. If corruption still occurs, let me know (email is at # the top of the file). + _, in_ext = os.path.splitext(w_item.infile) + _, out_ext = os.path.splitext(w_item.outfile) + base_cmd = [ 'ffmpeg', '-nostdin', @@ -86,9 +89,17 @@ def workitem_to_ffmpeg_cmd(w_item: WorkItem) -> t.List[str]: 'error', '-map_chapters', '-1', - '-vn', - '-c', - 'copy', + '-vn' + ] + + if in_ext.lower() == out_ext.lower(): + # Same format. No conversion needed. Copy the stream. + base_cmd += [ + '-c', + 'copy' + ] + + base_cmd += [ '-ss', w_item.start, '-to', @@ -128,6 +139,10 @@ def ffmpeg_split_chapter(w_item: WorkItem) -> t.Dict: # cmd is a a flat list of strings cmd = workitem_to_ffmpeg_cmd(w_item) + _dir = os.path.dirname(w_item.outfile) + if not os.path.isdir(_dir): + os.makedirs(_dir, exist_ok=True) + try: proc = sub.run( cmd, @@ -153,7 +168,11 @@ def ffmpeg_split_chapter(w_item: WorkItem) -> t.Dict: def compute_workitems( - infile: Path, outdir: Path, enumerate_files: bool = True, use_title_in_filenames: bool = True + infile: Path, + outdir: t.Optional[Path] = None, + enumerate_files: bool = True, + use_title_in_filenames: bool = True, + out_ext: t.Optional[str] = None ) -> t.Iterator[WorkItem]: """ Compute WorkItem's for each chapter to be processed. These WorkItems can be then used @@ -164,11 +183,15 @@ def compute_workitems( infile Path to an audio(book) file. Must contain chapter information in its metadata. outdir - Path to a directory where chapter files will be written. Must exist already. + Path to a directory where chapter files will be written. + If the value is `None`, a directory named `infile` (without an extension) will be used. enumerate_files Include chapter numbers in output filenames? use_title_in_filenames Include chapter titles in output filenames? + out_ext + A new file extension, in case conversion to another format is required. + If `None`, the extension remains unchanged. """ in_root, in_ext = os.path.splitext(os.path.basename(infile)) @@ -178,6 +201,13 @@ def compute_workitems( # Make sure extension has no leading dots. in_ext = in_ext[1:] if in_ext.startswith('.') else in_ext + if out_ext: + out_ext = out_ext[1:] if out_ext.startswith('.') else out_ext + else: + out_ext = in_ext + + if outdir is None: + outdir, _ = os.path.splitext(infile) # Get chapter metadata info = ffprobe_read_chapters(infile) @@ -210,11 +240,11 @@ def chnum_fmt(n: int) -> str: # Otherwise, use the root part of input filename title = title_maybe if (use_title_in_filenames and title_maybe) else in_root - out_base = '{title}.{ext}'.format(title=title, ext=in_ext) + out_base = '{title}.{ext}'.format(title=title, ext=out_ext) # Prepend chapter number if requested if enumerate_files: - out_base = '{0} - {1}'.format(chnum_fmt(ch_num), out_base) + out_base = '{0}. {1}'.format(chnum_fmt(ch_num), out_base) yield WorkItem( infile=infile,