diff --git a/lib/python/xconv/app.py b/lib/python/xconv/app.py index 08043d6..3690850 100644 --- a/lib/python/xconv/app.py +++ b/lib/python/xconv/app.py @@ -50,15 +50,58 @@ class SimpleTask(advancedav.SimpleTask): output_factory = OutputFile +class AdvancedTask(advancedav.Task): + output_factory = OutputFile + + def __init__(self, aav, basename): + self.output_basename = basename + super().__init__(aav) + + # == App == -def make_outfile(args, profile, infile): - if not args.output_filename: - if hasattr(profile, "ext"): - return build_path(args.output_directory, ".".join((splitext(basename(infile))[0], profile.ext if profile.ext else "bin"))) - else: - return build_path(args.output_directory, basename(infile)) +def make_basename(path, infile): + return build_path(path, splitext(basename(infile))[0]) + + +def make_outfile(path, infile, ext=None): + name, oldext = splitext(basename(infile)) + return build_path(path, ".".join((name, ext if ext else oldext))) + + +def create_task(aav, profile, inputs, args, filename_from=None): + is_advanced_task_profile = any(("advanced_task" in profile.features, + "no_single_output" in profile.features)) + + filename_from = filename_from or inputs[0] + + if not is_advanced_task_profile: + fmt = advancedav.DEFAULT_CONTAINER + ext = None + if "output" in profile.features: + fmt, ext = profile.features["output"] + outfile = args.output if args.output_filename else make_outfile(args.output_directory, filename_from, ext) + task = SimpleTask(aav, outfile, fmt) + else: - return args.output + basename = args.output if args.output_filename else make_basename(args.output_directory, filename_from) + task = AdvancedTask(aav, basename) + + + for input in inputs: + task.add_input(input) + + return task + + +def task_name(task): + if hasattr(task, "name"): + return basename(task.name) + elif task.inputs: + return "<%s" % task.inputs[0].name + elif task.outputs: + return ">%s" % task.outputs[0].name + else: + return "(anon task %p)" % id(task) def main(argv): @@ -95,12 +138,7 @@ def main(argv): print("\033[35mCollecting Tasks..\033[0m") if args.merge: - task = SimpleTask(aav, make_outfile(args, profile, args.inputs[0]), profile.container) - - for input in args.inputs: - task.add_input(input) - - tasks.append(task) + tasks.append(create_task(aav, profile, args.inputs, args)) elif args.concat: import tempfile, os @@ -113,7 +151,7 @@ def main(argv): print("\033[36m Concatenating %s\033[0m" % basename(f)) tmp.write("file '%s'\n" % f) - task = SimpleTask(aav, make_outfile(args, profile, args.inputs[0]), profile.container) + task = create_task(aav, profile, (), args, filename_from=args.inputs[0]) task.add_input(tmp.name).set(f="concat", safe="0") @@ -121,12 +159,7 @@ def main(argv): else: for input in args.inputs: - out = make_outfile(args, profile, input) - if args.update and exists(out): - continue - task = SimpleTask(aav, out, profile.container) - task.add_input(input) - tasks.append(task) + tasks.append(create_task(aav, profile, (input,), args)) print("\033[35mPreparing Tasks..\033[0m") @@ -140,17 +173,26 @@ def main(argv): # Apply profile for task in tasks: - print("\033[32m Applying profile for '%s'\033[0m" % basename(task.name), end="\033[K\r") + print("\033[32m Applying profile for '%s'\033[0m" % task_name(task), end="\033[K\r") res = profile(task, **pkw) if not res: - print("\033[31m Failed to apply profile for '%s'\033[0m\033[K" % basename(task.name)) + print("\033[31m Failed to apply profile for '%s'\033[0m\033[K" % task_name(task)) return 1 + if args.update: + for task in tasks[:]: + for output in [o for o in task.outputs if exists(o.name)]: + print("\033[33m Skipping existing '%s' (--update)\033[0m\033[K" % basename(skip.name)) + task.outputs.remove(skip) + if not tasks.outputs: + print("\033[33m Skipping task '%s' because no output files are left\033[0m\033[K" % task_name(task)) + tasks.remove[task] + print("\033[35mExecuting Tasks..\033[0m\033[K") # Commit for task in tasks: - print("\033[32m Processing '%s'\033[0m" % basename(task.name)) + print("\033[32m Processing '%s'\033[0m" % task_name(task)) task.commit() # Clean up diff --git a/lib/python/xconv/cmdline.py b/lib/python/xconv/cmdline.py index 04efa7c..337852a 100644 --- a/lib/python/xconv/cmdline.py +++ b/lib/python/xconv/cmdline.py @@ -51,7 +51,7 @@ class ProfilesAction(TerminalAction): def run(self, parser): print("Available Profiles:") for name, profile in sorted(load_all_profiles().items()): - print(" %-20s %s" % (name, profile.description if profile.description else "")) + print(" %-25s %s" % (name, profile.description if profile.description else "")) class ProfileInfoAction(TerminalAction): @@ -62,13 +62,15 @@ class ProfileInfoAction(TerminalAction): print("Profile '%s':" % profile_name) if profile.description: print(" Description: %s" % profile.description) - output_info = [] - if profile.container: - output_info.append("Format: %s" % profile.container) - if profile.ext: - output_info.append("File extension: %s" % profile.ext) - if output_info: - print(" Output: %s" % "; ".join(output_info)) + if "output" in profile.features: + output = profile.features["output"] + output_info = [] + if output[0]: + output_info.append("Format: %s" % output[0]) + if output[1]: + output_info.append("File extension: %s" % output[0]) + if output_info: + print(" Output: %s" % "; ".join(output_info)) if profile.features: print(" Flags: %s" % ", ".join("%s(%r)" % (k, v) if v is not None else k for k, v in profile.features.items())) if profile.defines: diff --git a/lib/python/xconv/profile.py b/lib/python/xconv/profile.py index 20fa25f..b12bdfb 100644 --- a/lib/python/xconv/profile.py +++ b/lib/python/xconv/profile.py @@ -77,18 +77,6 @@ def description(desc): return apply -def output(container=None, ext=None): - """ Add output file information """ - def apply(f): - if container: - f.container = container - f.ext = "mkv" if container == "matroska" else container - if ext: - f.ext = ext - return f - return apply - - def defines(**defs): """ Document supported defines with description """ def apply(f): @@ -98,13 +86,32 @@ def defines(**defs): def features(**features): - """ Set opaque feature flags """ + """ + Set opaque feature flags + + Boolean flags are checked for existance, not actual value. + + Known flags: + - output: Specifies type of the output file [(format, file-extension)] + - argshax: Pass parsed cmdline arguments in 'args' kwd + - singleaudio: Indicates that it operates on a single audio stream. No effects + - no_single_output: Profile doesn't constitute a 1:1 file conversion. Don't use SimpleTask + - advanced_task: reserved + """ def apply(f): __update(f, "features", features) return f return apply +def output(container=None, ext=None): + """ Add output file information """ + return features(output=( + container, + ext if ext else "mkv" if container == "matroska" else container + )) + + def singleaudio(profile): """ Operate on a single audio stream (The first one found) diff --git a/lib/python/xconv/profiles/audiobook.py b/lib/python/xconv/profiles/audiobook.py index 023c739..822d2c0 100644 --- a/lib/python/xconv/profiles/audiobook.py +++ b/lib/python/xconv/profiles/audiobook.py @@ -28,27 +28,37 @@ Opus Audiobook profile from ..profile import * +abdefines = dict( + stereo="Use two channels at 48k", + bitrate="Use custom target bitrate", + fancy="Use 56kbps stereo (For dramatic audiobooks with a lot of music and effects)" +) + +def apply(stream, defines): + stream.set(codec="libopus", + vbr="on", + b="40k", + ac="1", + application="voip") + if stream.source.channels > 1: + if "stereo" in defines: + stream.set(ac="2", + b="48k") + if "fancy" in defines: + stream.set(ac="2", + b="56k", + application="audio") + if "bitrate" in defines: + stream.bitrate = defines["bitrate"] + # At most input bitrate; we wouldn't gain anything since opus should be same or better compression + stream.bitrate = min(stream.bitrate, stream.source.bitrate) + + @profile @description("Encode Opus Audiobook") @output(container="ogg", ext="ogg") -@defines(stereo="Use two channels", - bitrate="Use custom target bitrate", - fancy="Use 48kbps stereo (For dramatic audiobooks with a lot of music and effects)") +@defines(**abdefines) @singleaudio def audiobook(task, stream, defines): - out = (task.map_stream(stream) - .set(codec="libopus", - vbr="on", - b="32k", - ac="1", - application="voip")) - if "stereo" in defines: - out.set(ac="2", - b="36k") - if "fancy" in defines: - out.set(ac="2", - b="48k", - application="audio") - if "bitrate" in defines: - out.set(b=defines["bitrate"]) + apply(task.map_stream(stream), defines) return True diff --git a/lib/python/xconv/profiles/audiobook_from_chapters.py b/lib/python/xconv/profiles/audiobook_from_chapters.py new file mode 100644 index 0000000..b6e738a --- /dev/null +++ b/lib/python/xconv/profiles/audiobook_from_chapters.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +""" +xconv ffmpeg wrapper based on AdvancedAV +----------------------------------------------------------- + AdvancedAV helps with constructing FFmpeg commandline arguments. + + It can automatically parse input files with the help of FFmpeg's ffprobe tool (WiP) + and allows programatically mapping streams to output files and setting metadata on them. +----------------------------------------------------------- + Copyright (c) 2015-2017 Taeyeon Mori + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +----------------------------------------------------------- +Opus Audiobook profile from m4b chapters +""" + +from ..profile import * +from .audiobook import apply, abdefines + + +@profile +@description("Split & Encode Opus Audiobook from M4B chapters") +@output(container="ogg", ext="ogg") +@features(no_single_output=True) +@defines(**abdefines) +@singleaudio +def audiobook_from_chapters(task, stream, defines): + for chapter in task.iter_chapters(): + apply( + task.add_output(chapter.title + ".ogg", "ogg") + .set(ss=chapter.start_time, + to=chapter.end_time) + .map_stream(stream), defines) + return True diff --git a/lib/python/xconv/profiles/getsubs.py b/lib/python/xconv/profiles/getsubs.py new file mode 100644 index 0000000..366ebf6 --- /dev/null +++ b/lib/python/xconv/profiles/getsubs.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +""" +xconv ffmpeg wrapper based on AdvancedAV +----------------------------------------------------------- + AdvancedAV helps with constructing FFmpeg commandline arguments. + + It can automatically parse input files with the help of FFmpeg's ffprobe tool (WiP) + and allows programatically mapping streams to output files and setting metadata on them. +----------------------------------------------------------- + Copyright (c) 2015-2017 Taeyeon Mori + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +----------------------------------------------------------- +Extract subtitle streams +""" + +from ..profile import * + + +@profile +@description("Extract subtitle tracks") +@defines(format="Convert all subtitles to a specific format") +@features(no_single_output=True) +def getsubs(task, defines): + for stream in task.iter_subtitle_streams(): + of = task.add_output("%s.%s.%s" % (task.output_basename, task.inputs.index(stream.file), stream.stream_spec), None) # TODO get real file extension + os = of.map_stream(stream) + if "format" in defines: + os.codec = defines["format"] + return True