diff --git a/.gitignore b/.gitignore index ffeaad5..e4b8660 100755 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.pyc __pycache__/ -/Music/ -/*.txt +.cache/ +Music/ +*.txt diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f11a1a6 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,23 @@ +language: python +python: + - "2.7" + - "3.4" + - "3.5" + - "3.6" +before_install: + - sudo apt-get -qq update + - sudo apt-get -y install autoconf automake build-essential libass-dev libfreetype6-dev libtheora-dev libtool libva-dev libvdpau-dev libvorbis-dev libxcb1-dev libxcb-shm0-dev libxcb-xfixes0-dev pkg-config texinfo wget zlib1g-dev + - sudo apt-get -y install yasm nasm libmp3lame-dev + - mkdir ~/ffmpeg_sources + - cd ~/ffmpeg_sources + - wget http://ffmpeg.org/releases/ffmpeg-snapshot.tar.bz2 + - tar jxf ffmpeg-snapshot.tar.bz2 + - cd ffmpeg + - PATH="$HOME/bin:$PATH" PKG_CONFIG_PATH="$HOME/ffmpeg_build/lib/pkgconfig" ./configure --prefix="$HOME/ffmpeg_build" --pkg-config-flags="--static" --extra-cflags="-I$HOME/ffmpeg_build/include" --extra-ldflags="-L$HOME/ffmpeg_build/lib" --bindir="$HOME/bin" --enable-libmp3lame + - PATH="$HOME/bin:$PATH" make -s -j + - make install + - hash -r +install: + - cd $TRAVIS_BUILD_DIR + - pip install -r requirements.txt +script: python -m pytest test diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..0514b16 --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,31 @@ + + +- [ ] Using latest version as provided on the [master branch](https://github.com/ritiek/spotify-downloader/tree/master) +- [ ] [Searched](https://github.com/ritiek/spotify-downloader/issues?utf8=%E2%9C%93&q=is%3Aissue) for similar issues including closed ones + +#### What is the purpose of your *issue*? +- [ ] Script won't run +- [ ] Encountered bug +- [ ] Feature request +- [ ] Question +- [ ] Other + +#### System information +- Your `python` version: `python 2.7` +- Your operating system: `Ubuntu 16.04` + +### Description + + + + + + + + diff --git a/README.md b/README.md index 5c254bb..69c204f 100755 --- a/README.md +++ b/README.md @@ -52,7 +52,9 @@ You'll also need to install FFmpeg for conversion (use `--avconv` if you'd like Linux: `sudo apt-get install ffmpeg` -Mac: `brew install ffmpeg --with-libass --with-opus --with-fdk-aac` +Mac: `brew install ffmpeg --with-libmp3lame --with-libass --with-opus --with-fdk-aac` + +If it does not install correctly, you may have to build it from source. For more info see https://trac.ffmpeg.org/wiki/CompilationGuide. ### Windows @@ -153,20 +155,9 @@ http://open.spotify.com/track/64yrDBpcdwEdNY9loyEGbX - Then you can simply run `python spotdl.py --list=.txt` to download them all! -## FAQ +## Running tests -#### I get system cannot find the specified file when downloading? - -Check out these issues [#22](https://github.com/Ritiek/Spotify-Downloader/issues/22), [#35](https://github.com/Ritiek/Spotify-Downloader/issues/35), [#36](https://github.com/Ritiek/Spotify-Downloader/issues/36). - -#### How can I download whole playlist with its URI? - -~~Currently this is not possible without generating unique tokens from Spotify but you can copy all the songs from a playlist and paste them in `list.txt`. I am avoiding tokens as much possible to retain the portability of this tool but if you would like to add it as an optional feature to this tool, PR's welcome!~~ -This feature has been added! - -#### You write horrible code. What's wrong with you? - -I'm trying... +`python -m pytest test` ## Disclaimer diff --git a/core/convert.py b/core/convert.py new file mode 100644 index 0000000..cf7f522 --- /dev/null +++ b/core/convert.py @@ -0,0 +1,78 @@ +import subprocess +import os +import sys + +def song(input_song, output_song, avconv=False, verbose=False): + if not input_song == output_song: + if sys.version_info < (3, 0): + input_song = input_song.encode('utf-8') + output_song = output_song.encode('utf-8') + print('Converting ' + input_song + ' to ' + output_song.split('.')[-1]) + if avconv: + exit_code = convert_with_avconv(input_song, output_song, verbose) + else: + exit_code = convert_with_FFmpeg(input_song, output_song, verbose) + return exit_code + return None + +def convert_with_avconv(input_song, output_song, verbose): + # different path for windows + if os.name == 'nt': + avconv_path = 'Scripts\\avconv.exe' + else: + avconv_path = 'avconv' + + if verbose: + level = 'debug' + else: + level = '0' + + command = [avconv_path, + '-loglevel', level, + '-i', 'Music/' + input_song, + '-ab', '192k', + 'Music/' + output_song] + + subprocess.call(command) + + +def convert_with_FFmpeg(input_song, output_song, verbose): + # 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 + + if os.name == "nt": + ffmpeg_pre = 'Scripts\\ffmpeg.exe ' + else: + ffmpeg_pre = 'ffmpeg ' + + ffmpeg_pre += '-y ' + if not verbose: + ffmpeg_pre += '-hide_banner -nostats -v panic ' + + input_ext = input_song.split('.')[-1] + output_ext = output_song.split('.')[-1] + + if input_ext == 'm4a': + if output_ext == 'mp3': + ffmpeg_params = '-codec:v copy -codec:a libmp3lame -q:a 2 ' + elif output_ext == 'webm': + ffmpeg_params = '-c:a libopus -vbr on -b:a 192k -vn ' + + elif input_ext == 'webm': + if output_ext == 'mp3': + ffmpeg_params = ' -ab 192k -ar 44100 -vn ' + elif output_ext == 'm4a': + ffmpeg_params = '-cutoff 20000 -c:a libfdk_aac -b:a 192k -vn ' + + command = (ffmpeg_pre + + '-i Music/' + input_song + ' ' + + ffmpeg_params + + 'Music/' + output_song + '').split(' ') + + subprocess.call(command) + diff --git a/core/metadata.py b/core/metadata.py index 6549755..a751c59 100755 --- a/core/metadata.py +++ b/core/metadata.py @@ -25,23 +25,25 @@ def compare(file, metadata): already_tagged = False return already_tagged -def embed(music_file, meta_tags, output_ext): +def embed(music_file, meta_tags): if sys.version_info < (3, 0): - music_file = misc.encode('utf-8') + music_file = music_file.encode('utf-8') if meta_tags is None: print('Could not find meta-tags') - elif output_ext == '.m4a': + return None + elif music_file.endswith('.m4a'): print('Fixing meta-tags') - embed_m4a(music_file, meta_tags, output_ext) - elif output_ext == '.mp3': + return embed_m4a(music_file, meta_tags) + elif music_file.endswith('.mp3'): print('Fixing meta-tags') - embed_mp3(music_file, meta_tags, output_ext) + return embed_mp3(music_file, meta_tags) else: print('Cannot embed meta-tags into given output extension') + return False -def embed_mp3(music_file, meta_tags, output_ext): +def embed_mp3(music_file, meta_tags): # EasyID3 is fun to use ;) - audiofile = EasyID3('Music/' + music_file + output_ext) + audiofile = EasyID3('Music/' + music_file) audiofile['artist'] = meta_tags['artists'][0]['name'] audiofile['albumartist'] = meta_tags['artists'][0]['name'] audiofile['album'] = meta_tags['album']['name'] @@ -64,13 +66,14 @@ def embed_mp3(music_file, meta_tags, output_ext): if meta_tags['copyright']: audiofile['copyright'] = meta_tags['copyright'] audiofile.save(v2_version=3) - audiofile = ID3('Music/' + music_file + output_ext) + audiofile = ID3('Music/' + music_file) albumart = urllib2.urlopen(meta_tags['album']['images'][0]['url']) audiofile["APIC"] = APIC(encoding=3, mime='image/jpeg', type=3, desc=u'Cover', data=albumart.read()) albumart.close() audiofile.save(v2_version=3) + return True -def embed_m4a(music_file, meta_tags, output_ext): +def embed_m4a(music_file, meta_tags): # Apple has specific tags - see mutagen docs - # http://mutagen.readthedocs.io/en/latest/api/mp4.html tags = {'album': '\xa9alb', @@ -90,7 +93,7 @@ def embed_m4a(music_file, meta_tags, output_ext): 'copyright': 'cprt', 'tempo': 'tmpo'} - audiofile = MP4('Music/' + music_file + output_ext) + audiofile = MP4('Music/' + music_file) audiofile[tags['artist']] = meta_tags['artists'][0]['name'] audiofile[tags['albumartist']] = meta_tags['artists'][0]['name'] audiofile[tags['album']] = meta_tags['album']['name'] @@ -107,3 +110,4 @@ def embed_m4a(music_file, meta_tags, output_ext): audiofile[tags['albumart']] = [ MP4Cover(albumart.read(), imageformat=MP4Cover.FORMAT_JPEG) ] albumart.close() audiofile.save() + return True diff --git a/core/misc.py b/core/misc.py index e9b5e86..f3da00f 100755 --- a/core/misc.py +++ b/core/misc.py @@ -101,10 +101,10 @@ def generate_token(): token = creds.get_access_token() return token -def generate_search_URL(song): +def generate_search_url(song): # urllib2.quote() encodes URL with special characters - URL = "https://www.youtube.com/results?sp=EgIQAQ%253D%253D&q=" + quote(song) - return URL + url = "https://www.youtube.com/results?sp=EgIQAQ%253D%253D&q=" + quote(song) + return url # fix encoding issues in python2 def fix_encoding(query): @@ -117,6 +117,14 @@ def fix_decoding(query): query = query.decode('utf-8') return query +def filter_path(path): + os.chdir(sys.path[0]) + if not os.path.exists(path): + os.makedirs(path) + for temp in os.listdir(path): + if temp.endswith('.temp'): + os.remove(path + '/' + temp) + def grace_quit(): print('') print('') diff --git a/spotdl.py b/spotdl.py index fa9b31a..ee2d067 100755 --- a/spotdl.py +++ b/spotdl.py @@ -3,6 +3,7 @@ # -*- coding: UTF-8 -*- from core import metadata +from core import convert from core import misc from bs4 import BeautifulSoup from titlecase import titlecase @@ -11,7 +12,6 @@ import spotipy import pafy import sys import os -import subprocess # urllib2 is urllib.request in python3 try: @@ -19,7 +19,7 @@ try: except ImportError: import urllib.request as urllib2 -# decode spotify link to "[artist] - [song]" +# "[artist] - [song]" def generate_songname(raw_song): if misc.is_spotify(raw_song): tags = generate_metadata(raw_song) @@ -33,8 +33,10 @@ def generate_metadata(raw_song): meta_tags = spotify.track(raw_song) else: # otherwise search on spotify and fetch information from first result - meta_tags = spotify.search(raw_song, limit=1)['tracks']['items'][0] - + try: + meta_tags = spotify.search(raw_song, limit=1)['tracks']['items'][0] + except: + return None artist = spotify.artist(meta_tags['artists'][0]['id']) album = spotify.album(meta_tags['album']['id']) @@ -55,12 +57,12 @@ def generate_metadata(raw_song): #pprint.pprint(spotify.album(meta_tags['album']['id'])) return meta_tags -def generate_YouTube_URL(raw_song): +def generate_youtube_url(raw_song): # decode spotify http link to "[artist] - [song]" song = generate_songname(raw_song) # generate direct search YouTube URL - searchURL = misc.generate_search_URL(song) - item = urllib2.urlopen(searchURL).read() + search_url = misc.generate_search_url(song) + item = urllib2.urlopen(search_url).read() #item = unicode(item, 'utf-8') items_parse = BeautifulSoup(item, "html.parser") check = 1 @@ -95,15 +97,15 @@ def generate_YouTube_URL(raw_song): # parse track from YouTube def go_pafy(raw_song): # video link of the video to extract audio from - trackURL = generate_YouTube_URL(raw_song) - if trackURL is None: + track_url = generate_youtube_url(raw_song) + if track_url is None: return None else: # parse the YouTube video - return pafy.new(trackURL) + return pafy.new(track_url) # title of the YouTube video -def get_YouTube_title(content, number): +def get_youtube_title(content, number=None): title = misc.fix_encoding(content.title) if number is None: return title @@ -157,77 +159,8 @@ def download_song(content): link.download(filepath='Music/' + music_file + args.input_ext) return True -# convert song from input_ext to output_ext -def convert_song(music_file): - # skip conversion if input_ext == output_ext - if not args.input_ext == args.output_ext: - music_file = misc.fix_encoding(music_file) - print('Converting ' + music_file + args.input_ext + ' to ' + args.output_ext[1:]) - if args.avconv: - convert_with_avconv(music_file) - else: - convert_with_FFmpeg(music_file) - os.remove('Music/' + music_file + args.input_ext) - -def convert_with_avconv(music_file): - # different path for windows - if os.name == 'nt': - avconv_path = 'Scripts\\avconv.exe' - else: - avconv_path = 'avconv' - - if args.verbose: - level = 'debug' - else: - level = '0' - - command = [avconv_path, - '-loglevel', level, - '-i', 'Music/' + music_file + args.input_ext, - '-ab', '192k', - 'Music/' + music_file + args.output_ext] - - subprocess.call(command) - - -def convert_with_FFmpeg(music_file): - # 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 - - if os.name == "nt": - ffmpeg_pre = 'Scripts\\ffmpeg.exe ' - else: - ffmpeg_pre = 'ffmpeg ' - - ffmpeg_pre += '-y ' - if not args.verbose: - ffmpeg_pre += '-hide_banner -nostats -v panic ' - - if args.input_ext == '.m4a': - if args.output_ext == '.mp3': - ffmpeg_params = '-codec:v copy -codec:a libmp3lame -q:a 2 ' - elif output_ext == '.webm': - ffmpeg_params = '-c:a libopus -vbr on -b:a 192k -vn ' - elif args.input_ext == '.webm': - if args.output_ext == '.mp3': - ffmpeg_params = ' -ab 192k -ar 44100 -vn ' - elif args.output_ext == '.m4a': - ffmpeg_params = '-cutoff 20000 -c:a libfdk_aac -b:a 192k -vn ' - - command = (ffmpeg_pre + - '-i Music/' + music_file + args.input_ext + ' ' + - ffmpeg_params + - 'Music/' + music_file + args.output_ext + '').split(' ') - - subprocess.call(command) - # check if input song already exists in Music folder -def check_exists(music_file, raw_song, islist): +def check_exists(music_file, raw_song, islist=True): files = os.listdir("Music") for file in files: if file.endswith(".temp"): @@ -254,6 +187,7 @@ def check_exists(music_file, raw_song, islist): return False else: return True + return False # download songs from list def grab_list(file): @@ -307,32 +241,43 @@ def grab_single(raw_song, number=None): return # print "[number]. [artist] - [song]" if downloading from list # otherwise print "[artist] - [song]" - print(get_YouTube_title(content, number)) + print(get_youtube_title(content, number)) # generate file name of the song to download music_file = misc.generate_filename(content.title) music_file = misc.fix_decoding(music_file) if not check_exists(music_file, raw_song, islist=islist): if download_song(content): print('') - convert_song(music_file) + input_song = music_file + args.input_ext + output_song = music_file + args.output_ext + convert.song(input_song, + output_song, + avconv=args.avconv, + verbose=args.verbose) + os.remove('Music/' + input_song) meta_tags = generate_metadata(raw_song) if not args.no_metadata: - metadata.embed(music_file, meta_tags, args.output_ext) + metadata.embed(output_song, meta_tags) else: print('No audio streams available') +class Args(object): + manual = False + input_ext = '.m4a' + output_ext = '.mp3' + +args = Args() +# token is mandatory when using Spotify's API +# https://developer.spotify.com/news-stories/2017/01/27/removing-unauthenticated-calls-to-the-web-api/ +token = misc.generate_token() +spotify = spotipy.Spotify(auth=token) + +misc.filter_path('Music') + if __name__ == '__main__': os.chdir(sys.path[0]) - if not os.path.exists("Music"): - os.makedirs("Music") - # token is mandatory when using Spotify's API - # https://developer.spotify.com/news-stories/2017/01/27/removing-unauthenticated-calls-to-the-web-api/ - token = misc.generate_token() - spotify = spotipy.Spotify(auth=token) - - # set up arguments args = misc.get_arguments() if args.song: @@ -340,4 +285,4 @@ if __name__ == '__main__': elif args.list: grab_list(file=args.list) elif args.username: - feed_playlist(username=args.username) + feed_playlist(username=args.username) \ No newline at end of file diff --git a/test/test_single.py b/test/test_single.py new file mode 100644 index 0000000..5b55f1e --- /dev/null +++ b/test/test_single.py @@ -0,0 +1,69 @@ +# -*- coding: UTF-8 -*- + +import spotdl + +raw_song = 'http://open.spotify.com/track/0JlS7BXXD07hRmevDnbPDU' + +def test_spotify_title(): + expect_title = 'David André Østby - Intro' + title = spotdl.generate_songname(raw_song) + assert title == expect_title + +def test_youtube_url(): + expect_url = 'youtube.com/watch?v=rg1wfcty0BA' + url = spotdl.generate_youtube_url(raw_song) + assert url == expect_url + +def test_youtube_title(): + expect_title = 'Intro - David André Østby' + content = spotdl.go_pafy(raw_song) + title = spotdl.get_youtube_title(content) + assert title == expect_title + +def test_check_exists(): + expect_check = False + content = spotdl.go_pafy(raw_song) + music_file = spotdl.misc.generate_filename(content.title) + music_file = spotdl.misc.fix_decoding(music_file) + check = spotdl.check_exists(music_file, raw_song) + assert check == expect_check + +def test_download(): + expect_download = True + content = spotdl.go_pafy(raw_song) + download = spotdl.download_song(content) + assert download == expect_download + +def test_convert(): + # exit code None = success + expect_convert = None + content = spotdl.go_pafy(raw_song) + music_file = spotdl.misc.generate_filename(content.title) + music_file = spotdl.misc.fix_decoding(music_file) + input_song = music_file + spotdl.args.input_ext + output_song = music_file + spotdl.args.output_ext + convert = spotdl.convert.song(input_song, output_song) + assert convert == expect_convert + +def test_metadata(): + expect_metadata = True + content = spotdl.go_pafy(raw_song) + music_file = spotdl.misc.generate_filename(content.title) + music_file = spotdl.misc.fix_decoding(music_file) + meta_tags = spotdl.generate_metadata(raw_song) + + output_song = music_file + spotdl.args.output_ext + metadata_output = spotdl.metadata.embed(output_song, meta_tags) + + input_song = music_file + spotdl.args.input_ext + metadata_input = spotdl.metadata.embed(input_song, meta_tags) + + assert metadata_output == (metadata_input == expect_metadata) + +def check_exists2(): + expect_check = True + content = spotdl.go_pafy(raw_song) + music_file = spotdl.misc.generate_filename(content.title) + music_file = spotdl.misc.fix_decoding(music_file) + check = spotdl.check_exists(music_file, raw_song) + assert check == expect_check diff --git a/test/test_username.py b/test/test_username.py new file mode 100644 index 0000000..16c608c --- /dev/null +++ b/test/test_username.py @@ -0,0 +1,42 @@ +# -*- coding: UTF-8 -*- + +import spotdl + +username = 'alex' + +def test_user(): + expect_playlists = 7 + playlists = spotdl.spotify.user_playlists(username) + playlists = len(playlists['items']) + assert playlists == expect_playlists + +def test_playlist(): + expect_tracks = 14 + playlist = spotdl.spotify.user_playlists(username)['items'][0] + tracks = playlist['tracks']['total'] + assert tracks == expect_tracks + +def test_list(): + playlist = spotdl.spotify.user_playlists(username)['items'][0] + expect_lines = playlist['tracks']['total'] + result = spotdl.spotify.user_playlist(playlist['owner']['id'], playlist['id'], fields='tracks,next') + tracks = result['tracks'] + spotdl.misc.feed_tracks('list.txt', tracks) + + while tracks['next']: + tracks = spotify.next(tracks) + misc.feed_tracks('list.txt', tracks) + + with open('list.txt', 'r') as listed: + expect_song = (listed.read()).splitlines()[0] + + spotdl.misc.trim_song('list.txt') + with open('list.txt', 'a') as myfile: + myfile.write(expect_song) + + with open('list.txt', 'r') as listed: + songs = (listed.read()).splitlines() + + lines = len(songs) + song = songs[-1] + assert (expect_lines == lines and expect_song == song)