├── beetsplug ├── __init__.py └── setlister.py └── readme.md /beetsplug/__init__.py: -------------------------------------------------------------------------------- 1 | from pkgutil import extend_path 2 | __path__ = extend_path(__path__, __name__) 3 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Setlister 2 | 3 | Plugin for [beets](https://github.com/sampsyo/beets) to generate playlists from the setlists of a given artist, using [setlist.fm](http://www.setlist.fm) 4 | 5 | 6 | ## Usage 7 | 1. Clone this project, or download setlister.py, in to your configured pluginpath (e.g., `~/.beets`) 8 | 2. Add `setlister` to your configured beets plugins 9 | 3. Configure setlister to know where your playlists have to be placed 10 | ```yaml 11 | setlister: 12 | playlist_dir: ~/Music/setlists 13 | ``` 14 | Now you can run `$ beets setlister artist` to download the artists' latest setlist to your configured playlist directory, or specify the concert date using the `--date` option. 15 | 16 | ## Sample 17 | ```bash 18 | $ beet setlister alt-j 19 | Setlist: alt-J at Zenith (17-02-2015) (19 tracks) 20 | 1 Hunger of the Pine: found 21 | 2 Fitzpleasure: found 22 | 3 Something Good: found 23 | 4 Left Hand Free: found 24 | 5 Dissolve Me: found 25 | 6 Matilda: found 26 | 7 Bloodflood: found 27 | 8 Bloodflood Pt. 2: found 28 | 9 Leon: not found 29 | 10 ❦ (Ripe & Ruin): found 30 | 11 Tessellate: found 31 | 12 Every Other Freckle: found 32 | 13 Taro: found 33 | 14 Warm Foothills: found 34 | 15 The Gospel of John Hurt: found 35 | 16 Lovely Day: found 36 | 17 Nara: found 37 | 18 Leaving Nara: found 38 | 19 Breezeblocks: found 39 | Saved playlist at "/Users/tjs/Music/setlists/alt-J at Zenith (17-02-2015).m3u" 40 | 41 | ``` -------------------------------------------------------------------------------- /beetsplug/setlister.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015, Tom Jaspers . 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining 4 | # a copy of this software and associated documentation files (the 5 | # "Software"), to deal in the Software without restriction, including 6 | # without limitation the rights to use, copy, modify, merge, publish, 7 | # distribute, sublicense, and/or sell copies of the Software, and to 8 | # permit persons to whom the Software is furnished to do so, subject to 9 | # the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be 12 | # included in all copies or substantial portions of the Software. 13 | 14 | """Generates playlists from setlist.fm 15 | https://github.com/tomjaspers/beets-setlister 16 | """ 17 | 18 | from __future__ import (division, absolute_import, print_function, 19 | unicode_literals) 20 | 21 | from beets.plugins import BeetsPlugin 22 | from beets import ui 23 | from beets.library import Item 24 | from beets.dbcore.query import AndQuery, OrQuery, MatchQuery 25 | from beets.util import mkdirall, normpath, syspath 26 | import beets.autotag.hooks as hooks 27 | import os 28 | import requests 29 | 30 | 31 | def _get_best_match(items, track_name, artist_name): 32 | """ Returns the best match (according to a track_name/artist_name distance) 33 | from a list of Items 34 | """ 35 | 36 | def calc_distance(track_info, track_name, artist_name): 37 | dist = hooks.Distance() 38 | 39 | dist.add_string('track_title', track_name, track_info.title) 40 | 41 | if track_info.artist: 42 | dist.add_string('track_artist', 43 | artist_name, 44 | track_info.artist) 45 | 46 | return dist.distance 47 | 48 | matches = [(i, calc_distance(i, track_name, artist_name)) for i in items] 49 | matches.sort(key=lambda match: match[1]) 50 | 51 | return matches[0] 52 | 53 | 54 | def _get_mb_candidate(track_name, artist_name, threshold=0.2): 55 | """Returns the best candidate from MusicBrainz for a track_name/artist_name 56 | """ 57 | candidates = hooks.item_candidates(Item(), artist_name, track_name) 58 | best_match = _get_best_match(candidates, track_name, artist_name) 59 | 60 | return best_match[0] if best_match[1] <= threshold else None 61 | 62 | 63 | def _find_item_in_lib(lib, track_name, artist_name): 64 | """Finds an Item in the library based on the track_name. 65 | 66 | The track_name is not guaranteed to be perfect (i.e. as soon on MB), 67 | so in that case we query MB and look for the track id and query our 68 | lib with that. 69 | """ 70 | # Query the library based on the track name 71 | query = MatchQuery('title', track_name) 72 | lib_results = lib._fetch(Item, query=query) 73 | 74 | # Maybe the provided track name isn't all too good 75 | # Search for the track on MusicBrainz, and use that info to retry our lib 76 | if not lib_results: 77 | mb_candidate = _get_mb_candidate(track_name, artist_name) 78 | if mb_candidate: 79 | query = OrQuery(( 80 | AndQuery(( 81 | MatchQuery('title', mb_candidate.title), 82 | MatchQuery('artist', mb_candidate.artist), 83 | )), 84 | MatchQuery('mb_trackid', mb_candidate.track_id) 85 | )) 86 | lib_results = lib._fetch(Item, query=query) 87 | 88 | if not lib_results: 89 | return None 90 | 91 | # If we get multiple Item results from our library, choose best match 92 | # using the distance 93 | if len(lib_results) > 1: 94 | return _get_best_match(lib_results, track_name, artist_name)[0] 95 | 96 | return lib_results[0] 97 | 98 | 99 | def _save_playlist(m3u_path, items): 100 | """Saves a list of Items as a playlist at m3u_path 101 | """ 102 | mkdirall(m3u_path) 103 | with open(syspath(m3u_path), 'w') as f: 104 | for item in items: 105 | f.write(item.path + b'\n') 106 | 107 | 108 | requests_session = requests.Session() 109 | requests_session.headers = {'User-Agent': 'beets'} 110 | SETLISTFM_ENDPOINT = 'http://api.setlist.fm/rest/0.1/search/setlists.json' 111 | 112 | 113 | def _get_setlist(artist_name, date=None): 114 | """Query setlist.fm for an artist and return the first 115 | complete setlist, alongside some information about the event 116 | """ 117 | venue_name = None 118 | event_date = None 119 | track_names = [] 120 | 121 | # Query setlistfm using the artist_name 122 | response = requests_session.get(SETLISTFM_ENDPOINT, params={ 123 | 'artistName': artist_name, 124 | 'date': date, 125 | }) 126 | 127 | if not response.status_code == 200: 128 | return 129 | 130 | # Setlist.fm can have some events with empty setlists 131 | # We'll just pick the first event with a non-empty setlist 132 | results = response.json() 133 | setlists = results['setlists']['setlist'] 134 | if not isinstance(setlists, list): 135 | setlists = [setlists] 136 | for setlist in setlists: 137 | sets = setlist['sets'] 138 | if len(sets) > 0: 139 | artist_name = setlist['artist']['@name'] 140 | event_date = setlist['@eventDate'] 141 | venue_name = setlist['venue']['@name'] 142 | for subset in sets['set']: 143 | for song in subset['song']: 144 | track_names += [song['@name']] 145 | break # Stop because we have found a setlist 146 | 147 | return {'artist_name': artist_name, 148 | 'venue_name': venue_name, 149 | 'event_date': event_date, 150 | 'track_names': track_names} 151 | 152 | 153 | class SetlisterPlugin(BeetsPlugin): 154 | def __init__(self): 155 | super(SetlisterPlugin, self).__init__() 156 | self.config.add({ 157 | 'playlist_dir': None, 158 | }) 159 | 160 | def setlister(self, lib, artist_name, date=None): 161 | """Glue everything together 162 | """ 163 | if not self.config['playlist_dir']: 164 | self._log.warning(u'You have to configure a playlist_dir') 165 | return 166 | 167 | # Support `$ beet setlister red hot chili peppers` 168 | if isinstance(artist_name, list): 169 | artist_name = ' '.join(artist_name) 170 | 171 | if not artist_name: 172 | self._log.warning(u'You have to provide an artist') 173 | return 174 | 175 | # Extract setlist information from setlist.fm 176 | try: 177 | setlist = _get_setlist(artist_name, date) 178 | except Exception: 179 | self._log.info(u'error scraping setlist.fm for {0}'.format( 180 | artist_name)) 181 | return 182 | 183 | if not setlist or not setlist['track_names']: 184 | self._log.info(u'could not find a setlist for {0}'.format( 185 | artist_name)) 186 | return 187 | 188 | setlist_name = u'{0} at {1} ({2})'.format( 189 | setlist['artist_name'], 190 | setlist['venue_name'], 191 | setlist['event_date']) 192 | 193 | self._log.info(u'Setlist: {0} ({1} tracks)'.format( 194 | setlist_name, len(setlist['track_names']))) 195 | 196 | # Match the setlist' tracks with items in our library 197 | items, _ = self.find_items_in_lib(lib, 198 | setlist['track_names'], 199 | artist_name) 200 | 201 | # Save the items as a playlist 202 | m3u_path = normpath(os.path.join( 203 | self.config['playlist_dir'].as_filename(), 204 | setlist_name + '.m3u')) 205 | _save_playlist(m3u_path, items) 206 | self._log.info(u'Saved playlist at "{0}"'.format(m3u_path)) 207 | 208 | def find_items_in_lib(self, lib, track_names, artist_name): 209 | """Returns a list of items found, and list of items not found in library 210 | from a given list of track names. 211 | """ 212 | items, missing_items = [], [] 213 | for track_nr, track_name in enumerate(track_names): 214 | item = _find_item_in_lib(lib, track_name, artist_name) 215 | if item: 216 | items += [item] 217 | message = ui.colorize('text_success', u'found') 218 | else: 219 | missing_items += [item] 220 | message = ui.colorize('text_error', u'not found') 221 | self._log.info("{0} {1}: {2}".format( 222 | (track_nr+1), track_name, message)) 223 | return items, missing_items 224 | 225 | def commands(self): 226 | def func(lib, opts, args): 227 | self.setlister(lib, ui.decargs(args), opts.date) 228 | 229 | cmd = ui.Subcommand( 230 | 'setlister', 231 | help='create playlist from an artists\' latest setlist' 232 | ) 233 | cmd.parser.add_option('-d', '--date', dest='date', default=None, 234 | help='setlist of a specific date (dd-MM-yyyy)') 235 | 236 | cmd.func = func 237 | 238 | return [cmd] 239 | --------------------------------------------------------------------------------