Refactor encoding

This commit is contained in:
Ritiek Malhotra
2020-03-16 18:12:52 +05:30
parent 937ed6ebcc
commit 5adb3d0a4d
11 changed files with 239 additions and 165 deletions

3
.gitignore vendored Executable file → Normal file
View File

@@ -1,6 +1,9 @@
# Spotdl generated files
*.m4a
*.webm
*.mp3
*.opus
*.flac
config.yml
Music/
*.txt

9
setup.py Executable file → Normal file
View File

@@ -10,7 +10,14 @@ setup(
name="spotdl",
# Tests are included automatically:
# https://docs.python.org/3.6/distutils/sourcedist.html#specifying-the-files-to-distribute
packages=["spotdl", "spotdl.lyrics", "spotdl.lyrics.providers"],
packages=[
"spotdl",
"spotdl.lyrics",
"spotdl.lyrics.providers",
"spotdl.encoders",
"spotdl.downloaders",
"spotdl.patch",
],
version=spotdl.__version__,
install_requires=[
"pathlib >= 1.0.1",

View File

@@ -1,164 +0,0 @@
import subprocess
import os
from logzero import logger as log
"""
What are the differences and similarities between ffmpeg, libav, and avconv?
https://stackoverflow.com/questions/9477115
ffmeg encoders high to lower quality
libopus > libvorbis >= libfdk_aac > aac > libmp3lame
libfdk_aac due to copyrights needs to be compiled by end user
on MacOS brew install ffmpeg --with-fdk-aac will do just that. Other OS?
https://trac.ffmpeg.org/wiki/Encode/AAC
"""
def song(
input_song,
output_song,
folder,
avconv=False,
trim_silence=False,
delete_original=True,
):
""" Do the audio format conversion. """
if avconv and trim_silence:
raise ValueError("avconv does not support trim_silence")
if not input_song == output_song:
log.info("Converting {0} to {1}".format(input_song, output_song.split(".")[-1]))
elif input_song.endswith(".m4a"):
log.info('Correcting container in "{}"'.format(input_song))
else:
return 0
convert = Converter(
input_song, output_song, folder, delete_original=delete_original
)
if avconv:
exit_code, command = convert.with_avconv()
else:
exit_code, command = convert.with_ffmpeg(trim_silence=trim_silence)
return exit_code, command
class Converter:
def __init__(self, input_song, output_song, folder, delete_original):
_, self.input_ext = os.path.splitext(input_song)
_, self.output_ext = os.path.splitext(output_song)
self.output_file = os.path.join(folder, output_song)
rename_to_temp = False
same_file = os.path.abspath(input_song) == os.path.abspath(output_song)
if same_file:
# FFmpeg/avconv cannot have the same file for both input and output
# This would happen when the extensions are same, so rename
# the input track to append ".temp"
log.debug(
'Input file and output file are going will be same during encoding, will append ".temp" to input file just before starting encoding to avoid conflict'
)
input_song = output_song + ".temp"
rename_to_temp = True
delete_original = True
self.input_file = os.path.join(folder, input_song)
self.rename_to_temp = rename_to_temp
self.delete_original = delete_original
def with_avconv(self):
if log.level == 10:
level = "debug"
else:
level = "0"
command = [
"avconv",
"-loglevel",
level,
"-i",
self.input_file,
"-ab",
"192k",
self.output_file,
"-y",
]
if self.rename_to_temp:
os.rename(self.output_file, self.input_file)
log.debug(command)
try:
code = subprocess.call(command)
except FileNotFoundError:
if self.rename_to_temp:
os.rename(self.input_file, self.output_file)
raise
if self.delete_original:
log.debug('Removing original file: "{}"'.format(self.input_file))
os.remove(self.input_file)
return code, command
def with_ffmpeg(self, trim_silence=False):
ffmpeg_pre = (
"ffmpeg -y -nostdin "
) # -nostdin is necessary for spotdl to be able to run in the backgroung.
if not log.level == 10:
ffmpeg_pre += "-hide_banner -nostats -v panic "
ffmpeg_params = ""
if self.input_ext == ".m4a":
if self.output_ext == ".mp3":
ffmpeg_params = "-codec:v copy -codec:a libmp3lame -ar 48000 "
elif self.output_ext == ".webm":
ffmpeg_params = "-codec:a libopus -vbr on "
elif self.output_ext == ".m4a":
ffmpeg_params = "-acodec copy "
elif self.input_ext == ".webm":
if self.output_ext == ".mp3":
ffmpeg_params = "-codec:a libmp3lame -ar 48000 "
elif self.output_ext == ".m4a":
ffmpeg_params = "-cutoff 20000 -codec:a aac -ar 48000 "
if self.output_ext == ".flac":
ffmpeg_params = "-codec:a flac -ar 48000 "
# add common params for any of the above combination
ffmpeg_params += "-b:a 192k -vn "
ffmpeg_pre += "-i "
if trim_silence:
ffmpeg_params += "-af silenceremove=start_periods=1 "
command = (
ffmpeg_pre.split()
+ [self.input_file]
+ ffmpeg_params.split()
+ [self.output_file]
)
if self.rename_to_temp:
os.rename(self.output_file, self.input_file)
log.debug(command)
try:
code = subprocess.call(command)
except FileNotFoundError:
if self.rename_to_temp:
os.rename(self.input_file, self.output_file)
raise
if self.delete_original:
log.debug('Removing original file: "{}"'.format(self.input_file))
os.remove(self.input_file)
return code, command

View File

@@ -0,0 +1,3 @@
from spotdl.encoders.base import EncoderBase
from spotdl.encoders.ffmpeg import EncoderFFmpeg
from spotdl.encoders.avconv import EncoderAvconv

77
spotdl/encoders/avconv.py Normal file
View File

@@ -0,0 +1,77 @@
import subprocess
import os
from logzero import logger as log
from spotdl.encoders import EncoderBase
class EncoderAvconv(EncoderBase):
def __init__(self, encoder_path="avconv"):
print("Using avconv is deprecated and this will be removed in",
"future versions. Use ffmpeg instead.")
encoder_path = encoder_path
_loglevel = "-loglevel 0"
_additional_arguments = ["-ab", "192k"]
super().__init__(encoder_path, _loglevel, _additional_arguments)
def set_argument(self, argument):
super().set_argument(argument)
def get_encoding(self, filename):
return super().get_encoding(filename)
def _generate_encoding_arguments(self, input_encoding, output_encoding):
initial_arguments = self._rules.get(input_encoding)
if initial_arguments is None:
raise TypeError(
'The input format ("{}") is not supported.'.format(
input_extension,
)
)
arguments = initial_arguments.get(output_encoding)
if arguments is None:
raise TypeError(
'The output format ("{}") is not supported.'.format(
output_extension,
)
)
return arguments
def _generate_encoding_arguments(self, input_encoding, output_encoding):
return ""
def set_debuglog(self):
self._loglevel = "-loglevel debug"
def _generate_encode_command(self, input_file, output_file):
input_encoding = self.get_encoding(input_file)
output_encoding = self.get_encoding(output_file)
arguments = self._generate_encoding_arguments(
input_encoding,
output_encoding
)
command = [self.encoder_path] \
+ ["-y"] \
+ self._loglevel.split() \
+ ["-i", input_file] \
+ self._additional_arguments \
+ [output_file]
return command
def re_encode(self, input_file, output_file, delete_original=False):
encode_command = self._generate_encode_command(
input_file,
output_file
)
returncode = subprocess.call(encode_command)
encode_successful = returncode == 0
if encode_successful and delete_original:
os.remove(input_file)
return returncode

56
spotdl/encoders/base.py Normal file
View File

@@ -0,0 +1,56 @@
from abc import ABCMeta
from abc import abstractmethod
from abc import abstractproperty
import os
"""
NOTE ON ENCODERS
================
* A comparision between FFmpeg, avconv, and libav:
https://stackoverflow.com/questions/9477115
* FFmeg encoders sorted in descending order based
on the quality of audio produced:
libopus > libvorbis >= libfdk_aac > aac > libmp3lame
* libfdk_aac encoder, due to copyrights needs to be compiled
by end user on MacOS brew install ffmpeg --with-fdk-aac
will do just that. Other OS? See:
https://trac.ffmpeg.org/wiki/Encode/AAC
"""
class EncoderBase(metaclass=ABCMeta):
@abstractmethod
def __init__(self, encoder_path, loglevel, additional_arguments):
self.encoder_path = encoder_path
self._loglevel = loglevel
self._additional_arguments = additional_arguments
@abstractmethod
def set_argument(self, argument):
self._additional_arguments += argument.split()
@abstractmethod
def get_encoding(self, filename):
_, extension = os.path.splitext(filename)
# Ignore the initial dot from file extension
return extension[1:]
@abstractmethod
def set_debuglog(self):
pass
@abstractmethod
def _generate_encode_command(self, input_file, output_file):
pass
@abstractmethod
def _generate_encoding_arguments(self, input_encoding, output_encoding):
pass
@abstractmethod
def re_encode(self, input_encoding, output_encoding):
pass

92
spotdl/encoders/ffmpeg.py Normal file
View File

@@ -0,0 +1,92 @@
import subprocess
import os
from logzero import logger as log
from spotdl.encoders import EncoderBase
RULES = {
"m4a": {
"mp3": "-codec:v copy -codec:a libmp3lame -ar 48000",
"webm": "-codec:a libopus -vbr on ",
"m4a": "-acodec copy ",
"flac": "-codec:a flac -ar 48000",
},
"webm": {
"mp3": "-codec:a libmp3lame -ar 48000",
"m4a": "-cutoff 20000 -codec:a aac -ar 48000",
"flac": "-codec:a flac -ar 48000",
},
}
class EncoderFFmpeg(EncoderBase):
def __init__(self, encoder_path="ffmpeg"):
encoder_path = encoder_path
_loglevel = "-hide_banner -nostats -v panic"
_additional_arguments = ["-b:a", "192k", "-vn"]
super().__init__(encoder_path, _loglevel, _additional_arguments)
self._rules = RULES
def set_argument(self, argument):
super().set_argument(argument)
def set_trim_silence(self):
self.set_argument("-af silenceremove=start_periods=1")
def get_encoding(self, filename):
return super().get_encoding(filename)
def _generate_encoding_arguments(self, input_encoding, output_encoding):
initial_arguments = self._rules.get(input_encoding)
if initial_arguments is None:
raise TypeError(
'The input format ("{}") is not supported.'.format(
input_extension,
)
)
arguments = initial_arguments.get(output_encoding)
if arguments is None:
raise TypeError(
'The output format ("{}") is not supported.'.format(
output_extension,
)
)
return arguments
def set_debuglog(self):
self._loglevel = "-loglevel debug"
def _generate_encode_command(self, input_file, output_file):
input_encoding = self.get_encoding(input_file)
output_encoding = self.get_encoding(output_file)
arguments = self._generate_encoding_arguments(
input_encoding,
output_encoding
)
command = [self.encoder_path] \
+ ["-y", "-nostdin"] \
+ self._loglevel.split() \
+ ["-i", input_file] \
+ arguments.split() \
+ self._additional_arguments \
+ [output_file]
return command
def re_encode(self, input_file, output_file, delete_original=False):
encode_command = self._generate_encode_command(
input_file,
output_file
)
returncode = subprocess.call(encode_command)
encode_successful = returncode == 0
if encode_successful and delete_original:
os.remove(input_file)
return returncode