1
0
mirror of https://github.com/spl0k/supysonic.git synced 2024-12-22 08:56:17 +00:00

Initial ListenBrainz support

This commit is contained in:
Iván Ávalos 2024-03-18 03:30:56 -06:00
parent 1feaae7637
commit a39f2fb16a
No known key found for this signature in database
GPG Key ID: AC47C0E4F348CE33
12 changed files with 197 additions and 1 deletions

View File

@ -13,6 +13,7 @@ from ..db import Track, Album, Artist, Folder
from ..db import StarredTrack, StarredAlbum, StarredArtist, StarredFolder from ..db import StarredTrack, StarredAlbum, StarredArtist, StarredFolder
from ..db import RatingTrack, RatingFolder from ..db import RatingTrack, RatingFolder
from ..lastfm import LastFm from ..lastfm import LastFm
from ..listenbrainz import ListenBrainz
from . import get_entity, get_entity_id, api_routing from . import get_entity, get_entity_id, api_routing
from .exceptions import AggregateException, GenericError, MissingParameter, NotFound from .exceptions import AggregateException, GenericError, MissingParameter, NotFound
@ -175,10 +176,13 @@ def scrobble():
t = int(t) / 1000 if t else int(time.time()) t = int(t) / 1000 if t else int(time.time())
lfm = LastFm(current_app.config["LASTFM"], request.user) lfm = LastFm(current_app.config["LASTFM"], request.user)
lbz = ListenBrainz(current_app.config["LISTENBRAINZ"], request.user)
if submission in (None, "", True, "true", "True", 1, "1"): if submission in (None, "", True, "true", "True", 1, "1"):
lfm.scrobble(res, t) lfm.scrobble(res, t)
lbz.scrobble(res, t)
else: else:
lfm.now_playing(res) lfm.now_playing(res)
lbz.now_playing(res)
return request.formatter.empty return request.formatter.empty

View File

@ -52,6 +52,7 @@ class DefaultConfig:
"log_rotate": True, "log_rotate": True,
} }
LASTFM = {"api_key": None, "secret": None} LASTFM = {"api_key": None, "secret": None}
LISTENBRAINZ = {"api_url": "https://api.listenbrainz.org"}
TRANSCODING = {} TRANSCODING = {}
MIMETYPES = {} MIMETYPES = {}

View File

@ -31,7 +31,7 @@ from playhouse.db_url import parseresult_to_dict, schemes
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import UUID, uuid4 from uuid import UUID, uuid4
SCHEMA_VERSION = "20230331" SCHEMA_VERSION = "20240318"
def now(): def now():
@ -439,6 +439,11 @@ class User(_Model):
default=True default=True
) # True: ok/unlinked, False: invalid session ) # True: ok/unlinked, False: invalid session
listenbrainz_session = FixedCharField(36, null=True)
listenbrainz_status = BooleanField(
default=True
) # True: ok/unlinked, False: invalid token
last_play = ForeignKeyField(Track, null=True, backref="+") last_play = ForeignKeyField(Track, null=True, backref="+")
last_play_date = DateTimeField(null=True) last_play_date = DateTimeField(null=True)

View File

@ -13,6 +13,7 @@ from functools import wraps
from ..db import ClientPrefs, User from ..db import ClientPrefs, User
from ..lastfm import LastFm from ..lastfm import LastFm
from ..listenbrainz import ListenBrainz
from ..managers.user import UserManager from ..managers.user import UserManager
from . import admin_only, frontend from . import admin_only, frontend
@ -297,6 +298,30 @@ def lastfm_unreg(uid, user):
return redirect(url_for("frontend.user_profile", uid=uid)) return redirect(url_for("frontend.user_profile", uid=uid))
@frontend.route("/user/<uid>/listenbrainz/link")
@me_or_uuid
def listenbrainz_reg(uid, user):
token = request.args.get("token")
if not token:
flash("Missing ListenBrainz auth token")
return redirect(url_for("frontend.user_profile", uid=uid))
lbz = ListenBrainz(current_app.config["LISTENBRAINZ"], user)
status, error = lbz.link_account(token)
flash(error if not status else "Successfully linked ListenBrainz account")
return redirect(url_for("frontend.user_profile", uid=uid))
@frontend.route("/user/<uid>/listenbrainz/unlink")
@me_or_uuid
def listenbrainz_unreg(uid, user):
lbz = ListenBrainz(current_app.config["LISTENBRAINZ"], user)
lbz.unlink_account()
flash("Unlinked ListenBrainz account")
return redirect(url_for("frontend.user_profile", uid=uid))
@frontend.route("/user/login", methods=["GET", "POST"]) @frontend.route("/user/login", methods=["GET", "POST"])
def login(): def login():
return_url = request.args.get("returnUrl") or url_for("frontend.index") return_url = request.args.get("returnUrl") or url_for("frontend.index")

127
supysonic/listenbrainz.py Normal file
View File

@ -0,0 +1,127 @@
# This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API.
#
# Copyright (C) 2013-2022 Alban 'spl0k' Féron
# Copyright (C) 2024 Iván Ávalos
#
# Distributed under terms of the GNU AGPLv3 license.
import hashlib
import logging
import requests
import json
from urllib.parse import urljoin
logger = logging.getLogger(__name__)
class ListenBrainz:
def __init__(self, config, user):
if config["api_url"] is not None:
self.__api_url = config["api_url"]
self.__enabled = True
else:
self.__enabled = False
self.__user = user
def link_account(self, token):
if not self.__enabled:
return False, "No ListenBrainz URL set"
res = self.__api_request(False, "/1/validate-token", token)
if not res:
return False, "Error connecting to ListenBrainz"
else:
if "valid" in res and res["valid"]:
self.__user.listenbrainz_session = token
self.__user.listenbrainz_status = True
self.__user.save()
return True, "OK"
else:
return False, f"Error: {res['message']}"
def unlink_account(self):
self.__user.listenbrainz_session = None
self.__user.listenbrainz_status = True
self.__user.save()
def now_playing(self, track):
if not self.__enabled:
return
self.__api_request(
True,
"/1/submit-listens",
self.__user.listenbrainz_session,
listen_type="playing_now",
payload=[{
"track_metadata": {
"artist_name": track.album.artist.name,
"track_name": track.title,
"release_name": track.album.name,
"additional_info": {
"media_player": "Supysonic",
"duration_ms": track.duration,
},
},
}]
)
def scrobble(self, track, ts):
if not self.__enabled:
return
self.__api_request(
True,
"/1/submit-listens",
self.__user.listenbrainz_session,
listen_type="single",
payload=[{
"listened_at": ts,
"track_metadata": {
"artist_name": track.album.artist.name,
"track_name": track.title,
"release_name": track.album.name,
"additional_info": {
"media_player": "Supysonic",
"duration_ms": track.duration,
},
},
}]
)
def __api_request(self, write, route, token, **kwargs):
if not self.__enabled or not token:
return
headers = {"Content-Type": "application/json"}
headers["Authorization"] = "Token {0}".format(token)
try:
if write:
r = requests.post(
urljoin(self.__api_url, route),
headers=headers,
data=json.dumps(kwargs),
timeout=5)
else:
r = requests.get(
urljoin(self.__api_url, route),
headers=headers,
data=json.dumps(kwargs),
timeout=5)
r.raise_for_status()
except requests.HTTPError as e:
status_code = e.response.status_code
if status_code == 401: # Unauthorized
self.__user.listenbrainz_status = False
self.__user.save()
message = e.response.json().get("error", "")
logger.warning("ListenBrainz error %i: %s", status_code, message)
return None
except requests.exceptions.RequestException as e:
logger.warning("Error while connecting to ListenBrainz: " + str(e))
return None
return r.json()

View File

@ -0,0 +1,2 @@
ALTER TABLE user ADD listenbrainz_session CHAR(36);
ALTER TABLE user ADD listenbrainz_status BOOLEAN NOT NULL DEFAULT TRUE;

View File

@ -0,0 +1,2 @@
ALTER TABLE user ADD COLUMN listenbrainz_session CHAR(36);
ALTER TABLE user ADD COLUMN listenbrainz_status BOOLEAN NOT NULL DEFAULT TRUE;

View File

@ -0,0 +1,2 @@
ALTER TABLE user ADD listenbrainz_session CHAR(36);
ALTER TABLE user ADD listenbrainz_status BOOLEAN NOT NULL DEFAULT TRUE;

View File

@ -57,6 +57,8 @@ CREATE TABLE IF NOT EXISTS user (
salt CHAR(6) NOT NULL, salt CHAR(6) NOT NULL,
admin BOOLEAN NOT NULL, admin BOOLEAN NOT NULL,
jukebox BOOLEAN NOT NULL, jukebox BOOLEAN NOT NULL,
listenbrainz_session CHAR(36),
listenbrainz_status BOOLEAN NOT NULL,
lastfm_session CHAR(32), lastfm_session CHAR(32),
lastfm_status BOOLEAN NOT NULL, lastfm_status BOOLEAN NOT NULL,
last_play_id CHAR(32) REFERENCES track(id), last_play_id CHAR(32) REFERENCES track(id),

View File

@ -57,6 +57,8 @@ CREATE TABLE IF NOT EXISTS "user" (
salt CHAR(6) NOT NULL, salt CHAR(6) NOT NULL,
admin BOOLEAN NOT NULL, admin BOOLEAN NOT NULL,
jukebox BOOLEAN NOT NULL, jukebox BOOLEAN NOT NULL,
listenbrainz_session CHAR(36),
listenbrainz_status BOOLEAN NOT NULL,
lastfm_session CHAR(32), lastfm_session CHAR(32),
lastfm_status BOOLEAN NOT NULL, lastfm_status BOOLEAN NOT NULL,
last_play_id UUID REFERENCES track, last_play_id UUID REFERENCES track,

View File

@ -59,6 +59,8 @@ CREATE TABLE IF NOT EXISTS user (
salt CHAR(6) NOT NULL, salt CHAR(6) NOT NULL,
admin BOOLEAN NOT NULL, admin BOOLEAN NOT NULL,
jukebox BOOLEAN NOT NULL, jukebox BOOLEAN NOT NULL,
listenbrainz_session CHAR(36),
listenbrainz_status BOOLEAN NOT NULL,
lastfm_session CHAR(32), lastfm_session CHAR(32),
lastfm_status BOOLEAN NOT NULL, lastfm_status BOOLEAN NOT NULL,
last_play_id CHAR(36) REFERENCES track, last_play_id CHAR(36) REFERENCES track,

View File

@ -81,6 +81,28 @@
</div> </div>
</form> </form>
</div> </div>
<div class="col-md-6">
<form>
<div class="form_group">
<label class="sr-only" for="listenbrainz">ListenBrainz status</label>
<div class="input-group">
<div class="input-group-addon">ListenBrainz status</div>
{% if user.listenbrainz_session %}
<input class="form-control" type="text" id="listenbrainz" placeholder="{% if user.listenbrainz_status %}Linked{% else %}Invalid token{% endif %}" readonly>
<div class="input-group-btn">
<a class="btn btn-default" href="{{ url_for('frontend.listenbrainz_unreg', uid = user.id) }}">Unlink</a>
</div>
{% else %}
<input class="form-control" type="text" name="token" id="listenbrainz" placeholder="Insert auth token" maxlength="36" />
<div class="input-group-btn">
<button class="btn btn-default" type="submit" formaction="{{ url_for('frontend.listenbrainz_reg', uid = user.id) }}">Link</button>
</div>
{% endif %}
</div>
</div>
</form>
<br>
</div>
</div> </div>
{% if request.user.id == user.id %} {% if request.user.id == user.id %}
<a href="{{ url_for('frontend.change_password_form', uid = 'me') }}" class="btn btn-default">Change password</a></li> <a href="{{ url_for('frontend.change_password_form', uid = 'me') }}" class="btn btn-default">Change password</a></li>