From 8dca25f8612395a5b51bda7ea98169375b2ff1cb Mon Sep 17 00:00:00 2001 From: Mark Hoekveen Date: Wed, 13 Mar 2024 11:01:23 +0100 Subject: [PATCH] Spotify user api integration En een begin aan een playlist maker. Doet op het moment niet veel. --- muzak/settings.py | 1 + playlist/models/__init__.py | 1 + playlist/models/playlist.py | 8 +++ playlist/models/profile.py | 52 +++++++++++++++++++ playlist/models/track.py | 6 +++ playlist/models/vote.py | 3 +- playlist/spotify.py | 59 +++++++++++++++++---- playlist/static/img/spotify-cancel.png | Bin 0 -> 378 bytes playlist/static/img/spotify.png | Bin 0 -> 1524 bytes playlist/static/style.css | 20 ++++++- playlist/templates/PlaylistMaker.html | 22 ++++++++ playlist/templates/Settings.html | 27 ++++++++-- playlist/templates/Vote.html | 26 +++++----- playlist/urls.py | 3 ++ playlist/views.py | 69 ++++++++++++++++++++++--- 15 files changed, 258 insertions(+), 39 deletions(-) create mode 100644 playlist/models/playlist.py create mode 100644 playlist/static/img/spotify-cancel.png create mode 100644 playlist/static/img/spotify.png create mode 100644 playlist/templates/PlaylistMaker.html diff --git a/muzak/settings.py b/muzak/settings.py index 4682432..67f7ac8 100644 --- a/muzak/settings.py +++ b/muzak/settings.py @@ -14,6 +14,7 @@ DEBUG = True ALLOWED_HOSTS = ['*'] +CURRENT_HOST = 'http://127.0.0.1:8000' # Needs to be set for spotify callback to work. INSTALLED_APPS = [ 'playlist.apps.PlaylistConfig', diff --git a/playlist/models/__init__.py b/playlist/models/__init__.py index 2af94c6..7213a1b 100644 --- a/playlist/models/__init__.py +++ b/playlist/models/__init__.py @@ -3,3 +3,4 @@ from .artist import Artist from .album import Album from .vote import Vote from .profile import Profile, Background +from .playlist import Playlist diff --git a/playlist/models/playlist.py b/playlist/models/playlist.py new file mode 100644 index 0000000..ad030ea --- /dev/null +++ b/playlist/models/playlist.py @@ -0,0 +1,8 @@ +from django.db import models +from .track import Track + +class Playlist(models.Model): + def __str__(self): + return self.name + name = models.CharField(max_length=255) + tracks = models.ManyToManyField(Track) \ No newline at end of file diff --git a/playlist/models/profile.py b/playlist/models/profile.py index 192c553..ed701c5 100644 --- a/playlist/models/profile.py +++ b/playlist/models/profile.py @@ -5,6 +5,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone from datetime import datetime, timedelta +from ..spotify import spt NOMINATIONS_PER_DAY = 10 MAX_QUOTA = 22 @@ -25,6 +26,10 @@ class Profile(models.Model): quota = models.FloatField(default=10.0) quota_last_updated = models.DateTimeField(default=timezone.now, null=False) + spt_access_token = models.CharField(max_length=128, default="", null=True) + spt_refresh_token = models.CharField(max_length=128, default="", null=True) + spt_expires = models.DateTimeField(default=None, null=True) + @property def quota_display(self): return int(self.quota) @@ -38,6 +43,53 @@ class Profile(models.Model): print(f"Er zijn {elapsed.seconds}s voorbij, je nieuwe quota is {self.quota}") self.quota_last_updated = timezone.now() + def init_spt(self, code): + """Initial setting of spotify tokens from user accept code""" + data = spt.get_token_json( + grant_type='authorization_code', + code=code + ) + self.save_spt(data) + + def refresh_spt(self): + """Refreshes expired access token using stored refresh token""" + if self.spt_refresh_token: + data = spt.get_token_json( + grant_type='refresh_token', + refresh_token=self.spt_refresh_token + ) + self.save_spt(data) + + def save_spt(self, data): + """Saves tokens from json response""" + access = data.get('access_token', None) + refresh = data.get('refresh_token', self.spt_refresh_token) + expires = data.get('expires_in', 3600) + self.spt_access_token = access + self.spt_refresh_token = refresh + self.spt_expires = timezone.now() + timedelta(seconds=expires - 300) + self.save() + + def unlink_spt(self): + self.spt_access_token = None + self.spt_refresh_token = None + self.spt_expires = None + + @property + def can_spt(self): + return ( + self.spt_access_token and + self.spt_expires and + (timezone.now() < self.spt_expires) + ) + + def get_or_update_spt(self): + if self.spt_refresh_token: + if not self.can_spt: + self.refresh_spt() + return self.spt_access_token + return None + def save(self, *args, **kwargs): self.update_quota() super().save(*args, **kwargs) diff --git a/playlist/models/track.py b/playlist/models/track.py index d12c6d4..7ecbc55 100644 --- a/playlist/models/track.py +++ b/playlist/models/track.py @@ -2,6 +2,7 @@ from django.db import models from random import choice from .album import Album from .artist import Artist +from .vote import Vote import re class TrackManager(models.Manager): @@ -30,6 +31,10 @@ class Track(models.Model): def link(self): return f'https://open.spotify.com/track/{self.id}' + @property + def score(self): + return Vote.objects.filter(track=self.id).aggregate(rating=models.Avg("points"))["rating"] + def is_duplicate_of(self): tracks = Track.objects.exclude(id=self.id).filter(name__startswith=self.name_sanitized) artists = [artist.name for artist in self.artists.all()] @@ -37,6 +42,7 @@ class Track(models.Model): tracks = tracks.filter(artists__name__exact=artist) return tracks.first() + id = models.CharField(primary_key=True, max_length=128) name = models.CharField(max_length=255) artists = models.ManyToManyField(Artist) diff --git a/playlist/models/vote.py b/playlist/models/vote.py index 3586528..d05712c 100644 --- a/playlist/models/vote.py +++ b/playlist/models/vote.py @@ -1,10 +1,9 @@ from django.db import models from django.contrib.auth.models import User -from . import Track class Vote(models.Model): class Meta: unique_together = ["user", "track"] - track = models.ForeignKey(Track, on_delete=models.CASCADE) + track = models.ForeignKey('Track', on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE) points = models.IntegerField() diff --git a/playlist/spotify.py b/playlist/spotify.py index 30f7ba4..ee530be 100644 --- a/playlist/spotify.py +++ b/playlist/spotify.py @@ -3,46 +3,75 @@ import os import re from datetime import datetime, timedelta from dotenv import load_dotenv +from django.urls import reverse +from django.conf import settings load_dotenv() + class Spotify(): def __init__(self): self.token = None self.valid_until = None + def redirect_uri(self): + return settings.CURRENT_HOST + reverse('spotify-callback') + def token_valid(self): if self.token and self.valid_until and (datetime.now() < self.valid_until): return True return False - def get_token(self): - if self.token_valid(): - print("Token still valid") - return self.token - print("New token") + def get_token_json(self, grant_type, code=None, refresh_token=None): url = 'https://accounts.spotify.com/api/token' headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': 'Basic ' + os.getenv('SPOTIFY_ENCODED_ID') } data = { - 'grant_type': 'client_credentials' + 'grant_type': grant_type } + if code: + data['code'] = code + data['redirect_uri'] = self.redirect_uri() + if refresh_token: + data['refresh_token'] = refresh_token r = requests.post(url, headers=headers, data=data) - data = r.json() + return r.json() + + def get_system_token(self): + if self.token_valid(): + return self.token + data = self.get_token_json('client_credentials') self.token = data["access_token"] self.valid_until = datetime.now() + timedelta(seconds=data["expires_in"]-300) return self.token + def get_user_token(self, code): + data = self.get_token_json('authorization_code', code, redirect=True) + print(data) + return data + return (data.get('access_token', None), data.get('refresh_token', None)) + + def get_oauth_redirect(self): + scopes = ['user-read-private', 'user-read-email'] + CLIENT_ID = 'bcc523219d1d4248a7e8892e809a5767' #TODO: delet. + url = f'https://accounts.spotify.com/authorize?' + url += f'client_id={CLIENT_ID}&' + url += f'response_type=code&' + url += f'scope={"%20".join(scopes)}&' + url += f'redirect_uri={self.redirect_uri()}' + print(url) + return url + def unshort(self, url): - """Converts a spotify.link url into a normal spotify ID""" + """Converts a spotify.link url into a normal spotify URL with ID""" r = requests.get(url, allow_redirects=False) r.raise_for_status() return r.headers["Location"] def get_song_info(self, spotify_id): - token = self.get_token() + token = self.get_system_token() url = 'https://api.spotify.com/v1/tracks/' + spotify_id headers = { 'Content-Type': 'application/json', @@ -51,3 +80,15 @@ class Spotify(): r = requests.get(url, headers=headers) r.raise_for_status() return r.json() + + def get_profile(self, access_token): + url = 'https://api.spotify.com/v1/me' + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + access_token + } + r = requests.get(url, headers=headers) + r.raise_for_status() + return r.json() + +spt = Spotify() \ No newline at end of file diff --git a/playlist/static/img/spotify-cancel.png b/playlist/static/img/spotify-cancel.png new file mode 100644 index 0000000000000000000000000000000000000000..827afbd88869c0e6f35fa489ec97fe1e1febb68e GIT binary patch literal 378 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF^mS!_#3sd{qjdUh zg#}P3)YHW=gyVX0LIbm-fj~k+2orN6o1nNrfk>JF)5lQJg*>VP%xlDsFsu$VNk}kb zW8UC-hAlvO<^dj6)vYQURa8~k7Su#Zg$PD7h4@BEwWN77a56Gn&F4#Ft7ypvTA*6u z8c~vxSdwa$T$Bo=7>o=IEp!dcbdAhH3=FJHOstGebPY_b3=Eu``1hh{$jwj5OsmAL U!RY>WMxX`;Pgg&ebxsLQ05bz)3IG5A literal 0 HcmV?d00001 diff --git a/playlist/static/img/spotify.png b/playlist/static/img/spotify.png new file mode 100644 index 0000000000000000000000000000000000000000..01417dfce75fa31286ef5981dfaf79a659bdbc7f GIT binary patch literal 1524 zcmVPx)u1Q2eRA_;{n!9crM-+y?5kfXB4Y`z4#Xy>vvtuAn;9hwKivVrKTjoxM@CwmI76a_9fg?jD+ocS-t;}gm(L~2c9f|7%*EFAOWIwGA4p(UOaaPGQ$aIgybXudUq0< z7{e*cC>l(n|INAA`+y%n0?-%$=0waRGl)dL8#U6LF8d)db0ngHBo4kUc;g-tSrScY zgduih6BMg>W!LReG`b@2Xz*K+=#fUo*%Nu-KO%lM2d&X)j=)~Q8@eLkN%}}!C=2aF z{LtE4!UXmXKBhg55>f}hOyaXj8~xi%par}SGZj0q7Fr}V1&EWJM1tb8O8GZwjzU8U zg%&r?OHs>xJJvv%gb~sb34lLoCQ&VpwkG8ss^S&$Nx?@Le}B#WKWh*TDh1J~EX_~h z2)JQ%JONa?j^hZ+qxSbfo2# zMs%c~G&1AkiA>Mm%1joraFOs)_^x14EhKgF7S7&RJd`D=J5OC<-m!Tz1v0=}7+u`~ z6E>IoiOlBmhQIg&We)5JUW$Y&QYfrG>fIceTIDcvcYRJipj9lj?-TpxGc;gK#4?&gic(?l1iYP0F1v}bN~D$<1g3v zV{^D>bUSe!I4do#KvGb}xr$RH0_B%0>q2g)`Evl6r?f=C+0z6*x`*cD!dG`;cXbhM zz3V;eQAz~reF%JjL_wfobUsZ`i*zu!o$ai`rGo^>Mdqi`hTL5}A$X2p2`rD`%n@lM z;fe%0B2an}<+8{AZD4de;r{tcd;Urw%Mhg*f4R29nG>_}RJzNc2d;p(N#J@(_j?}l zCIr|{=wC|pQk$ZAjjkM^%XNP;GwZBE5Y~Ab$wk%?fxT?tlgjm6GP4Y(mQ-648y(*j z;7cUr0@sP*+rE9sBM~66rTe4XDw!boL)Q;t%AI(7kvY$qR+0E@$iG!&B)0=JFYw(Fxv1pqfHH3gv%Jlm<36 zPi>RgPKUt5{Qx#dD!Zg}>-pig5*;Lny9>NLT~-D(H`|P^Z1}rsNc2tKEIsJFd6v?r zPQa5`xS^y>qN3-{=Au+5QJyyKk?{)nRNZ}6A{&Z8d8FP3-$Fn)dpZERCb0qV71Ud& zAy4O$>48J=ebB-71zXT*131$RUY5hbPpe>?z$?E!2^@6-eMpqj1zeNzpp~{H99r9~ z!r;l8RmX!LznIhtx0^uesCUxBRtvT$T$PkICv7_{n$^aG59NIYUn_d|@<^Rb8^B6n zPg_Fi2DYK#eJ!+kz8`@SkvfSqfXdpM+6(}nOC{al`x9^^z(%8Sh-ve9j~OSV^ZO%E zB2p*O4EkGw-;IDH0X9!NH{i6VOL_bGG(R;65UG*r0G;Q!KOXUekH--xA3gplogaJr a$<6=JV~`6FodHGw0000{{track}}: {{track.score}}

+ {% endfor %} +{% else %} +
+ {% csrf_token %} + Hoe veel nummers wil je? + + +
+{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/playlist/templates/Settings.html b/playlist/templates/Settings.html index 2654aa1..93da805 100644 --- a/playlist/templates/Settings.html +++ b/playlist/templates/Settings.html @@ -13,6 +13,24 @@ Instellingen + {% if sptDisplayName %} +
+ {% csrf_token %} +
+ + {{sptDisplayName}} + + +
+
+ {% else %} +
+
+ + +
+
+ {% endif %} {% endif %}
{% csrf_token %} @@ -32,9 +50,8 @@ Instellingen {% endif %}
-
- - {{user.profile.quota|floatformat:3}} -
- +
+ + {{user.profile.quota|floatformat:3}} +
{% endblock %} diff --git a/playlist/templates/Vote.html b/playlist/templates/Vote.html index 3b2a562..dfceba4 100644 --- a/playlist/templates/Vote.html +++ b/playlist/templates/Vote.html @@ -18,18 +18,6 @@ Stemmen
Hoeveel sterren geef jij dit?
- - - + + +
{% if undo %}
{% csrf_token %} -

Je hebt {{undo.track}} een score van {{undo.points | add:3}} sterren gegeven!

+

Je hebt {{undo.track}} een score van {{undo.points}} sterren gegeven!

{% endif %} diff --git a/playlist/urls.py b/playlist/urls.py index 140d926..0e04732 100644 --- a/playlist/urls.py +++ b/playlist/urls.py @@ -8,6 +8,9 @@ urlpatterns = [ path('vote/', views.VoteView.as_view(), name="vote"), path('vote/undo', views.UndoView.as_view(), name="undo"), path('settings/', views.SettingsView.as_view(), name="settings"), + path('settings/make-playlist', views.PlaylistMake.as_view(), name="playlist"), path('overview/', views.OverView.as_view(), name="overview"), + path('spotify/', views.SpotifyView.as_view(), name="spotify"), + path('spotify/callback', views.SpotifyCallbackView.as_view(), name="spotify-callback"), path('', views.AuthView.as_view()), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/playlist/views.py b/playlist/views.py index a453b24..9836eeb 100644 --- a/playlist/views.py +++ b/playlist/views.py @@ -3,15 +3,15 @@ from django.views import View from django.http import HttpResponse, HttpResponseRedirect from django.template.response import TemplateResponse from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import Avg +from django.urls import reverse import re from collections import OrderedDict from .models import Track, Artist, Album, Vote, Background, Profile from .utils import from_json, get_unvoted -from . import spotify +from .spotify import spt from .tasks import get_banter, get_and_dither_image -spt = spotify.Spotify() - class AuthView(View): def get(self, request): if request.user.is_authenticated: @@ -29,7 +29,7 @@ class VoteView(LoginRequiredMixin, View): try: spotify_id = self.request.POST['spotify_id'] score = int(self.request.POST['vote']) - score = min(max(-2, score), 2) + score = min(max(1, score), 5) track = Track.objects.get(pk=spotify_id) try: Vote.objects.get(user=request.user, track=track) @@ -82,7 +82,8 @@ class NominateView(LoginRequiredMixin, View): except Track.DoesNotExist: try: json = spt.get_song_info(spotify_id) - except: + except Exception as e: + print(e) return TemplateResponse(request, "Nominate.html", {"error": "Fout bij het ophalen van het liedje."}) # Album artists @@ -133,12 +134,22 @@ class SettingsView(LoginRequiredMixin, View): "backgrounds": backgrounds, "currentBackground": (profile.background.id if profile.background else 1) } + spt_token = profile.get_or_update_spt() + if spt_token: + context["sptDisplayName"] = spt.get_profile(spt_token).get('display_name') + return TemplateResponse(request, "Settings.html", context) def post(self, request): try: - bg = Background.objects.get(pk=request.POST['background']) + bg = request.POST.get('background', None) + unlink_spotify = request.POST.get('unlink-spotify', None) (profile,_) = Profile.objects.get_or_create(user=request.user) - profile.background = bg + if bg: + bg = Background.objects.get(pk=request.POST['background']) + profile.background = bg + if unlink_spotify: + print("unlink spt") + profile.unlink_spt() profile.save() except: pass @@ -183,4 +194,46 @@ class OverView(LoginRequiredMixin, View): context = { "artists": artists_ordered } - return TemplateResponse(request, "Overview.html", context) \ No newline at end of file + return TemplateResponse(request, "Overview.html", context) + +class PlaylistMake(LoginRequiredMixin, View): + def get(self, request, count=None): + context = {} + if count: + #tracks = Track.objects.order_by('score')[count] + tracks = Track.objects.annotate(average_points=Avg('vote__points')).order_by('-average_points')[:count] + context["tracks"] = tracks + return TemplateResponse(request, "PlaylistMaker.html", context) + def post(self, request): + try: + count = int(self.request.POST['count']) + return self.get(request, count=count) + except Exception as e: + print(e) + return self.get(request) + +class SpotifyView(LoginRequiredMixin, View): + def get(self, request): + url = spt.get_oauth_redirect() + return HttpResponseRedirect(url) + +class SpotifyCallbackView(LoginRequiredMixin, View): + def get(self, request): + error = self.request.GET.get('error') + code = self.request.GET.get('code') + if error: + return TemplateResponse(request, "Nominate.html", {"error": "Je moet wel accepteren, pannenkoek."}) + elif code: + (profile,_) = Profile.objects.get_or_create(user=request.user) + profile.init_spt(code) + # data = spt.get_user_token(code) + # access = data.get('access_token', None) + # refresh = data.get('refresh_token', None) + # expires = data.get('expires_in', 3600) + + # if access and refresh: + # profile.set_spotify(access, refresh, expires) + # else: + # return TemplateResponse(request, "Nominate.html", {"error": "No."}) + return HttpResponseRedirect(reverse('settings')) + #return TemplateResponse(request, "Nominate.html", {"warning": code})