Support FLAC output format (#259)

* Convert to .flac option

* Embed metadata to FLAC

* Update usage help

* Write tests
This commit is contained in:
Ritiek Malhotra
2018-04-02 00:47:31 +05:30
committed by GitHub
parent 7f7c3d6f58
commit 96ab547c5c
8 changed files with 123 additions and 79 deletions

View File

@@ -90,9 +90,9 @@ but make sure `$ python -V` gives you a `Python 3.x.x`!
``` ```
usage: spotdl.py [-h] usage: spotdl.py [-h]
(-s SONG | -l LIST | -p PLAYLIST | -b ALBUM | -u USERNAME) (-s SONG | -l LIST | -p PLAYLIST | -b ALBUM | -u USERNAME)
[-m] [-nm] [-a] [-f FOLDER] [--overwrite {force,prompt,skip}] [-m] [-nm] [-a] [-f FOLDER] [--overwrite {skip,force,prompt}]
[-i INPUT_EXT] [-o OUTPUT_EXT] [-ff] [-dm] [-d] [-mo] [-ns] [-i {.webm,.m4a}] [-o OUTPUT_EXT] [-ff] [-dm] [-d] [-mo]
[-ll {INFO,WARNING,ERROR,DEBUG}] [-ns] [-ll {INFO,WARNING,ERROR,DEBUG}] [-c CONFIG]
Download and convert songs from Spotify, Youtube etc. Download and convert songs from Spotify, Youtube etc.
@@ -116,14 +116,14 @@ optional arguments:
-f FOLDER, --folder FOLDER -f FOLDER, --folder FOLDER
path to folder where files will be stored in (default: path to folder where files will be stored in (default:
Music) Music)
--overwrite {force,prompt,skip} --overwrite {skip,force,prompt}
change the overwrite policy (default: prompt) change the overwrite policy (default: prompt)
-i INPUT_EXT, --input-ext INPUT_EXT -i {.webm,.m4a}, --input-ext {.webm,.m4a}
prefered input format .m4a or .webm (Opus) (default: prefered input format .m4a or .webm (Opus) (default:
.m4a) .m4a)
-o OUTPUT_EXT, --output-ext OUTPUT_EXT -o OUTPUT_EXT, --output-ext OUTPUT_EXT
prefered output extension .mp3 or .m4a (AAC) (default: prefered output format .mp3, .m4a (AAC), .flac, etc.
.mp3) (default: .mp3)
-ff, --file-format File format to save the downloaded song with, each tag -ff, --file-format File format to save the downloaded song with, each tag
is surrounded by curly braces. Possible formats: is surrounded by curly braces. Possible formats:
['track_name', 'artist', 'album', 'album_artist', ['track_name', 'artist', 'album', 'album_artist',
@@ -140,7 +140,7 @@ optional arguments:
(default: False) (default: False)
-ll {INFO,WARNING,ERROR,DEBUG}, --log-level {INFO,WARNING,ERROR,DEBUG} -ll {INFO,WARNING,ERROR,DEBUG}, --log-level {INFO,WARNING,ERROR,DEBUG}
set log verbosity (default: INFO) set log verbosity (default: INFO)
-c CONFIG_FILE_PATH --config CONFIG_FILE_PATH -c CONFIG, --config CONFIG
Replace with custom config.yml file (default: None) Replace with custom config.yml file (default: None)
``` ```

View File

@@ -1,8 +1,33 @@
import logzero import logzero
_log_format = ("%(color)s%(levelname)s:%(end_color)s %(message)s") _log_format = ("%(color)s%(levelname)s:%(end_color)s %(message)s")
formatter = logzero.LogFormatter(fmt=_log_format) _formatter = logzero.LogFormatter(fmt=_log_format)
# options # options
log = logzero.setup_logger(formatter=formatter) log = logzero.setup_logger(formatter=_formatter)
args = None args = None
# Apple has specific tags - see mutagen docs -
# http://mutagen.readthedocs.io/en/latest/api/mp4.html
M4A_TAG_PRESET = { 'album' : '\xa9alb',
'artist' : '\xa9ART',
'date' : '\xa9day',
'title' : '\xa9nam',
'year' : '\xa9day',
'originaldate' : 'purd',
'comment' : '\xa9cmt',
'group' : '\xa9grp',
'writer' : '\xa9wrt',
'genre' : '\xa9gen',
'tracknumber' : 'trkn',
'albumartist' : 'aART',
'discnumber' : 'disk',
'cpil' : 'cpil',
'albumart' : 'covr',
'copyright' : 'cprt',
'tempo' : 'tmpo',
'lyrics' : '\xa9lyr' }
TAG_PRESET = {}
for key in M4A_TAG_PRESET.keys():
TAG_PRESET[key] = key

View File

@@ -17,16 +17,16 @@ https://trac.ffmpeg.org/wiki/Encode/AAC
def song(input_song, output_song, folder, avconv=False): def song(input_song, output_song, folder, avconv=False):
""" Do the audio format conversion. """ """ Do the audio format conversion. """
if not input_song == output_song: if input_song == output_song:
convert = Converter(input_song, output_song, folder) return 0
log.info('Converting {0} to {1}'.format( convert = Converter(input_song, output_song, folder)
input_song, output_song.split('.')[-1])) log.info('Converting {0} to {1}'.format(
if avconv: input_song, output_song.split('.')[-1]))
exit_code = convert.with_avconv() if avconv:
else: exit_code = convert.with_avconv()
exit_code = convert.with_ffmpeg() else:
return exit_code exit_code = convert.with_ffmpeg()
return 0 return exit_code
class Converter: class Converter:
@@ -56,6 +56,8 @@ class Converter:
_, input_ext = os.path.splitext(self.input_file) _, input_ext = os.path.splitext(self.input_file)
_, output_ext = os.path.splitext(self.output_file) _, output_ext = os.path.splitext(self.output_file)
ffmpeg_params = ''
if input_ext == '.m4a': if input_ext == '.m4a':
if output_ext == '.mp3': if output_ext == '.mp3':
ffmpeg_params = '-codec:v copy -codec:a libmp3lame -ar 44100 ' ffmpeg_params = '-codec:v copy -codec:a libmp3lame -ar 44100 '
@@ -68,6 +70,9 @@ class Converter:
elif output_ext == '.m4a': elif output_ext == '.m4a':
ffmpeg_params = '-cutoff 20000 -codec:a libfdk_aac -ar 44100 ' ffmpeg_params = '-cutoff 20000 -codec:a libfdk_aac -ar 44100 '
if output_ext == '.flac':
ffmpeg_params = '-codec:a flac -ar 44100 '
# add common params for any of the above combination # add common params for any of the above combination
ffmpeg_params += '-b:a 192k -vn ' ffmpeg_params += '-b:a 192k -vn '
ffmpeg_pre += ' -i' ffmpeg_pre += ' -i'

View File

@@ -122,10 +122,11 @@ def get_arguments(raw_args=None, to_group=True, to_merge=True):
choices={'prompt', 'force', 'skip'}) choices={'prompt', 'force', 'skip'})
parser.add_argument( parser.add_argument(
'-i', '--input-ext', default=config['input-ext'], '-i', '--input-ext', default=config['input-ext'],
help='prefered input format .m4a or .webm (Opus)') help='prefered input format .m4a or .webm (Opus)',
choices={'.m4a', '.webm'})
parser.add_argument( parser.add_argument(
'-o', '--output-ext', default=config['output-ext'], '-o', '--output-ext', default=config['output-ext'],
help='prefered output extension .mp3 or .m4a (AAC)') help='prefered output format .mp3, .m4a (AAC), .flac, etc.')
parser.add_argument( parser.add_argument(
'-ff', '--file-format', default=config['file-format'], '-ff', '--file-format', default=config['file-format'],
help='File format to save the downloaded song with, each tag ' help='File format to save the downloaded song with, each tag '

View File

@@ -1,7 +1,8 @@
from mutagen.easyid3 import EasyID3 from mutagen.easyid3 import EasyID3
from mutagen.id3 import ID3, TORY, TYER, TPUB, APIC, USLT, COMM from mutagen.id3 import ID3, TORY, TYER, TPUB, APIC, USLT, COMM
from mutagen.mp4 import MP4, MP4Cover from mutagen.mp4 import MP4, MP4Cover
from core.const import log from mutagen.flac import Picture, FLAC
from core.const import log, TAG_PRESET, M4A_TAG_PRESET
import urllib.request import urllib.request
@@ -31,6 +32,9 @@ def embed(music_file, meta_tags):
elif music_file.endswith('.mp3'): elif music_file.endswith('.mp3'):
log.info('Applying metadata') log.info('Applying metadata')
return embed.as_mp3() return embed.as_mp3()
elif music_file.endswith('.flac'):
log.info('Applying metadata')
return embed.as_flac()
else: else:
log.warning('Cannot embed metadata into given output extension') log.warning('Cannot embed metadata into given output extension')
return False return False
@@ -50,15 +54,7 @@ class EmbedMetadata:
# https://github.com/quodlibet/mutagen/blob/master/mutagen/easyid3.py # https://github.com/quodlibet/mutagen/blob/master/mutagen/easyid3.py
# Check out somewhere at end of above linked file # Check out somewhere at end of above linked file
audiofile = EasyID3(music_file) audiofile = EasyID3(music_file)
audiofile['artist'] = meta_tags['artists'][0]['name'] self._embed_basic_metadata(audiofile, preset=TAG_PRESET)
audiofile['albumartist'] = meta_tags['artists'][0]['name']
audiofile['album'] = meta_tags['album']['name']
audiofile['title'] = meta_tags['name']
audiofile['tracknumber'] = [meta_tags['track_number'],
meta_tags['total_tracks']]
audiofile['discnumber'] = [meta_tags['disc_number'], 0]
audiofile['date'] = meta_tags['release_date']
audiofile['originaldate'] = meta_tags['release_date']
audiofile['media'] = meta_tags['type'] audiofile['media'] = meta_tags['type']
audiofile['author'] = meta_tags['artists'][0]['name'] audiofile['author'] = meta_tags['artists'][0]['name']
audiofile['lyricist'] = meta_tags['artists'][0]['name'] audiofile['lyricist'] = meta_tags['artists'][0]['name']
@@ -68,10 +64,6 @@ class EmbedMetadata:
audiofile['length'] = str(meta_tags['duration']) audiofile['length'] = str(meta_tags['duration'])
if meta_tags['publisher']: if meta_tags['publisher']:
audiofile['encodedby'] = meta_tags['publisher'] audiofile['encodedby'] = meta_tags['publisher']
if meta_tags['genre']:
audiofile['genre'] = meta_tags['genre']
if meta_tags['copyright']:
audiofile['copyright'] = meta_tags['copyright']
if meta_tags['external_ids']['isrc']: if meta_tags['external_ids']['isrc']:
audiofile['isrc'] = meta_tags['external_ids']['isrc'] audiofile['isrc'] = meta_tags['external_ids']['isrc']
audiofile.save(v2_version=3) audiofile.save(v2_version=3)
@@ -101,48 +93,13 @@ class EmbedMetadata:
""" Embed metadata to M4A files. """ """ Embed metadata to M4A files. """
music_file = self.music_file music_file = self.music_file
meta_tags = self.meta_tags meta_tags = self.meta_tags
# Apple has specific tags - see mutagen docs -
# http://mutagen.readthedocs.io/en/latest/api/mp4.html
tags = { 'album' : '\xa9alb',
'artist' : '\xa9ART',
'date' : '\xa9day',
'title' : '\xa9nam',
'year' : '\xa9day',
'originaldate' : 'purd',
'comment' : '\xa9cmt',
'group' : '\xa9grp',
'writer' : '\xa9wrt',
'genre' : '\xa9gen',
'tracknumber' : 'trkn',
'albumartist' : 'aART',
'disknumber' : 'disk',
'cpil' : 'cpil',
'albumart' : 'covr',
'copyright' : 'cprt',
'tempo' : 'tmpo',
'lyrics' : '\xa9lyr' }
audiofile = MP4(music_file) audiofile = MP4(music_file)
audiofile[tags['artist']] = meta_tags['artists'][0]['name'] self._embed_basic_metadata(audiofile, preset=M4A_TAG_PRESET)
audiofile[tags['albumartist']] = meta_tags['artists'][0]['name'] audiofile[M4A_TAG_PRESET['year']] = meta_tags['year']
audiofile[tags['album']] = meta_tags['album']['name'] audiofile[M4A_TAG_PRESET['comment']] = meta_tags['external_urls']['spotify']
audiofile[tags['title']] = meta_tags['name']
audiofile[tags['tracknumber']] = [(meta_tags['track_number'],
meta_tags['total_tracks'])]
audiofile[tags['disknumber']] = [(meta_tags['disc_number'], 0)]
audiofile[tags['date']] = meta_tags['release_date']
audiofile[tags['year']] = meta_tags['year']
audiofile[tags['originaldate']] = meta_tags['release_date']
audiofile[tags['comment']] = meta_tags['external_urls']['spotify']
if meta_tags['genre']:
audiofile[tags['genre']] = meta_tags['genre']
if meta_tags['copyright']:
audiofile[tags['copyright']] = meta_tags['copyright']
if meta_tags['lyrics']:
audiofile[tags['lyrics']] = meta_tags['lyrics']
try: try:
albumart = urllib.request.urlopen(meta_tags['album']['images'][0]['url']) albumart = urllib.request.urlopen(meta_tags['album']['images'][0]['url'])
audiofile[tags['albumart']] = [MP4Cover( audiofile[M4A_TAG_PRESET['albumart']] = [MP4Cover(
albumart.read(), imageformat=MP4Cover.FORMAT_JPEG)] albumart.read(), imageformat=MP4Cover.FORMAT_JPEG)]
albumart.close() albumart.close()
except IndexError: except IndexError:
@@ -150,3 +107,47 @@ class EmbedMetadata:
audiofile.save() audiofile.save()
return True return True
def as_flac(self):
music_file = self.music_file
meta_tags = self.meta_tags
audiofile = FLAC(music_file)
self._embed_basic_metadata(audiofile)
audiofile['year'] = meta_tags['year']
audiofile['comment'] = meta_tags['external_urls']['spotify']
image = Picture()
image.type = 3
image.desc = 'Cover'
image.mime = 'image/jpeg'
albumart = urllib.request.urlopen(meta_tags['album']['images'][0]['url'])
image.data = albumart.read()
albumart.close()
audiofile.add_picture(image)
audiofile.save()
return True
def _embed_basic_metadata(self, audiofile, preset=TAG_PRESET):
meta_tags = self.meta_tags
audiofile[preset['artist']] = meta_tags['artists'][0]['name']
audiofile[preset['albumartist']] = meta_tags['artists'][0]['name']
audiofile[preset['album']] = meta_tags['album']['name']
audiofile[preset['title']] = meta_tags['name']
audiofile[preset['date']] = meta_tags['release_date']
audiofile[preset['originaldate']] = meta_tags['release_date']
if meta_tags['genre']:
audiofile[preset['genre']] = meta_tags['genre']
if meta_tags['copyright']:
audiofile[preset['copyright']] = meta_tags['copyright']
if meta_tags['lyrics']:
audiofile[preset['lyrics']] = meta_tags['lyrics']
if self.music_file.endswith('.flac'):
audiofile[preset['discnumber']] = str(meta_tags['disc_number'])
else:
audiofile[preset['discnumber']] = [(meta_tags['disc_number'], 0)]
if self.music_file.endswith('.flac'):
audiofile[preset['tracknumber']] = str(meta_tags['track_number'])
else:
audiofile[preset['tracknumber']] = [(meta_tags['track_number'],
meta_tags['total_tracks'])]

View File

@@ -172,7 +172,7 @@ if __name__ == '__main__':
internals.filter_path(const.args.folder) internals.filter_path(const.args.folder)
youtube_tools.set_api_key() youtube_tools.set_api_key()
const.log = const.logzero.setup_logger(formatter=const.formatter, const.log = const.logzero.setup_logger(formatter=const._formatter,
level=const.args.log_level) level=const.args.log_level)
log = const.log log = const.log
log.debug('Python version: {}'.format(sys.version)) log.debug('Python version: {}'.format(sys.version))

View File

@@ -10,5 +10,5 @@ def load_defaults():
const.args.log_level = 10 const.args.log_level = 10
spotdl.args = const.args spotdl.args = const.args
spotdl.log = const.logzero.setup_logger(formatter=const.formatter, spotdl.log = const.logzero.setup_logger(formatter=const._formatter,
level=const.args.log_level) level=const.args.log_level)

View File

@@ -16,7 +16,7 @@ raw_song = 'http://open.spotify.com/track/0JlS7BXXD07hRmevDnbPDU'
def test_metadata(): def test_metadata():
expect_number = 22 expect_number = 23
global meta_tags global meta_tags
meta_tags = spotify_tools.generate_metadata(raw_song) meta_tags = spotify_tools.generate_metadata(raw_song)
assert len(meta_tags) == expect_number assert len(meta_tags) == expect_number
@@ -102,6 +102,13 @@ class TestFFmpeg():
const.args.folder) const.args.folder)
assert return_code == expect_return_code assert return_code == expect_return_code
def test_convert_from_m4a_to_flac(self):
expect_return_code = 0
return_code = convert.song(file_name + '.m4a',
file_name + '.flac',
const.args.folder)
assert return_code == expect_return_code
class TestAvconv: class TestAvconv:
def test_convert_from_m4a_to_mp3(self): def test_convert_from_m4a_to_mp3(self):
@@ -133,10 +140,15 @@ class TestEmbedMetadata:
os.remove(track_path + '.webm') os.remove(track_path + '.webm')
assert embed == expect_embed assert embed == expect_embed
def test_embed_in_flac(self):
expect_embed = True
embed = metadata.embed(track_path + '.flac', meta_tags)
os.remove(track_path + '.flac')
assert embed == expect_embed
def test_check_track_exists_after_download(): def test_check_track_exists_after_download():
expect_check = True expect_check = True
# prerequisites for determining filename
check = spotdl.check_exists(file_name, raw_song, meta_tags) check = spotdl.check_exists(file_name, raw_song, meta_tags)
os.remove(track_path + '.mp3') os.remove(track_path + '.mp3')
assert check == expect_check assert check == expect_check