Graphs and tables for your Spotify account.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

400 lines
14 KiB

7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
  1. # imports {{{ #
  2. import requests
  3. import math
  4. import os
  5. import json
  6. from django.db.models import Count, F, Max
  7. from django.db import IntegrityError
  8. from django.http import JsonResponse
  9. from django.core import serializers
  10. from django.utils import timezone
  11. from .models import *
  12. from . import views
  13. from login.models import User
  14. from pprint import pprint
  15. from dateutil.parser import parse
  16. from datetime import datetime
  17. HISTORY_ENDPOINT = 'https://api.spotify.com/v1/me/player/recently-played'
  18. # }}} imports #
  19. # console_logging = True
  20. console_logging = False
  21. artists_genre_processed = 0
  22. features_processed = 0
  23. # update_track_genres {{{ #
  24. def update_track_genres(user_obj):
  25. """Updates user_obj's tracks with the most common genre associated with the
  26. songs' artist(s).
  27. :user_obj: User object who's tracks are being updated.
  28. :returns: None
  29. """
  30. tracks_processed = 0
  31. user_tracks = Track.objects.filter(users__exact=user_obj)
  32. for track in user_tracks:
  33. # just using this variable to save another call to db
  34. track_artists = track.artists.all()
  35. # set genres to first artist's genres then find intersection with others
  36. shared_genres = track_artists.first().genres.all()
  37. for artist in track_artists:
  38. shared_genres = shared_genres.intersection(artist.genres.all())
  39. shared_genres = shared_genres.order_by('-num_songs')
  40. undefined_genre_obj = Genre.objects.get(name="undefined")
  41. most_common_genre = shared_genres.first() if shared_genres.first() is \
  42. not undefined_genre_obj else shared_genres[1]
  43. track.genre = most_common_genre if most_common_genre is not None \
  44. else undefined_genre_obj
  45. track.save()
  46. tracks_processed += 1
  47. if console_logging:
  48. print("Added '{}' as genre for song #{} - '{}'".format(
  49. track.genre,
  50. tracks_processed,
  51. track.name,
  52. ))
  53. # }}} update_track_genres #
  54. # save_track_obj {{{ #
  55. def save_track_obj(track_dict, artists, user_obj):
  56. """Make an entry in the database for this track if it doesn't exist already.
  57. :track_dict: dictionary from the API call containing track information.
  58. :artists: artists of the song, passed in as a list of Artist objects.
  59. :user_obj: User object for which this Track is to be associated with.
  60. :returns: (The created/retrieved Track object, created)
  61. """
  62. track_query = Track.objects.filter(id__exact=track_dict['id'])
  63. if len(track_query) != 0:
  64. return track_query[0], False
  65. else:
  66. # check if track is simple or full, simple Track object won't have year
  67. # if 'album' in track_dict:
  68. if 'release_date' in track_dict['album']:
  69. # try:
  70. new_track = Track.objects.create(
  71. id=track_dict['id'],
  72. year=track_dict['album']['release_date'].split('-')[0],
  73. popularity=int(track_dict['popularity']),
  74. runtime=int(float(track_dict['duration_ms']) / 1000),
  75. name=track_dict['name'],
  76. )
  77. else:
  78. # except (IntegrityError, KeyError) as e:
  79. new_track = Track.objects.create(
  80. id=track_dict['id'],
  81. popularity=int(track_dict['popularity']),
  82. runtime=int(float(track_dict['duration_ms']) / 1000),
  83. name=track_dict['name'],
  84. )
  85. # have to add artists and user_obj after saving object since track needs to
  86. # have ID before filling in m2m field
  87. for artist in artists:
  88. new_track.artists.add(artist)
  89. # print(new_track.name, artist.name)
  90. if user_obj != None:
  91. new_track.users.add(user_obj)
  92. new_track.save()
  93. return new_track, True
  94. # }}} save_track_obj #
  95. # get_audio_features {{{ #
  96. def get_audio_features(headers, track_objs):
  97. """Creates and saves a new AudioFeatures objects for the respective
  98. track_objs. track_objs should contain the API limit for a single call
  99. (FEATURES_LIMIT) for maximum efficiency.
  100. :headers: headers containing the API token
  101. :track_objs: Track objects to associate with the new AudioFeatures object
  102. :returns: None
  103. """
  104. track_ids = str.join(",", [track_obj.id for track_obj in track_objs])
  105. params = {'ids': track_ids}
  106. features_response = requests.get("https://api.spotify.com/v1/audio-features",
  107. headers=headers,
  108. params={'ids': track_ids}
  109. ).json()['audio_features']
  110. # pprint.pprint(features_response)
  111. useless_keys = [ "key", "mode", "type", "liveness", "id", "uri",
  112. "track_href", "analysis_url", "time_signature", ]
  113. for i in range(len(track_objs)):
  114. if features_response[i] is not None:
  115. # Data that we don't need
  116. cur_features_obj = AudioFeatures()
  117. cur_features_obj.track = track_objs[i]
  118. for key, val in features_response[i].items():
  119. if key not in useless_keys:
  120. setattr(cur_features_obj, key, val)
  121. cur_features_obj.save()
  122. if console_logging:
  123. global features_processed
  124. features_processed += 1
  125. print("Added features for song #{} - {}".format(
  126. features_processed, track_objs[i].name))
  127. # }}} get_audio_features #
  128. # process_artist_genre {{{ #
  129. def process_artist_genre(genre_name, artist_obj):
  130. """Increase count for correspoding Genre object to genre_name and add that
  131. Genre to artist_obj.
  132. :genre_name: Name of genre.
  133. :artist_obj: Artist object to add Genre object to.
  134. :returns: None
  135. """
  136. genre_obj, created = Genre.objects.get_or_create(name=genre_name,
  137. defaults={'num_songs':1})
  138. if not created:
  139. genre_obj.num_songs = F('num_songs') + 1
  140. genre_obj.save()
  141. artist_obj.genres.add(genre_obj)
  142. artist_obj.save()
  143. # }}} process_artist_genre #
  144. # add_artist_genres {{{ #
  145. def add_artist_genres(headers, artist_objs):
  146. """Adds genres to artist_objs and increases the count the respective Genre
  147. object. artist_objs should contain the API limit for a single call
  148. (ARTIST_LIMIT) for maximum efficiency.
  149. :headers: For making the API call.
  150. :artist_objs: List of Artist objects for which to add/tally up genres for.
  151. :returns: None
  152. """
  153. artist_ids = str.join(",", [artist_obj.id for artist_obj in artist_objs])
  154. params = {'ids': artist_ids}
  155. artists_response = requests.get('https://api.spotify.com/v1/artists/',
  156. headers=headers,
  157. params={'ids': artist_ids},
  158. ).json()['artists']
  159. for i in range(len(artist_objs)):
  160. if len(artists_response[i]['genres']) == 0:
  161. process_artist_genre("undefined", artist_objs[i])
  162. else:
  163. for genre in artists_response[i]['genres']:
  164. process_artist_genre(genre, artist_objs[i])
  165. # print(artist_objs[i].name, genre)
  166. if console_logging:
  167. global artists_genre_processed
  168. artists_genre_processed += 1
  169. print("Added genres for artist #{} - {}".format(
  170. artists_genre_processed, artist_objs[i].name))
  171. # }}} add_artist_genres #
  172. # get_artists_in_genre {{{ #
  173. def get_artists_in_genre(user, genre, max_songs):
  174. """Return count of artists in genre.
  175. :user: User object to return data for.
  176. :genre: genre to count artists for.
  177. :max_songs: max total songs to include to prevent overflow due to having
  178. multiple artists on each track.
  179. :returns: dict of artists in the genre along with the number of songs they
  180. have.
  181. """
  182. genre_obj = Genre.objects.get(name=genre)
  183. artist_counts = (Artist.objects.filter(track__users=user)
  184. .filter(genres=genre_obj)
  185. .annotate(num_songs=Count('track', distinct=True))
  186. .order_by('-num_songs')
  187. )
  188. processed_artist_counts = {}
  189. songs_added = 0
  190. for artist in artist_counts:
  191. # hacky way to not have total count overflow due to there being multiple
  192. # artists on a track
  193. if songs_added + artist.num_songs <= max_songs:
  194. processed_artist_counts[artist.name] = artist.num_songs
  195. songs_added += artist.num_songs
  196. # processed_artist_counts = [{'name': artist.name, 'num_songs': artist.num_songs} for artist in artist_counts]
  197. # processed_artist_counts = {artist.name: artist.num_songs for artist in artist_counts}
  198. # pprint.pprint(processed_artist_counts)
  199. return processed_artist_counts
  200. # }}} get_artists_in_genre #
  201. # save_track_artists {{{ #
  202. def save_track_artists(track_dict, artist_genre_queue, user_headers):
  203. """ Update artist info before creating Track so that Track object can
  204. reference Artist object.
  205. :track_dict: response from Spotify API for track
  206. :returns: list of Artist objects in Track
  207. """
  208. track_artists = []
  209. for artist_dict in track_dict['artists']:
  210. artist_obj, artist_created = Artist.objects.get_or_create(
  211. id=artist_dict['id'],
  212. name=artist_dict['name'],)
  213. # only add/tally up artist genres if new
  214. if artist_created:
  215. artist_genre_queue.append(artist_obj)
  216. if len(artist_genre_queue) == views.ARTIST_LIMIT:
  217. add_artist_genres(user_headers, artist_genre_queue)
  218. artist_genre_queue[:] = []
  219. track_artists.append(artist_obj)
  220. return track_artists
  221. # }}} save_track_artists #
  222. # get_user_header {{{ #
  223. def get_user_header(user_obj):
  224. """Returns the authorization string needed to make an API call.
  225. :user_obj: User to return the auth string for.
  226. :returns: the authorization string used for the header in a Spotify API
  227. call.
  228. """
  229. seconds_elapsed = (timezone.now() -
  230. user_obj.access_obtained_at).total_seconds()
  231. if seconds_elapsed >= user_obj.access_expires_in:
  232. req_body = {
  233. 'grant_type': 'refresh_token',
  234. 'refresh_token': user_obj.refresh_token,
  235. 'client_id': os.environ['SPOTIFY_CLIENT_ID'],
  236. 'client_secret': os.environ['SPOTIFY_CLIENT_SECRET']
  237. }
  238. token_response = requests.post('https://accounts.spotify.com/api/token',
  239. data=req_body).json()
  240. user_obj.access_token = token_response['access_token']
  241. user_obj.access_expires_in = token_response['expires_in']
  242. user_obj.save()
  243. return {'Authorization': "Bearer " + user_obj.access_token}
  244. # }}} get_user_header #
  245. # save_history_obj {{{ #
  246. def save_history_obj (user, timestamp, track):
  247. """Return (get/create) a History object with the specified parameters. Can't
  248. use built-in get_or_create since don't know auto PK.
  249. :user: User object History should be associated with
  250. :timestamp: time at which song was listened to
  251. :track: Track object for song
  252. :returns: History object
  253. """
  254. history_query = History.objects.filter(user__exact=user,
  255. timestamp__exact=timestamp)
  256. if len(history_query) == 0:
  257. history_obj = History.objects.create(user=user, timestamp=timestamp,
  258. track=track)
  259. else:
  260. history_obj = history_query[0]
  261. return history_obj
  262. # }}} save_history_obj #
  263. # get_next_history_row {{{ #
  264. def get_next_history_row(csv_reader, headers, prev_info):
  265. """Return formatted information from next row in history CSV file.
  266. :csv_reader: TODO
  267. :headers:
  268. :prev_info: history_obj_info of last row in case no more rows
  269. :returns: (boolean of if last row, dict with information of next row)
  270. """
  271. try:
  272. row = next(csv_reader)
  273. # if Track.objects.filter(id__exact=row[1]).exists():
  274. history_obj_info = {}
  275. for i in range(len(headers)):
  276. history_obj_info[headers[i]] = row[i]
  277. return False, history_obj_info
  278. except StopIteration:
  279. return True, prev_info
  280. # }}} get_next_history_row #
  281. # parse_history {{{ #
  282. def parse_history(user_secret):
  283. """Scans user's listening history and stores the information in a
  284. database.
  285. :user_secret: secret for User object who's library is being scanned.
  286. :returns: None
  287. """
  288. user_obj = User.objects.get(secret=user_secret)
  289. payload = {'limit': str(views.USER_TRACKS_LIMIT)}
  290. last_time_played = History.objects.filter(user=user_obj).aggregate(Max('timestamp'))['timestamp__max']
  291. if last_time_played is not None:
  292. payload['after'] = last_time_played.isoformat()
  293. artist_genre_queue = []
  294. user_headers = get_user_header(user_obj)
  295. history_response = requests.get(HISTORY_ENDPOINT,
  296. headers=user_headers,
  297. params=payload).json()['items']
  298. # pprint(history_response)
  299. tracks_processed = 0
  300. for track_dict in history_response:
  301. # don't associate history track with User, not necessarily in their
  302. # library
  303. # track_obj, track_created = save_track_obj(track_dict['track'],
  304. # track_artists, None)
  305. track_artists = save_track_artists(track_dict['track'], artist_genre_queue,
  306. user_headers)
  307. track_obj, track_created = save_track_obj(track_dict['track'],
  308. track_artists, None)
  309. history_obj = save_history_obj(user_obj, parse(track_dict['played_at']),
  310. track_obj)
  311. tracks_processed += 1
  312. if console_logging:
  313. print("Added history track #{}: {}".format(
  314. tracks_processed, history_obj,))
  315. if len(artist_genre_queue) > 0:
  316. add_artist_genres(user_headers, artist_genre_queue)
  317. # TODO: update track genres from History relation
  318. # update_track_genres(user_obj)
  319. print("Scanned {} history tracks for user {} at {}.".format(
  320. tracks_processed, user_obj.id, datetime.now()))
  321. # }}} get_history #