xconv: Add support for Audible AAX

master
Taeyeon Mori 5 years ago
parent a64c02f870
commit 9c346bbcf0
  1. 26
      lib/python/advancedav.py
  2. 5
      lib/python/xconv/app.py
  3. 93
      lib/python/xconv/profiles/audiobook.py

@ -6,7 +6,7 @@ AdvancedAV FFmpeg commandline generator v3.0 [Library Edition]
It can automatically parse input files with the help of FFmpeg's ffprobe tool (WiP) 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. and allows programatically mapping streams to output files and setting metadata on them.
----------------------------------------------------------- -----------------------------------------------------------
Copyright 2014-2017 Taeyeon Mori Copyright 2014-2019 Taeyeon Mori
This program is free software: you can redistribute it and/or modify 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 it under the terms of the GNU General Public License as published by
@ -37,11 +37,9 @@ from pathlib import Path, PurePath
__all__ = "AdvancedAVError", "AdvancedAV", "SimpleAV", "MultiAV" __all__ = "AdvancedAVError", "AdvancedAV", "SimpleAV", "MultiAV"
version_info = 2, 99, 7 version_info = 2, 99, 8
# Constants # Constants
DEFAULT_CONTAINER = "matroska"
S_AUDIO = "a" S_AUDIO = "a"
S_VIDEO = "v" S_VIDEO = "v"
S_SUBTITLE = "s" S_SUBTITLE = "s"
@ -784,16 +782,16 @@ class OutputFile(File, ObjectWithMetadata):
Holds information about an output file Holds information about an output file
""" """
__slots__ = "task", "container", "_mapped_sources", "metadata" __slots__ = "task", "container", "_mapped_sources", "metadata"
local_option_names = ("reorder_streams",) + File.local_option_names local_option_names = ("reorder_streams",) + File.local_option_names
stream_factory = staticmethod(output_stream_factory) stream_factory = staticmethod(output_stream_factory)
def __init__(self, task: "Task", name: str, container=DEFAULT_CONTAINER, def __init__(self, task: "Task", name: str, container=None,
options: Mapping=None, metadata: Mapping=None): options: Mapping=None, metadata: Mapping=None):
super().__init__(name, options=options, metadata=metadata) super().__init__(name, options=options, metadata=metadata)
self.options.setdefault("c", "copy") #self.options.setdefault("c", "copy")
self.options.setdefault("reorder_streams", True) self.options.setdefault("reorder_streams", True)
self.task = task self.task = task
@ -1153,7 +1151,7 @@ class Task(BaseTask):
return file return file
# -- Manage Outputs # -- Manage Outputs
def add_output(self, filename: str, container: str=DEFAULT_CONTAINER, options: Mapping=None) -> OutputFile: def add_output(self, filename: str, container: str=None, options: Mapping=None) -> OutputFile:
""" Add an output file """ Add an output file
NOTE: Contrary to add_input this will NOT take an OutputFile instance and return it. NOTE: Contrary to add_input this will NOT take an OutputFile instance and return it.
@ -1187,7 +1185,7 @@ class SimpleTask(Task):
All members of the OutputFile can be accessed on the SimpleTask directly, as well as the usual Task methods. All members of the OutputFile can be accessed on the SimpleTask directly, as well as the usual Task methods.
Usage of add_output should be avoided however, because it would lead to confusion. Usage of add_output should be avoided however, because it would lead to confusion.
""" """
def __init__(self, pp: "AdvancedAV", filename: str, container: str=DEFAULT_CONTAINER, options: Mapping=None): def __init__(self, pp: "AdvancedAV", filename: str, container: str=None, options: Mapping=None):
super().__init__(pp) super().__init__(pp)
self.output = self.add_output(filename, container, options) self.output = self.add_output(filename, container, options)
@ -1238,7 +1236,7 @@ class AdvancedAV(metaclass=ABCMeta):
""" """
return Task(self) return Task(self)
def create_job(self, filename: str, container: str=DEFAULT_CONTAINER, options: Mapping=None) -> SimpleTask: def create_job(self, filename: str, container: str=None, options: Mapping=None) -> SimpleTask:
""" """
Create a simple AdvandecAV task Create a simple AdvandecAV task
:param filename: str The resulting filename :param filename: str The resulting filename
@ -1349,9 +1347,9 @@ class SimpleAV(AdvancedAV):
return out.decode("utf-8", "replace") return out.decode("utf-8", "replace")
def probe_file(self, file, *, ffprobe_args_hint=None): def probe_file(self, file, *, ffprobe_args_hint=None):
probe = self.call_probe(tuple(FFmpeg.argv_options(file.options)) probe = self.call_probe(ffprobe_args_hint
+ ffprobe_args_hint + tuple(FFmpeg.argv_options(file.options))
+ ("-i", file.filename)) + ("-i", file.filename))
return json.loads(probe) return json.loads(probe)
@ -1383,7 +1381,7 @@ class MultiAV(SimpleAV):
while self.queue: while self.queue:
self.manage_workers() self.manage_workers()
sleep(.250) sleep(.250)
def manage_workers(self): def manage_workers(self):
""" """
Make a single run over available workers and see to it that they have work if available Make a single run over available workers and see to it that they have work if available

@ -99,7 +99,7 @@ def create_task(aav, profile, inputs, args, filename_from=None):
filename_from = filename_from or inputs[0] filename_from = filename_from or inputs[0]
if not is_advanced_task_profile: if not is_advanced_task_profile:
fmt = advancedav.DEFAULT_CONTAINER fmt = None
ext = None ext = None
if "output" in profile.features: if "output" in profile.features:
fmt, ext = profile.features["output"] fmt, ext = profile.features["output"]
@ -166,7 +166,8 @@ def main(argv):
if args.quiet: if args.quiet:
aav.global_conv_args = "-loglevel", "warning" aav.global_conv_args = "-loglevel", "warning"
aav.global_args += "-hide_banner", "-stats" aav.global_args += "-hide_banner",
aav.global_conv_args += "-stats",
# Collect Tasks # Collect Tasks
tasks = [] tasks = []

@ -26,6 +26,7 @@ Opus Audiobook profile
""" """
import os import os
import math
from ..profile import * from ..profile import *
@ -34,7 +35,7 @@ abdefines = dict(
bitrate = "Use custom target bitrate", bitrate = "Use custom target bitrate",
stereo = "Use 2 channels (Ignored for mono source streams)", stereo = "Use 2 channels (Ignored for mono source streams)",
fancy = "Use higher bitrates (48k mono/64k stereo)", fancy = "Use higher bitrates (48k mono/64k stereo)",
ogg = "Use the .ogg file extension (Currently required on Android)" ogg = "Use the .ogg file extension (Currently required on Android)",
) )
def apply_stream(stream, defines): def apply_stream(stream, defines):
@ -66,10 +67,26 @@ def apply_stream(stream, defines):
stream.bitrate = min(stream.bitrate, stream.source.bitrate) stream.bitrate = min(stream.bitrate, stream.source.bitrate)
metadefines = dict(
title="Name of the book",
series="Series the book belongs to",
author="Name of the book's author",
performer="Name of the audiobook's reader/narrator",
genre="Name of the genre. Default is Audiobook",
publisher="Name of the recording company",
language="Language",
)
def apply_metadata(ob, defines):
ob.apply_meta(defines, "language", "author", "performer", publisher="organization", title="album")
ob.meta(genre=defines.get("genre", "Audiobook"))
@profile @profile
@description("Encode Opus Audiobook") @description("Encode Opus Audiobook")
@output(container="ogg", ext="opus") @output(container="ogg", ext="opus")
@defines(**abdefines) @defines(**abdefines, **metadefines)
@singleaudio @singleaudio
def audiobook(task, stream, defines): def audiobook(task, stream, defines):
if "ogg" in defines: if "ogg" in defines:
@ -77,6 +94,8 @@ def audiobook(task, stream, defines):
apply_stream(task.map_stream(stream), defines) apply_stream(task.map_stream(stream), defines)
apply_metadata(task.output, defines)
return True return True
@ -87,7 +106,7 @@ def audiobook(task, stream, defines):
@features(no_single_output=True) @features(no_single_output=True)
@defines(ignore_ends="Ignore chapter end marks and continue until next chapter starts", @defines(ignore_ends="Ignore chapter end marks and continue until next chapter starts",
chapter_only_names="Don't include the input filename in the output filename", chapter_only_names="Don't include the input filename in the output filename",
**abdefines) **abdefines, **metadefines)
@singleaudio @singleaudio
def from_chapters(task, stream, defines): def from_chapters(task, stream, defines):
# Read chapters from input # Read chapters from input
@ -126,9 +145,77 @@ def from_chapters(task, stream, defines):
apply_stream(out.map_stream(stream), defines) apply_stream(out.map_stream(stream), defines)
apply_metadata(out, defines)
return True
@profile
@description("Split & Encode Opus Audiobook from Audible AAX")
@output(container="ogg", ext="opus")
@features(no_single_output=True)
@defines(key="Audible activation_bytes (required)",
cover_file="Filename for the extracted cover (Default: <album>.jpg)",
#dont_embed_cover="Don't try to embed the cover",
artist_tag="Specify the tag to store the artist name (Default: author)",
performer="Add a performer tag",
#album="Override book title",
**abdefines)
def audible(task, defines):
if len(task.inputs) != 1:
print("audiobook.audible profile must be applied to a single AAX file!")
return False
input = task.inputs[0]
if "key" not in defines:
if input.metadata["major_brand"].lower() == "aax":
print("Audible activation_bytes must be specified in the 'key' define!")
return False
else:
input.set(activation_bytes=defines["key"])
audio = input.audio_streams[0]
# Extract cover
cover = input.video_streams[0]
cover_file = defines.get("cover_file", True)
if cover_file != "":
if cover_file is True:
cover_file = input.album + ".jpg"
elif "." not in cover_file:
cover_file += ".jpg"
cof = task.add_output(os.path.join(task.output_directory, cover_file))
cof.map_stream(cover).set(c="copy")
ext = "ogg" if "ogg" in defines else "opus"
chaps = len(input.chapters)
ct_fmt = "%%s %%0%dd - %%s.%s" % (math.ceil(math.log10(chaps)), ext)
add_meta = {defines.get("artist_tag", "author"): input.artist}
#album = defines.get("album", input.album)
for chapter in input.chapters:
no = chapter.index + 1
title = " - ".join((input.album, chapter.title))
filename = os.path.join(task.output_directory, ct_fmt % (input.album, no, chapter.title))
out = task.add_output(filename)
out.set(ss = chapter.start_time,
to = chapter.end_time,
map_metadata = "-1",
reorder_streams = False)
out.meta(title = title,
album = input.title,
tracknumber = "%d/%d" % (no, chaps),
**add_meta)
out.apply_meta(input.metadata, "copyright", "genre", "date", comment="description")
out.apply_meta(defines, "performer", publisher="organization")
apply_stream(out.map_stream(audio), defines)
#if not "dont_embed_cover" in defines:
# out.map_stream(cover) # Not sure how to make ffmpeg add covers to ogg
return True return True
@profile @profile
@description("Split into uniform pieces & Encode Opus Audiobook") @description("Split into uniform pieces & Encode Opus Audiobook")
@output(container="ogg", ext="opus") @output(container="ogg", ext="opus")

Loading…
Cancel
Save