Compare commits
11 Commits
abdcd45057
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
aaa70c5428
|
|||
|
45945732c9
|
|||
|
7b2ea2a93f
|
|||
|
e168b6f2df
|
|||
|
2d66b0a200
|
|||
|
ec3ab8b48b
|
|||
|
1a7b909efd
|
|||
|
b136285c00
|
|||
|
b633abdb6e
|
|||
|
ce5957eb70
|
|||
|
b388e8f796
|
@@ -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))
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
6
gohookr.sh
Executable file
6
gohookr.sh
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
cd `dirname $0`
|
||||||
|
git pull
|
||||||
|
docker-compose up -d --build
|
||||||
2
populater/src/app.py
Executable file → Normal file
2
populater/src/app.py
Executable file → Normal 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
16
readme.md
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}'
|
||||||
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
56
web/src/templates/manage.html
Normal file
56
web/src/templates/manage.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user