From 1b161c06336da600bfc9d34827d7c9871e49a635 Mon Sep 17 00:00:00 2001 From: Vladya Date: Sun, 14 Jun 2026 23:36:06 +0300 Subject: [PATCH 1/2] Some useful updates Automatic detection of the output folder based on the source file has been added. The option to select the extension of the output file has been added. For example, if you need to create a folder containing mp3 files based on a downloaded YouTube playlist in the form of a single video file. --- README.md | 11 +++++-- src/audiobook_split_ffmpeg/__init__.py | 2 +- src/audiobook_split_ffmpeg/cli.py | 21 +++++++++--- src/audiobook_split_ffmpeg/ffmpeg.py | 44 ++++++++++++++++++++++---- 4 files changed, 64 insertions(+), 14 deletions(-) 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/src/audiobook_split_ffmpeg/__init__.py b/src/audiobook_split_ffmpeg/__init__.py index 84e532a..bb3055c 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.1' diff --git a/src/audiobook_split_ffmpeg/cli.py b/src/audiobook_split_ffmpeg/cli.py index 544dbd3..0d0db5a 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 @@ -50,9 +51,16 @@ def parse_args(argv: t.List[str]) -> argparse.Namespace: ) 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=None, + help='New file extension. If conversion to another format is required.', + ) parser.add_argument( '--concurrency', required=False, @@ -113,12 +121,17 @@ def _main(args: argparse.Namespace) -> int: if args.verbose: print('args:', args) + outdir = args.outdir + if outdir is None: + outdir, _ = os.path.splitext(args.infile) + work_items = list( compute_workitems( args.infile, - args.outdir, + outdir, enumerate_files=args.enumerate_files, use_title_in_filenames=args.use_title, + out_ext=args.out_ext ) ) if args.verbose: @@ -126,7 +139,7 @@ def _main(args: argparse.Namespace) -> int: if args.dry_run: print('# NOTE: dry-run requested') - print(shlex.join(['mkdir', '-p', args.outdir])) + 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: @@ -135,7 +148,7 @@ def _main(args: argparse.Namespace) -> int: return process_workitems( work_items, - args.outdir, + outdir, args.concurrency, args.verbose, ) 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, From 7dff8afebbeaceee06b902d6e19b1d6d66e51160 Mon Sep 17 00:00:00 2001 From: Vladya Date: Tue, 16 Jun 2026 20:52:18 +0300 Subject: [PATCH 2/2] Version 0.2.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ability to pass multiple paths simultaneously has been added (the files will be split sequentially). The `audiobook-split-ffmpeg-mp3` script has been added; it is identical to its predecessor, but specifies the .mp3 format as the output format. The ability to pass a directory containing media files as an argument has also been added. The ability to pass an argument as a positional argument has been added. This may be useful for conveniently running the script via the ‘Open with’ command in Windows, where you can specify the utility’s .exe script. --- pyproject.toml | 1 + src/audiobook_split_ffmpeg/__init__.py | 2 +- src/audiobook_split_ffmpeg/cli.py | 99 +++++++++++++++++--------- 3 files changed, 68 insertions(+), 34 deletions(-) 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 bb3055c..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.1' +__version__ = '0.2.5' diff --git a/src/audiobook_split_ffmpeg/cli.py b/src/audiobook_split_ffmpeg/cli.py index 0d0db5a..21eb266 100644 --- a/src/audiobook_split_ffmpeg/cli.py +++ b/src/audiobook_split_ffmpeg/cli.py @@ -29,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 @@ -38,17 +38,28 @@ 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=False, @@ -58,7 +69,7 @@ def parse_args(argv: t.List[str]) -> argparse.Namespace: parser.add_argument( '--out-ext', required=False, - default=None, + default=("mp3" if mp3_default else None), help='New file extension. If conversion to another format is required.', ) parser.add_argument( @@ -103,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 @@ -121,37 +133,54 @@ def _main(args: argparse.Namespace) -> int: if args.verbose: print('args:', args) - outdir = args.outdir - if outdir is None: - outdir, _ = os.path.splitext(args.infile) - - work_items = list( - compute_workitems( - args.infile, + 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', 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, - enumerate_files=args.enumerate_files, - use_title_in_filenames=args.use_title, - out_ext=args.out_ext + 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 process_workitems( - work_items, - outdir, - args.concurrency, - args.verbose, - ) + if return_code != 0: + return return_code + + return 0 def main() -> t.NoReturn: @@ -161,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()