Compare commits

...

9 Commits

13 changed files with 172 additions and 40 deletions

1
.gitignore vendored
View File

@@ -3,4 +3,3 @@ todo
env env
__pycache__ __pycache__
redis-data redis-data
docker-compose.yml

View File

@@ -64,7 +64,7 @@ class Db:
self.rdb.set(Key.playlist_count(playlist_id), count) self.rdb.set(Key.playlist_count(playlist_id), count)
self.rdb.set(Key.playlist_counttype(playlist_id), counttype) self.rdb.set(Key.playlist_counttype(playlist_id), counttype)
def user_del_playlist(self, spotify_id, playlist_id): def user_del_playlist(self, spotify_id, playlist_id, delete_on_spotify = True):
self.rdb.srem(Key.user_playlists(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_count(playlist_id))
self.rdb.delete(Key.playlist_counttype(playlist_id)) self.rdb.delete(Key.playlist_counttype(playlist_id))

View File

@@ -18,13 +18,23 @@ class PlaylistUpdater:
def _update_tracks(self): def _update_tracks(self):
self._tracks = [] self._tracks = []
headers = { 'Authorization': f"Bearer {self._auth_token}" }
rnext = f"{self._spotify_api_endpoint}/me/tracks?limit=50" rnext = f"{self._spotify_api_endpoint}/me/tracks?limit=50"
while rnext is not None: while rnext is not None:
r = rq.get(rnext, headers = headers).json() r = self._fetch(rnext);
self._tracks += r['items'] self._tracks += r['items']
rnext = r['next'] rnext = r['next']
def _fetch(self, url):
headers = { 'Authorization': f"Bearer {self._auth_token}" }
while True:
r = rq.get(f"{url}", headers=headers)
if 200 <= r.status_code <= 299:
return r.json()
# slow down if spotify is applying rate limiting
if r.status_code == 429:
time.sleep(30)
# don't try again in a crazy fast loop to avoid rate limits
time.sleep(1)
def update_playlist(self, playlist_id): def update_playlist(self, playlist_id):
pltracks = [] pltracks = []
@@ -34,12 +44,12 @@ class PlaylistUpdater:
headers = { 'Authorization': f"Bearer {self._auth_token}" } headers = { 'Authorization': f"Bearer {self._auth_token}" }
added_at_dict = {} added_at_dict = {}
r = rq.get(f"{self._spotify_api_endpoint}/playlists/{playlist_id}?fields=tracks,snapshot_id", headers = headers).json() r = self._fetch(f"{self._spotify_api_endpoint}/playlists/{playlist_id}?fields=tracks,snapshot_id")
snapshot_id = r['snapshot_id'] snapshot_id = r['snapshot_id']
pltracks = [t['track']['id'] for t in r['tracks']['items']] pltracks = [t['track']['id'] for t in r['tracks']['items']]
rnext = r['tracks']['next'] rnext = r['tracks']['next']
while rnext is not None: while rnext is not None:
r = rq.get(rnext, headers = headers).json() r = self._fetch(rnext)
for track in r['items']: for track in r['items']:
pltracks.append(track['track']['id']) pltracks.append(track['track']['id'])
rnext = r['next'] rnext = r['next']

View File

@@ -2,6 +2,7 @@ version: '3.9'
services: services:
web: web:
hostname: heartbeats-web
build: build:
context: . context: .
dockerfile: ./web/Dockerfile dockerfile: ./web/Dockerfile
@@ -14,12 +15,14 @@ services:
- BASE_URL=http://localhost:8464 - BASE_URL=http://localhost:8464
redis: redis:
hostname: heartbeats-redis
image: redis image: redis
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- './redis-data:/data' - './redis-data:/data'
populater: populater:
hostname: heartbeats-populater
build: build:
context: . context: .
dockerfile: ./populater/Dockerfile dockerfile: ./populater/Dockerfile

2
populater/src/app.py Executable file → Normal file
View File

@@ -16,7 +16,7 @@ CLIENT_SECRET = os.environ.get('CLIENT_SECRET', None)
if CLIENT_SECRET is None: if CLIENT_SECRET is None:
raise ValueError("CLIENT_SECRET cannot be None, set using environment variable") raise ValueError("CLIENT_SECRET cannot be None, set using environment variable")
db = Db('redis', 6379, 0, CLIENT_ID, CLIENT_SECRET) db = Db('heartbeats-redis', 6379, 0, CLIENT_ID, CLIENT_SECRET)
def get_args(): def get_args():

16
readme.md Normal file
View File

@@ -0,0 +1,16 @@
# :musical_note: heartbeats :saxophone:
> Maintain a (potentially) public list of your recently liked songs so people know how cool you are
# Setup
0. Create a spotify developer account and create a new app
0. Create a `.env` file with your spotify secrets:
```
CLIENT_ID=<client_id>
CLIENT_SECRET=<client_secret>
```
0. Run `docker compose up -d` to start

View File

@@ -10,6 +10,7 @@ Jinja2==3.1.2
MarkupSafe==2.1.1 MarkupSafe==2.1.1
redis==4.4.0 redis==4.4.0
requests==2.28.1 requests==2.28.1
requests-futures==1.0.0
six==1.16.0 six==1.16.0
urllib3==1.26.13 urllib3==1.26.13
Werkzeug==2.2.2 Werkzeug==2.2.2

View File

@@ -1,6 +1,7 @@
import os import os
from flask import Flask, render_template, request, make_response, redirect, url_for from flask import Flask, render_template, request, make_response, redirect, url_for
import requests as rq import requests as rq
from requests_futures.sessions import FuturesSession
import urllib.parse import urllib.parse
import base64 import base64
from datetime import date, datetime from datetime import date, datetime
@@ -27,7 +28,7 @@ BASE_URL = os.environ.get('BASE_URL', None)
if BASE_URL is None: if BASE_URL is None:
raise ValueError("BASE_URL cannot be None, set using environtment variable") raise ValueError("BASE_URL cannot be None, set using environtment variable")
db = Db('redis', 6379, 0, CLIENT_ID, CLIENT_SECRET) db = Db('heartbeats-redis', 6379, 0, CLIENT_ID, CLIENT_SECRET)
SPOTIFY_AUTHORIZE_ENDPOINT = os.environ.get('SPOTIFY_AUTHORIZE_ENDPOINT', 'https://accounts.spotify.com/authorize?') 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_TOKEN_ENDPOINT = os.environ.get('SPOTIFY_AUTHORIZE_ENDPOINT', 'https://accounts.spotify.com/api/token')
@@ -51,6 +52,9 @@ def isauth(cookies):
return user_secret == db.get_user_client_secret(user_id) return user_secret == db.get_user_client_secret(user_id)
def create_empty_message():
return { 'type':'', 'text': u'\u00A0'}
@app.route("/", methods=['GET', 'POST']) @app.route("/", methods=['GET', 'POST'])
def index(): def index():
@@ -67,7 +71,7 @@ def index():
'redirect_uri': BASE_URL + url_for('cb'), 'redirect_uri': BASE_URL + url_for('cb'),
'response_type': 'code' 'response_type': 'code'
}), }),
message = { 'type':'', 'text': ''} message = create_empty_message()
)) ))
resp.set_cookie( resp.set_cookie(
'user_secret', 'user_secret',
@@ -81,7 +85,7 @@ def index():
if request.method=='GET': if request.method=='GET':
message = request.args.get('message') message = request.args.get('message')
return render_template("index_authorised.html", message = json.loads(message) if message else "" ) return render_template("index_authorised.html", message = json.loads(message) if message else create_empty_message())
playlist_name = request.form.get('pname') playlist_name = request.form.get('pname')
@@ -180,20 +184,73 @@ def logout():
resp.set_cookie('user_secret', '') resp.set_cookie('user_secret', '')
return resp return resp
@app.route('/delete') @app.route('/manage')
def delete(): def manage():
if not isauth(request.cookies): if not isauth(request.cookies):
return redirect(url_for('index')) return redirect(url_for('index'))
user_id = request.cookies.get('spotify_id') user_id = request.cookies.get('spotify_id')
playlist_id = request.args.get('playlist') playlist_id = request.args.get('playlist_id')
action = request.args.get('action')
def gen_populated_response(message = False):
session = FuturesSession()
futures = []
ids = db.get_user_playlists(user_id)
playlists = []
headers = { 'Authorization': f'Bearer {db.get_user_auth_token(user_id)}' }
for pl_id in ids:
futures.append(session.get(f"{SPOTIFY_API_ENDPOINT}/playlists/{pl_id}?fields=id,name", headers = headers))
for future in futures:
resp = future.result()
if resp.status_code != 200:
# fail silently for now...
# TODO maybe report this error l8r to user, log
print(f"NONZERO STATUS CODE: {resp.status_code=}")
print(f"{resp.content=}")
continue
resp = resp.json()
playlists.append(resp)
if not message:
message = create_empty_message()
if len(ids) == 0:
message['type'] = 'info'
message['text'] = 'no playlists managed by heartbeats'
message_str = request.args.get('message')
if message_str:
message = json.loads(message_str)
return render_template("manage.html", message=message, playlists=playlists)
if not playlist_id: if not playlist_id:
return render_template("delete.html", message={'type':'', 'text': ''}, playlist_ids=db.get_user_playlists(user_id)) return gen_populated_response()
db.user_del_playlist(user_id, playlist_id) if action in [ 'delete', 'unlink' ]:
actioned = 'deleted' if action == 'delete' else 'unlinked'
if action == 'delete':
headers = { 'Authorization': f'Bearer {db.get_user_auth_token(user_id)}' }
resp = rq.delete(
f"{SPOTIFY_API_ENDPOINT}/playlists/{playlist_id}/followers",
headers = headers
)
print(resp.content)
if resp.status_code != 200:
return redirect(url_for('manage', message=json.dumps({
'type': 'error',
'text': f'unable to {action} playlist {playlist_name}'
})))
return render_template("delete.html", message={
'type': 'info', db.user_del_playlist(user_id, playlist_id)
'text': f'successfully deleted playlist {playlist_id=}' playlist_name = request.args.get('playlist_name')
}, playlist_ids=db.get_user_playlists(user_id)) return redirect(url_for('manage', message=json.dumps({
'type': 'info',
'text': f'successfully {actioned} playlist {playlist_name}'
})))
return gen_populated_response(message={
'type': 'error',
'text': f'action not recognised: {action}'
})

View File

@@ -3,12 +3,16 @@
<style> <style>
@import url("https://styles.alv.cx/colors/gruvbox.css"); @import url("https://styles.alv.cx/colors/gruvbox.css");
@import url("https://styles.alv.cx/base.css"); @import url("https://styles.alv.cx/base.css");
@import url("https://styles.alv.cx/modules/darkmode.css");
:root { :root {
--light: var(--colorscheme-light); --colorscheme-dark: #000;
--fg: var(--colorscheme-light);
--fg-lc: var(--colorscheme-light-darker);
--bg: var(--colorscheme-dark);
--bg-lc: var(--colorscheme-dark-lighter);
} }
.messagetypeinfo { background-color: var(--blue); } .messagetypeinfo { background-color: var(--blue); }
.messagetypeerror { background-color: var(--red); } .messagetypeerror { background-color: var(--red); }
.messagetypewarning { background-color: var(--yellow); } .messagetypewarning { background-color: var(--yellow); }
@@ -28,6 +32,7 @@ a, a:visited {
#message { padding: 1em; } #message { padding: 1em; }
</style> </style>
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
{% block extrahead %}{% endblock %}
</head> </head>
<body> <body>
<p class="messagetype{{message['type']}}"id="message">{{message['text']}}</p> <p class="messagetype{{message['type']}}"id="message">{{message['text']}}</p>

View File

@@ -1,15 +0,0 @@
{% extends "base.html" %}
{% block title %}heartbeats | delete a playlist{% endblock %}
{% block body %}
<h1> delete a playlist </h1>
<ul>
{% for pid in playlist_ids %}
<li><a href="?playlist={{pid}}">{{pid}}</a></li>
{% endfor %}
</ul>
<a href="..">home</a>
{% endblock %}

View File

@@ -2,7 +2,7 @@
{% block title %}heartbeats for spotify{% endblock %} {% block title %}heartbeats for spotify{% endblock %}
{% block body %} {% block body %}
<body> <body>
<h1> <span style="color: #1db954">heart</span>beats for spotify </h1> <h1> <span style="color: #1db954">heart</span>beats<sub style="font-size:0.5em">beta</sub> for spotify </h1>
<h2> create a playlist </h2> <h2> create a playlist </h2>
<form action="./" method="post"> <form action="./" method="post">
@@ -12,7 +12,7 @@
</select></label></label> <br><br> </select></label></label> <br><br>
<label for="pname">name: </label> <label for="pname">name: </label>
<input type="text" id="pname" name="pname" placeholder="playlist_name"><br><br>number of tracks/days <input type="text" id="pname" name="pname" placeholder="playlist_name"><br><br>number of tracks/days
<input type="number" id="count" name="count" value="25" style="width: 5em;"> <input type="number" id="count" name="count" value="28" style="width: 5em;">
<select id="counttype" name="counttype"> <select id="counttype" name="counttype">
<option value="days">days</option> <option value="days">days</option>
<option value="tracks">tracks</option> <option value="tracks">tracks</option>
@@ -22,5 +22,5 @@
<br> <br>
</form> </form>
<p><a href="./delete">delete a playlist</a>, <a href="./logout">logout</a></p> <p><a href="./manage">manage playlists</a>, <a href="./logout">logout</a></p>
{% endblock %} {% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}heartbeats for spotify{% endblock %} {% block title %}heartbeats for spotify{% endblock %}
{% block body %} {% block body %}
<h1> heartbeats for spotify </h1> <h1> <span style="color: #1db954">heart</span>beats<sub style="font-size:0.5em">beta</sub> for spotify </h1>
<h2> <a href="{{loginurl}}"> login with spotify </a> </h2> <h2> <a href="{{loginurl}}"> login with spotify </a> </h2>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}heartbeats | manage playlists{% endblock %}
{% block extrahead %}
<style>
.playlists * {
padding: 0.5em;
}
.playlist {
display: flex;
justify-content: space-between;
align-content: last baseline;
}
.playlist .name {
width: 50%;
}
.playlist .actions a { padding: 0; }
.playlist .actions {
display: flex;
justify-content: space-between;
flex-grow: 1;
}
.playlist:nth-child(even) {
background-color: var(--bg-lc);
}
.playlists .actions a { padding: 0; }
span {
height: fit-content;
}
</style>
{% endblock %}
{% block body %}
<h1><span style="color: #1db954">manage</span> playlists</h1>
<div class="playlists">
{% for playlist in playlists %}
<div class="playlist">
<span class="name" title="{{playlist['name']}} ({{playlist['id']}})">{{playlist['name']}}</span>
<div class="actions">
<a href="?playlist_id={{playlist['id']}}&playlist_name={{playlist['name']}}&action=delete">delete</a>
<a href="?playlist_id={{playlist['id']}}&playlist_name={{playlist['name']}}&action=unlink">unlink without deleting</a>
</div>
</div>
{% endfor %}
</div>
<!--a href="..">home</a-->
{% endblock %}