From adbff401fbc2680eb0d735a237e770dc1602943a Mon Sep 17 00:00:00 2001 From: nathannathant <74019033+pynathanthomas@users.noreply.github.com> Date: Mon, 15 Mar 2021 13:59:58 -0700 Subject: [PATCH] Use Converter in .convert function in Track --- qobuz_dl_rewrite/converter.py | 104 +++++++++++++++++++++++---------- qobuz_dl_rewrite/downloader.py | 53 ++++------------- 2 files changed, 83 insertions(+), 74 deletions(-) diff --git a/qobuz_dl_rewrite/converter.py b/qobuz_dl_rewrite/converter.py index 4b50013..ac1880f 100644 --- a/qobuz_dl_rewrite/converter.py +++ b/qobuz_dl_rewrite/converter.py @@ -1,11 +1,9 @@ import logging import os -import shutil import subprocess -from tempfile import gettempdir from typing import Optional -from .exceptions import ConversionError +from .exceptions import BadEncoderOption, ConversionError logger = logging.getLogger(__name__) @@ -17,6 +15,8 @@ class Converter: codec_lib = None container = None lossless = False + cbr_options = None + vbr_options = None default_ffmpeg_arg = "" def __init__( @@ -25,7 +25,8 @@ class Converter: ffmpeg_arg: Optional[str] = None, sampling_rate: Optional[int] = None, bit_depth: Optional[int] = None, - copy_art: bool = False, + copy_art: bool = True, + **kwargs, ): """ :param ffmpeg_arg: The codec ffmpeg argument (defaults to an "optimal value") @@ -41,11 +42,12 @@ class Converter: self.filename = filename self.final_fn = f"{os.path.splitext(filename)[0]}.{self.container}" - self.tempfile = os.path.join(gettempdir(), os.path.basename(self.final_fn)) self.sampling_rate = sampling_rate self.bit_depth = bit_depth self.copy_art = copy_art + self.overwrite = kwargs.get("overwrite") + if ffmpeg_arg is None: logger.debug("No arguments provided. Codec defaults will be used") self.ffmpeg_arg = self.default_ffmpeg_arg @@ -55,7 +57,7 @@ class Converter: logger.debug("Ffmpeg codec extra argument: %s", self.ffmpeg_arg) - def convert(self, custom_fn: Optional[str] = None, remove_source: bool = True): + def convert(self, custom_fn: Optional[str] = None, remove_source: bool = False): """Convert the file. :param custom_fn: Custom output filename (defaults to the original @@ -74,12 +76,10 @@ class Converter: process.wait() if os.path.isfile(self.final_fn): if remove_source: - os.remove(self.filename) logger.debug("Source removed: %s", self.filename) + os.remove(self.filename) - shutil.move(self.tempfile, self.final_fn) - logger.debug("Moved: %s -> %s", self.tempfile, self.final_fn) - logger.debug("Converted: %s -> %s", self.filename, self.final_fn) + logger.debug("OK: %s -> %s", self.filename, self.final_fn) else: raise ConversionError("No file was returned from conversion") @@ -101,23 +101,21 @@ class Converter: command.extend(self.ffmpeg_arg.split()) if self.lossless: - if isinstance(self.sampling_rate, int): + if self.sampling_rate: command.extend(["-ar", str(self.sampling_rate)]) + if self.bit_depth == 16: + command.extend(["-sample_fmt", "s16"]) + elif self.bit_depth in (24, 32): + command.extend(["-sample_fmt", "s32"]) - if isinstance(self.bit_depth, int): - if int(self.bit_depth) == 16: - command.extend(["-sample_fmt", "s16"]) - elif int(self.bit_depth) in (24, 32): - command.extend(["-sample_fmt", "s32"]) - else: - raise ValueError("Bit depth must be 16, 24, or 32") + if self.overwrite: + command.append("-y") - command.extend(["-y", self.tempfile]) + command.append(self.final_fn) return command def _is_command_valid(self): - # TODO: add error handling for lossy codecs if self.ffmpeg_arg is not None and self.lossless: logger.debug( "Lossless codecs don't support extra arguments; " @@ -126,18 +124,51 @@ class Converter: self.ffmpeg_arg = self.default_ffmpeg_arg return + arg_value = self.ffmpeg_arg.split()[-1].strip().replace("k", "") + + try: + arg_value = int(self.ffmpeg_arg.split()[-1].strip()) + except ValueError: + raise BadEncoderOption(f"Invalid bitrate argument: {self.ffmpeg_arg}") + + logger.debug("Arg value provided: %d", arg_value) + options = [] + + if self.ffmpeg_arg.startswith("-b:a"): + if self.cbr_options is None: + raise BadEncoderOption("This codec doesn't support constant bitrate") + + options = self.cbr_options + + if self.ffmpeg_arg.startswith("-q:a"): + if self.vbr_options is None: + raise BadEncoderOption("This codec doesn't support variable bitrate") + + options = self.vbr_options + + if arg_value not in options: + raise BadEncoderOption( + f"VBR value is not in the codec range: {', '.join(options)}" + ) + + @property + def ext(self): + return self.filename.split('.')[-1] + class LAME(Converter): """ - Class for libmp3lame converter. Defaul ffmpeg_arg: `-q:a 0`. - - See available options: + Class for libmp3lame converter. See available options: https://trac.ffmpeg.org/wiki/Encode/MP3 """ codec_name = "lame" codec_lib = "libmp3lame" container = "mp3" + lossless = False + # Blatantly assume nobody will ever convert CBR at less than 96 + cbr_options = tuple(range(96, 321, 16)) + vbr_options = tuple(range(10)) default_ffmpeg_arg = "-q:a 0" # V0 @@ -149,23 +180,31 @@ class ALAC(Converter): lossless = True -class Vorbis(Converter): - """ - Class for libvorbis converter. Default ffmpeg_arg: `-q:a 6`. +class FLAC(Converter): + codec_name = "flac" + codec_lib = "flac" + container = "flac" + lossless = True - See available options: + +class VORBIS(Converter): + """ + Class for libvorbis converter. See available options: https://trac.ffmpeg.org/wiki/TheoraVorbisEncodingGuide """ codec_name = "vorbis" codec_lib = "libvorbis" container = "ogg" + lossless = False + vbr_options = tuple(range(-1, 11)) default_ffmpeg_arg = "-q:a 6" # 160, aka the "high" quality profile from Spotify class OPUS(Converter): """ - Class for libopus. Default ffmpeg_arg: `-b:a 128 -vbr on`. + Class for libopus. Currently, this codec takes only `-b:a` as an argument + but, unlike other codecs, it will convert to a variable bitrate. See more: http://ffmpeg.org/ffmpeg-codecs.html#libopus-1 @@ -174,18 +213,21 @@ class OPUS(Converter): codec_name = "opus" codec_lib = "libopus" container = "opus" + lossless = False + cbr_options = tuple(range(16, 513, 16)) default_ffmpeg_arg = "-b:a 128k" # Transparent class AAC(Converter): """ - Class for libfdk_aac converter. Default ffmpeg_arg: `-b:a 256k`. - - See available options: + Class for libfdk_aac converter. See available options: https://trac.ffmpeg.org/wiki/Encode/AAC """ codec_name = "aac" codec_lib = "libfdk_aac" container = "m4a" + lossless = False + cbr_options = tuple(range(16, 513, 16)) + # TODO: vbr_options default_ffmpeg_arg = "-b:a 256k" diff --git a/qobuz_dl_rewrite/downloader.py b/qobuz_dl_rewrite/downloader.py index 67ea3b6..6b75598 100644 --- a/qobuz_dl_rewrite/downloader.py +++ b/qobuz_dl_rewrite/downloader.py @@ -15,6 +15,7 @@ from .constants import EXT, FLAC_MAX_BLOCKSIZE from .exceptions import InvalidQuality, NonStreamable, TooLargeCoverArt from .metadata import TrackMetadata from .util import quality_id, safe_get, tqdm_download +from . import converter logger = logging.getLogger(__name__) @@ -222,50 +223,16 @@ class Track: def convert(self, codec='ALAC', **kwargs): assert self.__is_downloaded, 'track must be downloaded before conversion' - codec = codec.upper() - sampling_rate = kwargs.get("sampling_rate") - ext = EXT[self.quality] - conversion_command = [ - "ffmpeg", - "-i", - self.final_path, - "-c:v", # copy cover art - "copy", - "-loglevel", # no annoying logs - "warning", - ] + CONV_CLASS = { + 'ALAC': converter.ALAC, + 'MP3': converter.LAME, + 'FLAC': converter.FLAC, + 'OGG': converter.VORBIS, + 'AAC': converter.AAC, + } - if codec == 'ALAC': - new_ext = '.m4a' - conversion_command.extend(["-c:a", "alac"]) - elif codec == 'MP3': - new_ext = '.mp3' - # TODO: add further customization of mp3 quality - # https://trac.ffmpeg.org/wiki/Encode/MP3 - # VBR from 22-26KB/s - conversion_command.extend(["-c:a", "libmp3lame", "-q:a", "0"]) - elif codec == 'AAC': - new_ext = '.m4a' - # highest quality VBR - conversion_command.extend(["-c:a", "libfdk_aac", "-vbr", "5"]) - elif codec == 'FLAC': - new_ext = '.flac' - else: - raise NotImplementedError(f"Codec {codec} not implemented") - - if sampling_rate is not None and codec not in ("aac", "mp3"): - conversion_command.extend(["-ar", str(sampling_rate)]) - - # tempfile needs to use new_ext so that ffmpeg detects the container - tempfile = os.path.join(gettempdir(), f"~qdl_track_conv{new_ext}") - conversion_command.extend(["-y", tempfile]) - logger.debug(f"coverting with command {conversion_command}") - - process = subprocess.Popen(conversion_command) - process.wait() - out_path = self.final_path.replace(ext, new_ext) - shutil.move(tempfile, out_path) - self.final_path = out_path + engine = CONV_CLASS[codec.upper()](filename=self.final_path, sampling_rate=kwargs.get("sampling_rate"), overwrite=True) + engine.convert(remove_source=kwargs.get("remove_source", False)) def get(self, *keys, default=None): """Safe get method that allows for layered access.