├── requirements.txt ├── Dockerfile ├── README.md └── read-id3-tags.py /requirements.txt: -------------------------------------------------------------------------------- 1 | spotipy==2.11.1 2 | eyed3==v0.7.11 3 | termcolor==1.1.0 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:2.7.17-slim-buster as python 2 | 3 | FROM python as build 4 | COPY requirements.txt ./ 5 | RUN pip install -r requirements.txt 6 | RUN mkdir -p dist/usr/local/lib/python2.7/site-packages 7 | RUN cp -r /usr/local/lib/python2.7/site-packages dist/usr/local/lib/python2.7/ 8 | COPY read-id3-tags.py dist 9 | 10 | FROM python 11 | COPY --from=build dist / 12 | ENTRYPOINT [ "./read-id3-tags.py" ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spotify-m3u-import 2 | 3 | A small python script to create a Spotify playlist from a m3u playlist file. 4 | 5 | It will: 6 | 7 | - Read each entry in the playlist file 8 | - Read the IDv3 tags from each MP3 file 9 | - If there are no IDv3 tags it will attempt guess the artist and title from the file name 10 | - Use this data to find a track on Spotify 11 | - Create a Spotify playlist using the results 12 | 13 | ## Installation and requirements 14 | 15 | Install python modules: 16 | 17 | ``` 18 | pip install -r requirements.txt 19 | ``` 20 | 21 | Take 5 mins to register an app to get access to the Spotify API: 22 | 23 | https://developer.spotify.com/my-applications/#!/ 24 | 25 | The Redirect URI doesn't need to be valid, it can be a non-existant domain. 26 | 27 | Export Spotify related environment variables from your new app: 28 | 29 | ``` 30 | export SPOTIPY_CLIENT_ID='your-spotify-client-id' 31 | export SPOTIPY_CLIENT_SECRET='your-spotify-client-secret' 32 | export SPOTIPY_REDIRECT_URI='your-app-redirect-url' 33 | ``` 34 | 35 | ## Example 36 | 37 | ``` 38 | $ ./read-id3-tags.py --help 39 | usage: read-id3-tags.py [-h] -f FILE -u USERNAME [-d] 40 | 41 | A script to import a m3u playlist into Spotify 42 | 43 | optional arguments: 44 | -h, --help show this help message and exit 45 | -f FILE, --file FILE Path to m3u playlist file 46 | -u USERNAME, --username USERNAME 47 | Spotify username 48 | -d, --debug Debug mode 49 | $ 50 | $ ./read-id3-tags.py -f my_playlist.m3u -u my_username 51 | Parsed 3 tracks from my_playlist.m3u 52 | 53 | tracks/inspectah deck - the movement - 12 - vendetta.mp3 54 | IDv3 tag data: Inspectah Deck - Vendetta 55 | Guess from filename: Not required 56 | Spotify: Inspectah Deck - Vendetta, 23GoX2Usy1Ios5zCVRIIAO 57 | 58 | tracks/darude-sandstorm.mp3 59 | IDv3 tag data: None 60 | Guess from filename: darude - sandstorm 61 | Spotify: Darude - Sandstorm - Extended, 7ikiyBfgcVuAKAwZXXkWVT 62 | 63 | tracks/dave spoon - at night (shadow child & t. williams re-vibe).mp3 64 | IDv3 tag data: None 65 | Guess from filename: dave spoon - at night (shadow child & t. williams re 66 | Spotify: Dave Spoon - At Night - Shadow Child & T. Williams Re-vibe, 1JEA273o693GwuI39gayHk 67 | 68 | 3/3 of tracks matched on Spotify, creating playlist "my_playlist.m3u" on Spotify... done 69 | ``` 70 | 71 | ## Docker 72 | 73 | ```bash 74 | docker build -t m3u 75 | 76 | docker run --rm \ 77 | -e SPOTIFY_CLIENT_ID=abc \ 78 | -e SPOTIFY_CLIENT_SECRET=def \ 79 | -v $(pwd):/app \ 80 | m3u -f /app/my.m3u -u xyz 81 | ``` -------------------------------------------------------------------------------- /read-id3-tags.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import eyed3 5 | import argparse 6 | import os 7 | import sys 8 | import pprint 9 | import logging 10 | import logging.handlers 11 | import spotipy 12 | import spotipy.util as util 13 | from difflib import SequenceMatcher 14 | from termcolor import colored 15 | 16 | def parse_arguments(): 17 | p = argparse.ArgumentParser(description='A script to import a m3u playlist into Spotify') 18 | p.add_argument('-f', '--file', help='Path to m3u playlist file', type=argparse.FileType('r'), required=True) 19 | p.add_argument('-u', '--username', help='Spotify username', required=True) 20 | p.add_argument('-d', '--debug', help='Debug mode', action='store_true', default=False) 21 | return p.parse_args() 22 | 23 | def load_playlist_file(playlist_file): 24 | tracks = [] 25 | try: 26 | content = [ line.strip() for line in playlist_file if line.strip() and not line.startswith("#") ] 27 | except Exception as e: 28 | logger.critical('Playlist file "%s" failed load: %s' % (playlist_file, str(e))) 29 | sys.exit(1) 30 | else: 31 | for track in content: 32 | tracks.append({'path': track}) 33 | return tracks 34 | 35 | def read_id3_tags(file_name): 36 | tag_data = False 37 | try: 38 | track_id3 = eyed3.load(file_name) 39 | except Exception as e: 40 | logger.debug('Track "%s" failed ID3 tag load: %s' % (track, str(e))) 41 | else: 42 | logger.debug('Reading tags from "%s"' % track) 43 | if track_id3.tag is not None: 44 | if track_id3.tag.artist is not None and track_id3.tag.title is not None: 45 | tag_data = {'artist': track_id3.tag.artist, 'title': track_id3.tag.title} 46 | return tag_data 47 | 48 | def guess_missing_track_info(file_name): 49 | guess = False 50 | filename = os.path.basename(file_name) 51 | filename_no_ext = os.path.splitext(filename)[0] 52 | track_uri_parts = filename_no_ext.split('-') 53 | if len(track_uri_parts) > 1: 54 | guess = {'filename': {} } 55 | guess['artist'] = track_uri_parts[0].strip() 56 | guess['title'] = track_uri_parts[1].strip() 57 | return guess 58 | 59 | def find_spotify_track(track): 60 | def _select_result_from_spotify_search(search_string, track_name, spotify_match_threshold): 61 | logger.debug('Searching Spotify for "%s" trying to find track called "%s"' % (search_string, track_name)) 62 | def _how_similar(a, b): 63 | return SequenceMatcher(None, a, b).ratio() 64 | results_raw = sp.search(q=search_string, limit=30) 65 | if len(results_raw['tracks']['items']) > 0: 66 | spotify_results = results_raw['tracks']['items'] 67 | logger.debug('Spotify results:%s' % len(spotify_results)) 68 | for spotify_result in spotify_results: 69 | spotify_result['rank'] = _how_similar(track_name, spotify_result['name']) 70 | if spotify_result['rank'] == 1.0: 71 | return {'id': spotify_result['id'], 'title': spotify_result['name'], 'artist': spotify_result['artists'][0]['name']} 72 | spotify_results_sorted = sorted(spotify_results, key=lambda k: k['rank'], reverse=True) 73 | if len(spotify_results_sorted) > 0 and spotify_results_sorted[0]['rank'] > spotify_match_threshold: 74 | return {'id': spotify_results_sorted[0]['id'], 'title': spotify_results_sorted[0]['name'], 'artist': spotify_results_sorted[0]['artists'][0]['name']} 75 | logger.debug('No good Spotify result found') 76 | return False 77 | spotify_match_threshold = 0.5 78 | # search by id3 tags 79 | if track['id3_data'] and 'artist' in track['id3_data'] and 'title' in track['id3_data']: 80 | spotify_search_string = '%s %s' % (track['id3_data']['artist'], track['id3_data']['title']) 81 | seach_result = _select_result_from_spotify_search( 82 | spotify_search_string, 83 | track['id3_data']['title'], 84 | spotify_match_threshold 85 | ) 86 | if seach_result: 87 | return seach_result 88 | # search by track['guess'] 89 | if 'guess' in track and track['guess'] and 'artist' in track['guess'] and 'title' in track['guess']: 90 | spotify_search_string = '%s %s' % (track['guess']['artist'], track['guess']['title']) 91 | seach_result = _select_result_from_spotify_search( 92 | spotify_search_string, 93 | track['guess']['title'], 94 | spotify_match_threshold 95 | ) 96 | if seach_result: 97 | return seach_result 98 | return False 99 | 100 | def format_track_info(track): 101 | if track['id3_data']: 102 | formatted_id3_data = '%s - %s' % (repr(track['id3_data']['artist']), repr(track['id3_data']['title'])) 103 | formatted_guess = 'Not required' 104 | else: 105 | formatted_id3_data = colored('None', 'red') 106 | if track['guess']: 107 | formatted_guess = '%s - %s' % (repr(track['guess']['artist']), repr(track['guess']['title'])) 108 | else: 109 | formatted_guess = colored('None', 'red') 110 | if track['spotify_data']: 111 | formatted_spotify = colored('%s - %s, %s' % (repr(track['spotify_data']['artist']), repr(track['spotify_data']['title']), repr(track['spotify_data']['id'])), 'green') 112 | else: 113 | formatted_spotify = colored('None', 'red') 114 | return '\n%s\nIDv3 tag data: %s\nGuess from filename: %s\nSpotify: %s' % ( 115 | colored(repr(track['path']), 'blue'), 116 | formatted_id3_data, 117 | formatted_guess, 118 | formatted_spotify 119 | ) 120 | 121 | if __name__ == "__main__": 122 | args = parse_arguments() 123 | sp = spotipy.Spotify() 124 | 125 | logger = logging.getLogger(__name__) 126 | if args.debug: 127 | logger.setLevel(logging.DEBUG) 128 | stdout_level = logging.DEBUG 129 | else: 130 | logger.setLevel(logging.CRITICAL) 131 | eyed3.log.setLevel("ERROR") 132 | stdout_level = logging.CRITICAL 133 | 134 | tracks = load_playlist_file(args.file) 135 | 136 | print colored('Parsed %s tracks from %s' % (len(tracks), args.file.name), 'green') 137 | 138 | for track in tracks: 139 | track['id3_data'] = read_id3_tags(track['path']) 140 | if not track['id3_data']: 141 | track['guess'] = guess_missing_track_info(track['path']) 142 | track['spotify_data'] = find_spotify_track(track) 143 | 144 | print format_track_info(track) 145 | 146 | spotify_tracks = [ k['spotify_data']['id'] for k in tracks if k.get('spotify_data') ] 147 | spotify_playlist_name = args.file.name 148 | spotify_username = args.username 149 | 150 | if len(spotify_tracks) < 1: 151 | print '\nNo tracks matched on Spotify' 152 | sys.exit(0) 153 | 154 | print '\n%s/%s of tracks matched on Spotify, creating playlist "%s" on Spotify...' % (len(spotify_tracks), len(tracks), spotify_playlist_name), 155 | 156 | token = util.prompt_for_user_token(spotify_username, 'playlist-modify-private') 157 | 158 | if token: 159 | try: 160 | sp = spotipy.Spotify(auth=token) 161 | sp.trace = False 162 | playlist = sp.user_playlist_create(spotify_username, spotify_playlist_name, public=False) 163 | if len(spotify_tracks) > 100: 164 | def chunker(seq, size): 165 | return (seq[pos:pos + size] for pos in xrange(0, len(seq), size)) 166 | for spotify_tracks_chunk in chunker(spotify_tracks, 100): 167 | results = sp.user_playlist_add_tracks(spotify_username, playlist['id'], spotify_tracks_chunk) 168 | else: 169 | results = sp.user_playlist_add_tracks(spotify_username, playlist['id'], spotify_tracks) 170 | except Exception as e: 171 | logger.critical('Spotify error: %s' % str(e)) 172 | else: 173 | print 'done\n' 174 | else: 175 | logger.critical('Can\'t get token for %s user' % spotify_username) 176 | --------------------------------------------------------------------------------