From bb4bae77a85f1c8ee2a3441ad4833d1ddfd3807a Mon Sep 17 00:00:00 2001 From: Alvie Rahman Date: Fri, 16 Dec 2022 15:17:07 +0000 Subject: [PATCH] minimum viable product --- .gitignore | 4 + common/__init__.py | 2 + common/db.py | 117 ++++++++++++++ common/update_playlists.py | 111 +++++++++++++ populater/src/app.py | 53 ++++++ populater/src/common | 1 + requirements.txt | 14 ++ web/src/app.py | 188 ++++++++++++++++++++++ web/src/common | 1 + web/src/templates/base.html | 22 +++ web/src/templates/delete.html | 15 ++ web/src/templates/index_authorised.html | 29 ++++ web/src/templates/index_unauthorised.html | 7 + 13 files changed, 564 insertions(+) create mode 100644 .gitignore create mode 100644 common/__init__.py create mode 100644 common/db.py create mode 100644 common/update_playlists.py create mode 100755 populater/src/app.py create mode 120000 populater/src/common create mode 100644 requirements.txt create mode 100644 web/src/app.py create mode 120000 web/src/common create mode 100644 web/src/templates/base.html create mode 100644 web/src/templates/delete.html create mode 100644 web/src/templates/index_authorised.html create mode 100644 web/src/templates/index_unauthorised.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4d3d6d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +todo +env +__pycache__ diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..3f0d7f9 --- /dev/null +++ b/common/__init__.py @@ -0,0 +1,2 @@ +from .db import Db +from .update_playlists import PlaylistUpdater diff --git a/common/db.py b/common/db.py new file mode 100644 index 0000000..e9dc7dd --- /dev/null +++ b/common/db.py @@ -0,0 +1,117 @@ +import os + +import redis +import uuid +import requests as rq +import base64 + +class Key: + @staticmethod + def users(): + return f"users" + @staticmethod + def spotify_token(spotify_id): + return f"user:{spotify_id}:token" + + @staticmethod + def spotify_token_refresh(spotify_id): + return f"user:{spotify_id}:token_refresh" + + @staticmethod + def client_secret(spotify_id): + return f"user:{spotify_id}:client_secret" + + @staticmethod + def email(spotify_id): + return f"user:{spotify_id}:email" + + @staticmethod + def user_playlists(spotify_id): + return f"user:{spotify_id}:playlists" + + @staticmethod + def playlist_count(playlist_id): + return f"playlist:{playlist_id}:count" + + @staticmethod + def playlist_counttype(playlist_id): + return f"playlist:{playlist_id}:counttype" + + @staticmethod + def playlist_snapshot(playlist_id): + return f"playlist:{playlist_id}:snapshot" + + +class Db: + def __init__(self, host, port, db, client_id, client_secret): + self.rdb = redis.Redis(host=host, port=port, db=db, decode_responses=True) + self.client_id = client_id + self.client_secret = client_secret + + def get_all_users(self): + return self.rdb.smembers(Key.users()) + + # user secret is forgotten after a year by default, requiring them to log in again + def add_user(self, client_secret, spotify_token, spotify_id, token_expires, refresh_token, client_secret_expires=31557600): + self.rdb.setex(Key.client_secret(spotify_id), client_secret_expires, client_secret) + + self.rdb.setex(Key.spotify_token(spotify_id), token_expires, spotify_token) + self.rdb.set(Key.spotify_token_refresh(spotify_id), refresh_token) + self.rdb.sadd(Key.users(), spotify_id) + + def user_add_playlist(self, spotify_id, playlist_id, count, counttype): + self.rdb.sadd(Key.user_playlists(spotify_id), playlist_id) + self.rdb.set(Key.playlist_count(playlist_id), count) + self.rdb.set(Key.playlist_counttype(playlist_id), counttype) + + def user_del_playlist(self, spotify_id, playlist_id): + self.rdb.srem(Key.user_playlists(spotify_id), playlist_id) + self.rdb.delete(Key.playlist_count(playlist_id)) + self.rdb.delete(Key.playlist_counttype(playlist_id)) + + + def del_user(self, spotify_id): + self.rdb.delete(Key.spotify_token(spotify_id)) + self.rdb.delete(Key.email(spotify_id)) + self.rdb.srem(Key.users(), spotify_id) + + playlists = self.rdb.smembers(Key.user_playlists(spotify_id)) + for pid in playlists: + self.rdb.delete(Key.playlist_count(pid)) + self.rdb.delete(Key.playlist_counttype) + + + def get_user_auth_token(self, spotify_id): + auth_token = self.rdb.get(Key.spotify_token(spotify_id)) + if auth_token: + return auth_token + + refresh_token = self.rdb.get(Key.spotify_token_refresh(spotify_id)) + if not refresh_token: + return None + + print(f"{refresh_token=}") + authstring = base64.b64encode(bytes(f"{self.client_id}:{self.client_secret}", 'utf-8')).decode('utf-8') + res = rq.post('https://accounts.spotify.com/api/token', headers = { 'Authorization': f'Basic {authstring}', 'Content-Type': 'application/x-www-form-urlencoded' }, data = { + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token, + }) + print(res.status_code) + print(res.content) + res = res.json() + self.rdb.setex(Key.spotify_token(spotify_id), res['expires_in'], res['access_token']) + + return res['access_token'] + + + def get_user_client_secret(self, spotify_id): + return self.rdb.get(Key.client_secret(spotify_id)) + + def get_user_playlists(self, spotify_id): + return self.rdb.smembers(Key.user_playlists(spotify_id)) + + def get_user_playlist_count(self, playlist_id): + return int(self.rdb.get(Key.playlist_count(playlist_id))) + + def get_user_playlist_counttype(self, playlist_id): + return self.rdb.get(Key.playlist_counttype(playlist_id)) diff --git a/common/update_playlists.py b/common/update_playlists.py new file mode 100644 index 0000000..b721ad4 --- /dev/null +++ b/common/update_playlists.py @@ -0,0 +1,111 @@ +import requests as rq +import json +from datetime import datetime, timedelta + + +COUNTTYPE_DAYS = 'days' +COUNTTYPE_TRACKS = 'tracks' + + +class PlaylistUpdater: + def __init__(self, db, auth_token, spotify_api_endpoint='https://api.spotify.com/v1'): + self._db = db + self._tracks = [] + self._auth_token = auth_token + self._spotify_api_endpoint = spotify_api_endpoint + + self._update_tracks() + + def _update_tracks(self): + self._tracks = [] + headers = { 'Authorization': f"Bearer {self._auth_token}" } + rnext = f"{self._spotify_api_endpoint}/me/tracks?limit=50" + while rnext is not None: + r = rq.get(rnext, headers = headers).json() + self._tracks += r['items'] + rnext = r['next'] + + + def update_playlist(self, playlist_id): + pltracks = [] + newtracklist = [] + count = self._db.get_user_playlist_count(playlist_id) + counttype = self._db.get_user_playlist_counttype(playlist_id) + headers = { 'Authorization': f"Bearer {self._auth_token}" } + added_at_dict = {} + + r = rq.get(f"{self._spotify_api_endpoint}/playlists/{playlist_id}?fields=tracks,snapshot_id", headers = headers).json() + snapshot_id = r['snapshot_id'] + pltracks = [t['track']['id'] for t in r['tracks']['items']] + rnext = r['tracks']['next'] + while rnext is not None: + r = rq.get(rnext, headers = headers).json() + for track in r['items']: + pltracks.append(track['track']['id']) + rnext = r['next'] + + for i, track in enumerate(self._tracks): + # for some reason, python datetime doesn't support utc zulu timezone + added_at = datetime.fromisoformat(track['added_at'].replace('Z', '+00:00')).timestamp() + added_at_dict[track['track']['id']] = added_at + + if counttype == COUNTTYPE_DAYS: + if added_at < (datetime.now() - timedelta(days=count)).timestamp(): + break + + if counttype == COUNTTYPE_TRACKS and i >= count: + break + + newtracklist.append(track['track']['id']) + + + pltracks = set(pltracks) + newtracklist = set(newtracklist) + + toremove = list(pltracks - newtracklist) + toadd = sorted(list(newtracklist - pltracks), key = lambda t: added_at_dict[t]) + + print(f"{pltracks=}") + print(f"{newtracklist=}") + print(f"{toremove=}") + print(f"{toadd=}") + print(f"{count=}") + print(f"{counttype=}") + print(f"{COUNTTYPE_TRACKS=}") + print(f"{counttype==COUNTTYPE_TRACKS=}") + + while toremove: + tracks = [] + for i in range(100): + if not toremove: + break + tracks.append({ + 'uri': f"spotify:track:{toremove.pop()}" + }) + + r = rq.delete( + f"{self._spotify_api_endpoint}/playlists/{playlist_id}/tracks", + headers = headers, + json = { + 'tracks': tracks, + 'snapshot_id': snapshot_id + } + ).json() + print(f"{json.dumps(r, indent=2)=}") + + while toadd: + tracks = [] + for i in range(100): + if not toadd: + break + tracks.append(f"spotify:track:{toadd.pop()}") + + r = rq.post( + f"{self._spotify_api_endpoint}/playlists/{playlist_id}/tracks", + headers = headers, + json = { + 'uris': tracks, + 'position': 0 + } + ) + print(f"{r.text=}") diff --git a/populater/src/app.py b/populater/src/app.py new file mode 100755 index 0000000..af773dc --- /dev/null +++ b/populater/src/app.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 + +import sys +import time +import os + +from common import Db, PlaylistUpdater + + +INTERVAL = int(os.environ.get('INTERVAL', '0')) or 3600 + +CLIENT_ID = os.environ.get('CLIENT_ID', None) +if CLIENT_ID is None: + raise ValueError("CLIENT_ID cannot be None, set using environment variable") +CLIENT_SECRET = os.environ.get('CLIENT_SECRET', None) +if CLIENT_SECRET is None: + raise ValueError("CLIENT_SECRET cannot be None, set using environment variable") + +db = Db('localhost', 6379, 0, CLIENT_ID, CLIENT_SECRET) + + +def get_args(): + """ Get command line arguments """ + + import argparse + parser = argparse.ArgumentParser() + return parser.parse_args() + + +def main(args): + """ Entry point for script """ + users = db.get_all_users() + print(f"{users=}") + for user_id in users: + playlists = db.get_user_playlists(user_id) + if len(playlists) == 0: + continue + + auth_token = db.get_user_auth_token(user_id) + updater = PlaylistUpdater(db, auth_token) + + for playlist_id in playlists: + updater.update_playlist(playlist_id) + return 0 + +print(f"{__name__}") + +if __name__ == '__main__': + while True: + print("executing...") + main(get_args()) + print(f"done. sleeping for {INTERVAL} seconds...") + time.sleep(INTERVAL) diff --git a/populater/src/common b/populater/src/common new file mode 120000 index 0000000..248927d --- /dev/null +++ b/populater/src/common @@ -0,0 +1 @@ +../../common/ \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e6c28ff --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +async-timeout==4.0.2 +certifi==2022.12.7 +charset-normalizer==2.1.1 +click==8.1.3 +Flask==2.2.2 +idna==3.4 +itsdangerous==2.1.2 +Jinja2==3.1.2 +MarkupSafe==2.1.1 +redis==4.4.0 +requests==2.28.1 +six==1.16.0 +urllib3==1.26.13 +Werkzeug==2.2.2 diff --git a/web/src/app.py b/web/src/app.py new file mode 100644 index 0000000..dcb5079 --- /dev/null +++ b/web/src/app.py @@ -0,0 +1,188 @@ +import os +from flask import Flask, render_template, request, make_response, redirect, url_for +import requests as rq +import urllib.parse +import base64 +from datetime import date, datetime +import time +import json + +from common import Db, PlaylistUpdater +import uuid + +app = Flask(__name__) + +USER_SECRET_EXPIRATION = 31557600 + +URL_PREFIX = os.environ.get('URL_PREFIX', '/') +#app.config.update(APPLICATION_ROOT=URL_PREFIX) +CLIENT_ID = os.environ.get('CLIENT_ID', None) +if CLIENT_ID is None: + raise ValueError("CLIENT_ID cannot be None, set using environment variable") +CLIENT_SECRET = os.environ.get('CLIENT_SECRET', None) +if CLIENT_SECRET is None: + raise ValueError("CLIENT_SECRET cannot be None, set using environment variable") + +db = Db('localhost', 6379, 0, CLIENT_ID, CLIENT_SECRET) + +SPOTIFY_AUTHORIZE_ENDPOINT = os.environ.get('SPOTIFY_AUTHORIZE_ENDPOINT', 'https://accounts.spotify.com/authorize?') +SPOTIFY_TOKEN_ENDPOINT = os.environ.get('SPOTIFY_AUTHORIZE_ENDPOINT', 'https://accounts.spotify.com/api/token') +SPOTIFY_API_ENDPOINT = os.environ.get('SPOTIFY_API_ENDPOINT', 'https://api.spotify.com/v1') + +SCOPES = ' '.join([ + 'playlist-read-private', + 'playlist-read-collaborative', + 'playlist-modify-private', + 'playlist-modify-public', + 'user-read-email', + 'user-library-read', + 'user-library-modify' + ]) + + +def isauth(cookies): + user_id = cookies.get('spotify_id') + user_secret = cookies.get('user_secret') + auth_token = db.get_user_auth_token(user_id) + + return user_secret == db.get_user_client_secret(user_id) + + +@app.route("/", methods=['GET', 'POST']) +def index(): + user_id = request.cookies.get('spotify_id') + user_secret = request.cookies.get('user_secret') + auth_token = db.get_user_auth_token(user_id) + + if (not auth_token) or (user_secret != db.get_user_client_secret(user_id)): + resp = make_response(render_template( + "index_unauthorised.html", + loginurl = SPOTIFY_AUTHORIZE_ENDPOINT + urllib.parse.urlencode({ + 'client_id': CLIENT_ID, + 'scope': SCOPES, + 'redirect_uri': 'http://localhost:5000' + url_for('cb'), + 'response_type': 'code' + }) + )) + resp.set_cookie( + 'user_secret', + str(uuid.uuid4()), + secure = True, + expires = time.time() + USER_SECRET_EXPIRATION + ) + + return resp + + + if request.method=='GET': + message = request.args.get('message') + return render_template("index_authorised.html", message = json.loads(message) if message else "" ) + + + playlist_name = request.form.get('pname') + count = request.form.get('count') + public = request.form.get('public') == 'public' + counttype = request.form.get('counttype') + + create_rq = rq.post(f"{SPOTIFY_API_ENDPOINT}/users/{user_id}/playlists", + headers = { 'Authorization': f"Bearer {auth_token}" }, + json = { + 'name': playlist_name, + 'public': public, + 'collaborative': False, + 'description': f"{count}{counttype}" + }) + + + resp = create_rq.json() + + if create_rq.status_code != 201: + return resp + playlist_id = resp.get('id') + db.user_add_playlist(user_id, playlist_id, count, counttype) + + PlaylistUpdater(db, auth_token).update_playlist(playlist_id) + + + return render_template("index_authorised.html", message={ + 'type': 'info', + 'text': f"created playlist {playlist_name}. it will be populated shortly" + }) + + + +@app.route('/authcb') +def cb(): + user_secret = request.cookies.get('user_secret') + if not user_secret: + return 'client secret is not existing or something: {user_secret=}' + + error = request.args.get('error') + if error: + return 'error' + + spotify_code = request.args.get('code') + authstring = base64.b64encode(bytes(f"{CLIENT_ID}:{CLIENT_SECRET}", 'utf-8')).decode('utf-8') + data = { + 'code': spotify_code, + 'grant_type': 'authorization_code', + 'redirect_uri': 'http://localhost:5000' + url_for('cb'), + } + headers = { + 'Authorization': f'Basic {authstring}', + 'Content-Type': 'application/x-www-form-urlencoded' + } + + token_rq = rq.post(f"{SPOTIFY_TOKEN_ENDPOINT}", data = data, headers = headers) + if token_rq.status_code != rq.codes.ok: + resp = make_response(json.dumps({ + 'status_code': token_rq.status_code, + 'resp:': token_rq.json(), + 'data': data + })) + resp.headers.set('Content-Type', 'application/json') + return resp + + resp = token_rq.json() + spotify_token = resp['access_token'] + token_expires = resp['expires_in'] + refresh_token = resp['refresh_token'] + + me_rq = rq.get(f"{SPOTIFY_API_ENDPOINT}/me", headers = { 'Authorization': f"Bearer {spotify_token}" }) + resp = me_rq.json() + if me_rq.status_code != 200: + return resp + spotify_id = resp['id'] + + db.add_user(user_secret, spotify_token, spotify_id, token_expires, refresh_token, client_secret_expires=USER_SECRET_EXPIRATION) + + resp = make_response(redirect(url_for('index', message=json.dumps({ + 'type': 'info', + 'text': "successfully logged in with spotify" + })))) + resp.set_cookie('spotify_id', spotify_id) + return resp + +@app.route('/logout') +def logout(): + resp = make_response(redirect(url_for('index'))) + resp.set_cookie('user_secret', '') + return resp + +@app.route('/delete') +def delete(): + if not isauth(request.cookies): + return redirect(url_for('index')) + + user_id = request.cookies.get('spotify_id') + playlist_id = request.args.get('playlist') + + if not playlist_id: + return render_template("delete.html", message={'type':'', 'text': ''}, playlist_ids=db.get_user_playlists(user_id)) + + db.user_del_playlist(user_id, playlist_id) + + return render_template("delete.html", message={ + 'type': 'info', + 'text': f'successfully deleted playlist {playlist_id=}' + }, playlist_ids=db.get_user_playlists(user_id)) diff --git a/web/src/common b/web/src/common new file mode 120000 index 0000000..248927d --- /dev/null +++ b/web/src/common @@ -0,0 +1 @@ +../../common/ \ No newline at end of file diff --git a/web/src/templates/base.html b/web/src/templates/base.html new file mode 100644 index 0000000..360c6c7 --- /dev/null +++ b/web/src/templates/base.html @@ -0,0 +1,22 @@ + + + +{% block title %}{% endblock %} + + +

{{message['text']}}

+{% block body %} +{% endblock %} +

built with ❤ by alv

+ + diff --git a/web/src/templates/delete.html b/web/src/templates/delete.html new file mode 100644 index 0000000..3800ceb --- /dev/null +++ b/web/src/templates/delete.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}rolling_liked | delete a playlist{% endblock %} + +{% block body %} +

delete a playlist

+ + +home + +{% endblock %} diff --git a/web/src/templates/index_authorised.html b/web/src/templates/index_authorised.html new file mode 100644 index 0000000..6d6754c --- /dev/null +++ b/web/src/templates/index_authorised.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}rolling_liked{% endblock %} +{% block body %} +

rolling_liked

+ +
+ create a + playlist called

+

+ which always has the most recent

+ +

+ from my spotify liked songs playlist +

+ +
+
+
+
+ delete a playlist + logout +
+{% endblock %} diff --git a/web/src/templates/index_unauthorised.html b/web/src/templates/index_unauthorised.html new file mode 100644 index 0000000..862f919 --- /dev/null +++ b/web/src/templates/index_unauthorised.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block title %}rolling_liked{% endblock %} +{% block body %} +

rolling_liked

+ +

login with spotify

+{% endblock %}