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