mirror of
https://github.com/owntone/owntone-server.git
synced 2025-03-03 23:30:09 -05:00
[sync] Merge remote-tracking branch 'origin/master' into bulma-1.0
This commit is contained in:
commit
e9ed220853
@ -1,7 +1,7 @@
|
||||
dnl Process this file with autoconf to produce a configure script.
|
||||
|
||||
AC_PREREQ([2.60])
|
||||
AC_INIT([owntone], [28.10])
|
||||
AC_INIT([owntone], [28.11])
|
||||
|
||||
AC_CONFIG_SRCDIR([config.h.in])
|
||||
AC_CONFIG_MACRO_DIR([m4])
|
||||
|
1
docs/changelog.md
Normal file
1
docs/changelog.md
Normal file
@ -0,0 +1 @@
|
||||
--8<-- "ChangeLog"
|
@ -31,21 +31,21 @@ OwnTone is written in C with a web interface written in Vue.js.
|
||||
## Features
|
||||
|
||||
- Stream to :material-cast-variant: AirPlay (synchronized multiroom) and :material-cast:
|
||||
Chromecast devices
|
||||
Chromecast devices
|
||||
- :material-music-box-multiple-outline: Share local library with iTunes and Roku
|
||||
- :material-volume-high: Local audio playback with ALSA or PulseAudio
|
||||
- Supports multiple different clients:
|
||||
|
||||
- :material-cellphone: Remote apps like Apple Remote (iOS) or Retune (Android)
|
||||
- :material-web: Integrated mobile friendly web interface
|
||||
- :material-console: MPD clients
|
||||
- :material-cellphone: Remote apps like Apple Remote (iOS) or Retune (Android)
|
||||
- :material-web: Integrated mobile friendly web interface
|
||||
- :material-console: MPD clients
|
||||
|
||||
- Supports :material-music: music and :material-book-open-variant:
|
||||
audiobook files, :material-microphone: podcast files and :material-rss: RSS
|
||||
and :material-radio: internet radio
|
||||
audiobook files, :material-microphone: podcast files and :material-rss: RSS
|
||||
and :material-radio: internet radio
|
||||
- :material-file-music: Supports audio files in most formats
|
||||
- :material-spotify: Supports playing your Spotify library (requires
|
||||
Spotify premium account)
|
||||
Spotify premium account)
|
||||
- :material-raspberry-pi: Runs on low power devices like the Raspberry Pi
|
||||
|
||||
---
|
||||
|
File diff suppressed because one or more lines are too long
25
mkdocs.yml
25
mkdocs.yml
@ -46,18 +46,27 @@ theme:
|
||||
# - navigation.indexes
|
||||
- navigation.top
|
||||
palette:
|
||||
- scheme: default
|
||||
# Palette toggle for automatic mode
|
||||
- media: "(prefers-color-scheme)"
|
||||
toggle:
|
||||
icon: material/brightness-auto
|
||||
name: Switch to light mode
|
||||
# Palette toggle for light mode
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
primary: white
|
||||
accent: teal
|
||||
toggle:
|
||||
icon: material/toggle-switch
|
||||
icon: material/brightness-7
|
||||
name: Switch to dark mode
|
||||
- scheme: slate
|
||||
primary: blue grey
|
||||
# Palette toggle for dark mode
|
||||
- media: "(prefers-color-scheme: dark)"
|
||||
scheme: slate
|
||||
primary: black
|
||||
accent: teal
|
||||
toggle:
|
||||
icon: material/toggle-switch-off-outline
|
||||
name: Switch to light mode
|
||||
icon: material/brightness-4
|
||||
name: Switch to system preference
|
||||
font:
|
||||
text: Roboto
|
||||
code: Roboto Mono
|
||||
@ -112,6 +121,9 @@ markdown_extensions:
|
||||
repo: mkdocs-material
|
||||
- pymdownx.mark
|
||||
- pymdownx.smartsymbols
|
||||
- pymdownx.snippets:
|
||||
base_path: [!relative $config_dir]
|
||||
check_paths: true
|
||||
- pymdownx.superfences:
|
||||
custom_fences:
|
||||
- name: mermaid
|
||||
@ -159,4 +171,5 @@ nav:
|
||||
- Remote Access: advanced/remote-access.md
|
||||
- Multiple Instances: advanced/multiple-instances.md
|
||||
- Development: development.md
|
||||
- Changelog: changelog.md
|
||||
- JSON API: json-api.md
|
||||
|
@ -20,7 +20,7 @@ Debug domains; available domains are: \fIconfig\fP, \fIdaap\fP,
|
||||
\fIdb\fP, \fIhttpd\fP, \fImain\fP, \fImdns\fP, \fImisc\fP,
|
||||
\fIrsp\fP, \fIscan\fP, \fIxcode\fP, \fIevent\fP, \fIhttp\fP, \fIremote\fP,
|
||||
\fIdacp\fP, \fIffmpeg\fP, \fIartwork\fP, \fIplayer\fP, \fIraop\fP,
|
||||
\fIlaudio\fP, \fIdmap\fP, \fIfdbperf\fP, \fIspotify\fP, \fIlastfm\fP,
|
||||
\fIlaudio\fP, \fIdmap\fP, \fIfdbperf\fP, \fIspotify\fP, \fIscrobble\fP,
|
||||
\fIcache\fP, \fImpd\fP, \fIstream\fP, \fIcast\fP, \fIfifo\fP, \fIlib\fP,
|
||||
\fIweb\fP, \fIairplay\fP.
|
||||
.TP
|
||||
|
@ -126,6 +126,7 @@ owntone_SOURCES = main.c \
|
||||
evthr.c evthr.h \
|
||||
$(SPOTIFY_SRC) \
|
||||
$(LASTFM_SRC) \
|
||||
listenbrainz.c listenbrainz.h \
|
||||
$(MPD_SRC) \
|
||||
listener.c listener.h \
|
||||
commands.c commands.h \
|
||||
|
1
src/db.h
1
src/db.h
@ -72,6 +72,7 @@ enum query_type {
|
||||
#define DB_ADMIN_START_TIME "start_time"
|
||||
#define DB_ADMIN_LASTFM_SESSION_KEY "lastfm_sk"
|
||||
#define DB_ADMIN_SPOTIFY_REFRESH_TOKEN "spotify_refresh_token"
|
||||
#define DB_ADMIN_LISTENBRAINZ_TOKEN "listenbrainz_token"
|
||||
|
||||
/* Max value for media_file_info->rating (valid range is from 0 to 100) */
|
||||
#define DB_FILES_RATING_MAX 100
|
||||
|
@ -21,7 +21,7 @@ struct http_client_ctx
|
||||
*/
|
||||
const char *url;
|
||||
struct keyval *output_headers;
|
||||
char *output_body;
|
||||
const char *output_body;
|
||||
|
||||
/* A keyval/evbuf to store response headers and body.
|
||||
* Can be set to NULL to ignore that part of the response.
|
||||
@ -37,10 +37,6 @@ struct http_client_ctx
|
||||
|
||||
/* HTTP Response code */
|
||||
int response_code;
|
||||
|
||||
/* Private */
|
||||
int ret;
|
||||
void *evbase;
|
||||
};
|
||||
|
||||
struct http_icy_metadata
|
||||
|
@ -54,6 +54,7 @@
|
||||
#ifdef LASTFM
|
||||
# include "lastfm.h"
|
||||
#endif
|
||||
#include "listenbrainz.h"
|
||||
#ifdef HAVE_LIBWEBSOCKETS
|
||||
# include "websocket.h"
|
||||
#endif
|
||||
@ -162,16 +163,17 @@ playcount_inc_cb(void *arg)
|
||||
db_file_inc_playcount(*id);
|
||||
}
|
||||
|
||||
#ifdef LASTFM
|
||||
/* Callback from the worker thread (async operation as it may block) */
|
||||
static void
|
||||
scrobble_cb(void *arg)
|
||||
{
|
||||
int *id = arg;
|
||||
|
||||
#ifdef LASTFM
|
||||
lastfm_scrobble(*id);
|
||||
}
|
||||
#endif
|
||||
listenbrainz_scrobble(*id);
|
||||
}
|
||||
|
||||
static const char *
|
||||
content_type_from_ext(const char *ext)
|
||||
@ -672,9 +674,7 @@ stream_end_register(struct stream_ctx *st)
|
||||
{
|
||||
st->no_register_playback = true;
|
||||
worker_execute(playcount_inc_cb, &st->id, sizeof(int), 0);
|
||||
#ifdef LASTFM
|
||||
worker_execute(scrobble_cb, &st->id, sizeof(int), 1);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -47,6 +47,7 @@
|
||||
# include "lastfm.h"
|
||||
#endif
|
||||
#include "library.h"
|
||||
#include "listenbrainz.h"
|
||||
#include "logger.h"
|
||||
#include "misc.h"
|
||||
#include "misc_json.h"
|
||||
@ -1447,6 +1448,77 @@ jsonapi_reply_lastfm_logout(struct httpd_request *hreq)
|
||||
return HTTP_NOCONTENT;
|
||||
}
|
||||
|
||||
static int
|
||||
jsonapi_reply_listenbrainz(struct httpd_request *hreq)
|
||||
{
|
||||
struct listenbrainz_status status;
|
||||
json_object *jreply;
|
||||
|
||||
listenbrainz_status_get(&status);
|
||||
|
||||
CHECK_NULL(L_WEB, jreply = json_object_new_object());
|
||||
|
||||
json_object_object_add(jreply, "enabled", json_object_new_boolean(!status.disabled));
|
||||
json_object_object_add(jreply, "token_valid", json_object_new_boolean(status.token_valid));
|
||||
if (status.user_name)
|
||||
json_object_object_add(jreply, "user_name", json_object_new_string(status.user_name));
|
||||
if (status.message)
|
||||
json_object_object_add(jreply, "message", json_object_new_string(status.message));
|
||||
|
||||
|
||||
CHECK_ERRNO(L_WEB, evbuffer_add_printf(hreq->out_body, "%s", json_object_to_json_string(jreply)));
|
||||
|
||||
jparse_free(jreply);
|
||||
listenbrainz_status_free(&status, true);
|
||||
|
||||
return HTTP_OK;
|
||||
}
|
||||
|
||||
static int
|
||||
jsonapi_reply_listenbrainz_token_add(struct httpd_request *hreq)
|
||||
{
|
||||
json_object *request;
|
||||
const char *token;
|
||||
int ret;
|
||||
|
||||
request = jparse_obj_from_evbuffer(hreq->in_body);
|
||||
if (!request)
|
||||
{
|
||||
DPRINTF(E_LOG, L_WEB, "Failed to parse incoming request\n");
|
||||
return HTTP_BADREQUEST;
|
||||
}
|
||||
|
||||
token = jparse_str_from_obj(request, "token");
|
||||
|
||||
ret = listenbrainz_token_set(token);
|
||||
|
||||
jparse_free(request);
|
||||
|
||||
if (ret < 0)
|
||||
{
|
||||
DPRINTF(E_LOG, L_WEB, "Failed to set ListenBrainz token\n");
|
||||
return HTTP_INTERNAL;
|
||||
}
|
||||
|
||||
return HTTP_NOCONTENT;
|
||||
}
|
||||
|
||||
static int
|
||||
jsonapi_reply_listenbrainz_token_delete(struct httpd_request *hreq)
|
||||
{
|
||||
int ret;
|
||||
|
||||
ret = listenbrainz_token_delete();
|
||||
|
||||
if (ret < 0)
|
||||
{
|
||||
DPRINTF(E_LOG, L_WEB, "Failed to delete ListenBrainz token\n");
|
||||
return HTTP_INTERNAL;
|
||||
}
|
||||
|
||||
return HTTP_NOCONTENT;
|
||||
}
|
||||
|
||||
/*
|
||||
* Kicks off pairing of a daap/dacp client
|
||||
*
|
||||
@ -4658,6 +4730,10 @@ static struct httpd_uri_map adm_handlers[] =
|
||||
|
||||
{ HTTPD_METHOD_GET, "^/api/search$", jsonapi_reply_search },
|
||||
|
||||
{ HTTPD_METHOD_GET, "^/api/listenbrainz$", jsonapi_reply_listenbrainz },
|
||||
{ HTTPD_METHOD_POST, "^/api/listenbrainz/token$", jsonapi_reply_listenbrainz_token_add },
|
||||
{ HTTPD_METHOD_DELETE, "^/api/listenbrainz/token$", jsonapi_reply_listenbrainz_token_delete },
|
||||
|
||||
{ 0, NULL, NULL }
|
||||
};
|
||||
|
||||
|
@ -26,9 +26,9 @@ struct sp_credentials
|
||||
char username[64];
|
||||
char password[32];
|
||||
|
||||
uint8_t stored_cred[256]; // Actual size is 146, but leave room for some more
|
||||
uint8_t stored_cred[512]; // Actual size is 146, but leave room for some more
|
||||
size_t stored_cred_len;
|
||||
uint8_t token[256]; // Actual size is ?
|
||||
uint8_t token[512]; // Actual size is 270 for family accounts
|
||||
size_t token_len;
|
||||
};
|
||||
|
||||
|
@ -142,7 +142,7 @@ session_new(struct sp_session **out, struct sp_cmdargs *cmdargs, event_callback_
|
||||
if (cmdargs->stored_cred)
|
||||
{
|
||||
if (cmdargs->stored_cred_len > sizeof(session->credentials.stored_cred))
|
||||
RETURN_ERROR(SP_ERR_INVALID, "Invalid stored credential");
|
||||
RETURN_ERROR(SP_ERR_INVALID, "Stored credentials too long");
|
||||
|
||||
session->credentials.stored_cred_len = cmdargs->stored_cred_len;
|
||||
memcpy(session->credentials.stored_cred, cmdargs->stored_cred, session->credentials.stored_cred_len);
|
||||
@ -150,7 +150,7 @@ session_new(struct sp_session **out, struct sp_cmdargs *cmdargs, event_callback_
|
||||
else if (cmdargs->token)
|
||||
{
|
||||
if (strlen(cmdargs->token) > sizeof(session->credentials.token))
|
||||
RETURN_ERROR(SP_ERR_INVALID, "Invalid token");
|
||||
RETURN_ERROR(SP_ERR_INVALID, "Token too long");
|
||||
|
||||
session->credentials.token_len = strlen(cmdargs->token);
|
||||
memcpy(session->credentials.token, cmdargs->token, session->credentials.token_len);
|
||||
|
44
src/lastfm.c
44
src/lastfm.c
@ -84,7 +84,7 @@ param_sign(struct keyval *kv)
|
||||
if (gc_err != GPG_ERR_NO_ERROR)
|
||||
{
|
||||
gpg_strerror_r(gc_err, ebuf, sizeof(ebuf));
|
||||
DPRINTF(E_LOG, L_LASTFM, "Could not open MD5: %s\n", ebuf);
|
||||
DPRINTF(E_LOG, L_SCROBBLE, "lastfm: Could not open MD5: %s\n", ebuf);
|
||||
return -1;
|
||||
}
|
||||
|
||||
@ -99,7 +99,7 @@ param_sign(struct keyval *kv)
|
||||
hash_bytes = gcry_md_read(md_hdl, GCRY_MD_MD5);
|
||||
if (!hash_bytes)
|
||||
{
|
||||
DPRINTF(E_LOG, L_LASTFM, "Could not read MD5 hash\n");
|
||||
DPRINTF(E_LOG, L_SCROBBLE, "lastfm: Could not read MD5 hash\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
@ -163,22 +163,22 @@ response_process(struct http_client_ctx *ctx, char **errmsg)
|
||||
body = (char *)evbuffer_pullup(ctx->input_body, -1);
|
||||
if (!body || (strlen(body) == 0))
|
||||
{
|
||||
DPRINTF(E_LOG, L_LASTFM, "Empty response\n");
|
||||
DPRINTF(E_LOG, L_SCROBBLE, "lastfm: Empty response\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
tree = xml_from_string(body);
|
||||
if (!tree)
|
||||
{
|
||||
DPRINTF(E_LOG, L_LASTFM, "Failed to parse LastFM response:\n%s\n", body);
|
||||
DPRINTF(E_LOG, L_SCROBBLE, "lastfm: Failed to parse LastFM response:\n%s\n", body);
|
||||
return -1;
|
||||
}
|
||||
|
||||
error = xml_get_val(tree, "lfm/error");
|
||||
if (error)
|
||||
{
|
||||
DPRINTF(E_LOG, L_LASTFM, "Request to LastFM failed: %s\n", error);
|
||||
DPRINTF(E_DBG, L_LASTFM, "LastFM response:\n%s\n", body);
|
||||
DPRINTF(E_LOG, L_SCROBBLE, "lastfm: Request to LastFM failed: %s\n", error);
|
||||
DPRINTF(E_DBG, L_SCROBBLE, "lastfm: LastFM response:\n%s\n", body);
|
||||
|
||||
if (errmsg)
|
||||
*errmsg = atrim(error);
|
||||
@ -187,12 +187,12 @@ response_process(struct http_client_ctx *ctx, char **errmsg)
|
||||
return -1;
|
||||
}
|
||||
|
||||
DPRINTF(E_SPAM, L_LASTFM, "LastFM response:\n%s\n", body);
|
||||
DPRINTF(E_SPAM, L_SCROBBLE, "lastfm: LastFM response:\n%s\n", body);
|
||||
|
||||
// Was it a scrobble request? Then do nothing. TODO: Check for error messages
|
||||
if (xml_get_node(tree, "lfm/scrobbles/scrobble"))
|
||||
{
|
||||
DPRINTF(E_DBG, L_LASTFM, "Scrobble callback\n");
|
||||
DPRINTF(E_DBG, L_SCROBBLE, "lastfm: Scrobble callback\n");
|
||||
xml_free(tree);
|
||||
return 0;
|
||||
}
|
||||
@ -201,12 +201,12 @@ response_process(struct http_client_ctx *ctx, char **errmsg)
|
||||
sk = atrim(xml_get_val(tree, "lfm/session/key"));
|
||||
if (!sk)
|
||||
{
|
||||
DPRINTF(E_LOG, L_LASTFM, "Session key not found\n");
|
||||
DPRINTF(E_LOG, L_SCROBBLE, "lastfm: Session key not found\n");
|
||||
xml_free(tree);
|
||||
return -1;
|
||||
}
|
||||
|
||||
DPRINTF(E_INFO, L_LASTFM, "Got session key from LastFM: %s\n", sk);
|
||||
DPRINTF(E_INFO, L_SCROBBLE, "lastfm: Got session key from LastFM: %s\n", sk);
|
||||
db_admin_set(DB_ADMIN_LASTFM_SESSION_KEY, sk);
|
||||
|
||||
free(lastfm_session_key);
|
||||
@ -233,25 +233,27 @@ static int
|
||||
request_post(const char *url, struct keyval *kv, char **errmsg)
|
||||
{
|
||||
struct http_client_ctx ctx;
|
||||
char *request_body;
|
||||
int ret;
|
||||
|
||||
// API requires that we MD5 sign sorted param (without "format" param)
|
||||
ret = param_sign(kv);
|
||||
if (ret < 0)
|
||||
{
|
||||
DPRINTF(E_LOG, L_LASTFM, "Aborting request, param_sign failed\n");
|
||||
DPRINTF(E_LOG, L_SCROBBLE, "lastfm: Aborting request, param_sign failed\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
memset(&ctx, 0, sizeof(struct http_client_ctx));
|
||||
|
||||
ctx.output_body = http_form_urlencode(kv);
|
||||
if (!ctx.output_body)
|
||||
request_body = http_form_urlencode(kv);
|
||||
if (!request_body)
|
||||
{
|
||||
DPRINTF(E_LOG, L_LASTFM, "Aborting request, http_form_urlencode failed\n");
|
||||
DPRINTF(E_LOG, L_SCROBBLE, "lastfm: Aborting request, http_form_urlencode failed\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
ctx.output_body = request_body;
|
||||
ctx.url = url;
|
||||
ctx.input_body = evbuffer_new();
|
||||
|
||||
@ -262,7 +264,7 @@ request_post(const char *url, struct keyval *kv, char **errmsg)
|
||||
ret = response_process(&ctx, errmsg);
|
||||
|
||||
out_free_ctx:
|
||||
free(ctx.output_body);
|
||||
free(request_body);
|
||||
evbuffer_free(ctx.input_body);
|
||||
|
||||
return ret;
|
||||
@ -281,7 +283,7 @@ scrobble(int id)
|
||||
mfi = db_file_fetch_byid(id);
|
||||
if (!mfi)
|
||||
{
|
||||
DPRINTF(E_LOG, L_LASTFM, "Scrobble failed, track id %d is unknown\n", id);
|
||||
DPRINTF(E_LOG, L_SCROBBLE, "lastfm: Scrobble failed, track id %d is unknown\n", id);
|
||||
return -1;
|
||||
}
|
||||
|
||||
@ -327,7 +329,7 @@ scrobble(int id)
|
||||
return -1;
|
||||
}
|
||||
|
||||
DPRINTF(E_INFO, L_LASTFM, "Scrobbling '%s' by '%s'\n", keyval_get(kv, "track"), keyval_get(kv, "artist"));
|
||||
DPRINTF(E_INFO, L_SCROBBLE, "lastfm: Scrobbling '%s' by '%s'\n", keyval_get(kv, "track"), keyval_get(kv, "artist"));
|
||||
|
||||
ret = request_post(api_url, kv, NULL);
|
||||
|
||||
@ -367,7 +369,7 @@ lastfm_login_user(const char *user, const char *password, char **errmsg)
|
||||
struct keyval *kv;
|
||||
int ret;
|
||||
|
||||
DPRINTF(E_LOG, L_LASTFM, "LastFM credentials file OK, logging in with username %s\n", user);
|
||||
DPRINTF(E_LOG, L_SCROBBLE, "lastfm: LastFM credentials file OK, logging in with username %s\n", user);
|
||||
|
||||
// Stop active scrobbling session
|
||||
stop_scrobbling();
|
||||
@ -418,12 +420,12 @@ lastfm_logout(void)
|
||||
int
|
||||
lastfm_scrobble(int id)
|
||||
{
|
||||
DPRINTF(E_DBG, L_LASTFM, "Got LastFM scrobble request\n");
|
||||
|
||||
// LastFM is disabled because we already tried looking for a session key, but failed
|
||||
if (lastfm_disabled)
|
||||
return -1;
|
||||
|
||||
DPRINTF(E_DBG, L_SCROBBLE, "lastfm: Got LastFM scrobble request\n");
|
||||
|
||||
return scrobble(id);
|
||||
}
|
||||
|
||||
@ -443,7 +445,7 @@ lastfm_init(void)
|
||||
ret = db_admin_get(&lastfm_session_key, DB_ADMIN_LASTFM_SESSION_KEY);
|
||||
if (ret < 0)
|
||||
{
|
||||
DPRINTF(E_DBG, L_LASTFM, "No valid LastFM session key\n");
|
||||
DPRINTF(E_DBG, L_SCROBBLE, "lastfm: No valid LastFM session key\n");
|
||||
lastfm_disabled = true;
|
||||
}
|
||||
|
||||
|
327
src/listenbrainz.c
Normal file
327
src/listenbrainz.c
Normal file
@ -0,0 +1,327 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Christian Meffert <christian.meffert@googlemail.com>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
*/
|
||||
|
||||
#ifdef HAVE_CONFIG_H
|
||||
#include <config.h>
|
||||
#endif
|
||||
|
||||
#include <event2/event.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#include "conffile.h"
|
||||
#include "db.h"
|
||||
#include "http.h"
|
||||
#include "listenbrainz.h"
|
||||
#include "logger.h"
|
||||
#include "misc_json.h"
|
||||
|
||||
static const char *listenbrainz_submit_listens_url = "https://api.listenbrainz.org/1/submit-listens";
|
||||
static const char *listenbrainz_validate_token_url = "https://api.listenbrainz.org/1/validate-token";
|
||||
static bool listenbrainz_disabled = true;
|
||||
static char *listenbrainz_token = NULL;
|
||||
static time_t listenbrainz_rate_limited_until = 0;
|
||||
|
||||
static int
|
||||
submit_listens(struct media_file_info *mfi)
|
||||
{
|
||||
struct http_client_ctx ctx = { 0 };
|
||||
struct keyval kv_out = { 0 };
|
||||
struct keyval kv_in = { 0 };
|
||||
char auth_token[1024];
|
||||
json_object *request_body;
|
||||
json_object *listens;
|
||||
json_object *listen;
|
||||
json_object *track_metadata;
|
||||
json_object *additional_info;
|
||||
const char *x_rate_limit_reset_in;
|
||||
int32_t rate_limit_seconds = -1;
|
||||
int ret;
|
||||
|
||||
ctx.url = listenbrainz_submit_listens_url;
|
||||
|
||||
// Set request headers
|
||||
ctx.output_headers = &kv_out;
|
||||
snprintf(auth_token, sizeof(auth_token), "Token %s", listenbrainz_token);
|
||||
keyval_add(ctx.output_headers, "Authorization", auth_token);
|
||||
keyval_add(ctx.output_headers, "Content-Type", "application/json");
|
||||
|
||||
// Set request body
|
||||
request_body = json_object_new_object();
|
||||
json_object_object_add(request_body, "listen_type", json_object_new_string("single"));
|
||||
listens = json_object_new_array();
|
||||
json_object_object_add(request_body, "payload", listens);
|
||||
listen = json_object_new_object();
|
||||
json_object_array_add(listens, listen);
|
||||
json_object_object_add(listen, "listened_at", json_object_new_int64((int64_t)time(NULL)));
|
||||
track_metadata = json_object_new_object();
|
||||
json_object_object_add(listen, "track_metadata", track_metadata);
|
||||
json_object_object_add(track_metadata, "artist_name", json_object_new_string(mfi->artist));
|
||||
json_object_object_add(track_metadata, "release_name", json_object_new_string(mfi->album));
|
||||
json_object_object_add(track_metadata, "track_name", json_object_new_string(mfi->title));
|
||||
additional_info = json_object_new_object();
|
||||
json_object_object_add(track_metadata, "additional_info", additional_info);
|
||||
json_object_object_add(additional_info, "media_player", json_object_new_string(PACKAGE_NAME));
|
||||
json_object_object_add(additional_info, "media_player_version", json_object_new_string(PACKAGE_VERSION));
|
||||
json_object_object_add(additional_info, "submission_client", json_object_new_string(PACKAGE_NAME));
|
||||
json_object_object_add(additional_info, "submission_client_version", json_object_new_string(PACKAGE_VERSION));
|
||||
json_object_object_add(additional_info, "duration_ms", json_object_new_int((int32_t)mfi->song_length));
|
||||
ctx.output_body = json_object_to_json_string(request_body);
|
||||
|
||||
// Create input evbuffer for the response body and keyval for response headers
|
||||
ctx.input_headers = &kv_in;
|
||||
|
||||
// Send POST request for submit-listens endpoint
|
||||
ret = http_client_request(&ctx, NULL);
|
||||
|
||||
// Process response
|
||||
if (ret < 0)
|
||||
{
|
||||
DPRINTF(E_LOG, L_SCROBBLE, "lbrainz: Failed to scrobble '%s' by '%s'\n", mfi->title, mfi->artist);
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (ctx.response_code == HTTP_OK)
|
||||
{
|
||||
DPRINTF(E_INFO, L_SCROBBLE, "lbrainz: Scrobbled '%s' by '%s'\n", mfi->title, mfi->artist);
|
||||
listenbrainz_rate_limited_until = 0;
|
||||
}
|
||||
else if (ctx.response_code == 401)
|
||||
{
|
||||
DPRINTF(E_LOG, L_SCROBBLE, "lbrainz: Failed to scrobble '%s' by '%s', unauthorized, disable scrobbling\n", mfi->title,
|
||||
mfi->artist);
|
||||
listenbrainz_disabled = true;
|
||||
}
|
||||
else if (ctx.response_code == 429)
|
||||
{
|
||||
x_rate_limit_reset_in = keyval_get(ctx.input_headers, "X-RateLimit-Reset-In");
|
||||
ret = safe_atoi32(x_rate_limit_reset_in, &rate_limit_seconds);
|
||||
if (ret == 0 && rate_limit_seconds > 0)
|
||||
{
|
||||
listenbrainz_rate_limited_until = time(NULL) + rate_limit_seconds;
|
||||
}
|
||||
DPRINTF(E_INFO, L_SCROBBLE, "lbrainz: Failed to scrobble '%s' by '%s', rate limited for %d seconds\n", mfi->title,
|
||||
mfi->artist, rate_limit_seconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
DPRINTF(E_LOG, L_SCROBBLE, "lbrainz: Failed to scrobble '%s' by '%s', response code: %d\n", mfi->title, mfi->artist,
|
||||
ctx.response_code);
|
||||
}
|
||||
|
||||
out:
|
||||
|
||||
// Clean up
|
||||
jparse_free(request_body);
|
||||
keyval_clear(ctx.output_headers);
|
||||
keyval_clear(ctx.input_headers);
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
static int
|
||||
validate_token(struct listenbrainz_status *status)
|
||||
{
|
||||
struct http_client_ctx ctx = { 0 };
|
||||
struct keyval kv_out = { 0 };
|
||||
char auth_token[1024];
|
||||
char *response_body;
|
||||
json_object *json_response = NULL;
|
||||
int ret = 0;
|
||||
|
||||
if (!listenbrainz_token)
|
||||
return -1;
|
||||
|
||||
ctx.url = listenbrainz_validate_token_url;
|
||||
|
||||
// Set request headers
|
||||
ctx.output_headers = &kv_out;
|
||||
snprintf(auth_token, sizeof(auth_token), "Token %s", listenbrainz_token);
|
||||
keyval_add(ctx.output_headers, "Authorization", auth_token);
|
||||
|
||||
// Create input evbuffer for the response body
|
||||
ctx.input_body = evbuffer_new();
|
||||
|
||||
// Send GET request for validate-token endpoint
|
||||
ret = http_client_request(&ctx, NULL);
|
||||
|
||||
// Parse response
|
||||
// 0-terminate for safety
|
||||
evbuffer_add(ctx.input_body, "", 1);
|
||||
|
||||
response_body = (char *)evbuffer_pullup(ctx.input_body, -1);
|
||||
if (!response_body || (strlen(response_body) == 0))
|
||||
{
|
||||
DPRINTF(E_LOG, L_SCROBBLE, "lbrainz: Request for '%s' failed, response was empty\n", ctx.url);
|
||||
goto out;
|
||||
}
|
||||
|
||||
json_response = json_tokener_parse(response_body);
|
||||
if (!json_response)
|
||||
DPRINTF(E_LOG, L_SCROBBLE, "lbrainz: JSON parser returned an error for '%s'\n", ctx.url);
|
||||
|
||||
status->user_name = safe_strdup(jparse_str_from_obj(json_response, "user_name"));
|
||||
status->token_valid = jparse_bool_from_obj(json_response, "valid");
|
||||
status->message = safe_strdup(jparse_str_from_obj(json_response, "message"));
|
||||
listenbrainz_disabled = !status->token_valid;
|
||||
|
||||
out:
|
||||
|
||||
// Clean up
|
||||
if (ctx.input_body)
|
||||
evbuffer_free(ctx.input_body);
|
||||
keyval_clear(ctx.output_headers);
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* Thread: worker */
|
||||
int
|
||||
listenbrainz_scrobble(int mfi_id)
|
||||
{
|
||||
struct media_file_info *mfi;
|
||||
int ret;
|
||||
|
||||
if (listenbrainz_disabled)
|
||||
return -1;
|
||||
|
||||
if (listenbrainz_rate_limited_until > 0 && time(NULL) < listenbrainz_rate_limited_until)
|
||||
{
|
||||
DPRINTF(E_INFO, L_SCROBBLE, "lbrainz: Rate limited, not scrobbling\n");
|
||||
return -2;
|
||||
}
|
||||
|
||||
mfi = db_file_fetch_byid(mfi_id);
|
||||
if (!mfi)
|
||||
{
|
||||
DPRINTF(E_LOG, L_SCROBBLE, "lbrainz: Scrobble failed, track id %d is unknown\n", mfi_id);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Don't scrobble songs which are shorter than 30 sec
|
||||
if (mfi->song_length < 30000)
|
||||
goto noscrobble;
|
||||
|
||||
// Don't scrobble non-music and radio stations
|
||||
if ((mfi->media_kind != MEDIA_KIND_MUSIC) || (mfi->data_kind == DATA_KIND_HTTP))
|
||||
goto noscrobble;
|
||||
|
||||
// Don't scrobble songs with unknown artist
|
||||
if (strcmp(mfi->artist, CFG_NAME_UNKNOWN_ARTIST) == 0)
|
||||
goto noscrobble;
|
||||
|
||||
ret = submit_listens(mfi);
|
||||
return ret;
|
||||
|
||||
noscrobble:
|
||||
free_mfi(mfi, 0);
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
int
|
||||
listenbrainz_token_set(const char *token)
|
||||
{
|
||||
int ret;
|
||||
|
||||
if (!token)
|
||||
{
|
||||
DPRINTF(E_DBG, L_SCROBBLE, "lbrainz: Failed to update ListenBrainz token, no token provided\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
ret = db_admin_set(DB_ADMIN_LISTENBRAINZ_TOKEN, token);
|
||||
if (ret < 0)
|
||||
{
|
||||
DPRINTF(E_DBG, L_SCROBBLE, "lbrainz: Failed to update ListenBrainz token, DB update failed\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
free(listenbrainz_token);
|
||||
listenbrainz_token = NULL;
|
||||
ret = db_admin_get(&listenbrainz_token, DB_ADMIN_LISTENBRAINZ_TOKEN);
|
||||
if (ret == 0)
|
||||
listenbrainz_disabled = false;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
int
|
||||
listenbrainz_token_delete(void)
|
||||
{
|
||||
int ret;
|
||||
|
||||
ret = db_admin_delete(DB_ADMIN_LISTENBRAINZ_TOKEN);
|
||||
if (ret < 0)
|
||||
{
|
||||
DPRINTF(E_DBG, L_SCROBBLE, "lbrainz: Failed to delete ListenBrainz token, DB delete query failed\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
free(listenbrainz_token);
|
||||
listenbrainz_token = NULL;
|
||||
listenbrainz_disabled = true;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
int
|
||||
listenbrainz_status_get(struct listenbrainz_status *status)
|
||||
{
|
||||
int ret = 0;
|
||||
|
||||
memset(status, 0, sizeof(struct listenbrainz_status));
|
||||
|
||||
if (listenbrainz_disabled)
|
||||
{
|
||||
status->disabled = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
ret = validate_token(status);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
void
|
||||
listenbrainz_status_free(struct listenbrainz_status *status, bool content_only)
|
||||
{
|
||||
free(status->user_name);
|
||||
free(status->message);
|
||||
if (!content_only)
|
||||
free(status);
|
||||
}
|
||||
|
||||
/* Thread: main */
|
||||
int
|
||||
listenbrainz_init(void)
|
||||
{
|
||||
int ret;
|
||||
|
||||
ret = db_admin_get(&listenbrainz_token, DB_ADMIN_LISTENBRAINZ_TOKEN);
|
||||
listenbrainz_disabled = (ret < 0);
|
||||
|
||||
if (listenbrainz_disabled)
|
||||
{
|
||||
DPRINTF(E_DBG, L_SCROBBLE, "lbrainz: No valid ListenBrainz token\n");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
25
src/listenbrainz.h
Normal file
25
src/listenbrainz.h
Normal file
@ -0,0 +1,25 @@
|
||||
|
||||
#ifndef __LISTENBRAINZ_H__
|
||||
#define __LISTENBRAINZ_H__
|
||||
|
||||
struct listenbrainz_status {
|
||||
bool disabled;
|
||||
char *user_name;
|
||||
bool token_valid;
|
||||
char *message;
|
||||
};
|
||||
|
||||
int
|
||||
listenbrainz_scrobble(int mfi_id);
|
||||
int
|
||||
listenbrainz_token_set(const char *token);
|
||||
int
|
||||
listenbrainz_token_delete(void);
|
||||
int
|
||||
listenbrainz_status_get(struct listenbrainz_status *status);
|
||||
void
|
||||
listenbrainz_status_free(struct listenbrainz_status *status, bool content_only);
|
||||
int
|
||||
listenbrainz_init(void);
|
||||
|
||||
#endif /* !__LISTENBRAINZ_H__ */
|
@ -58,7 +58,7 @@ static uint32_t logger_repeat_counter;
|
||||
static uint32_t logger_last_hash;
|
||||
static char *logfilename;
|
||||
static FILE *logfile;
|
||||
static char *labels[] = { "config", "daap", "db", "httpd", "http", "main", "mdns", "misc", "rsp", "scan", "xcode", "event", "remote", "dacp", "ffmpeg", "artwork", "player", "raop", "laudio", "dmap", "dbperf", "spotify", "lastfm", "cache", "mpd", "stream", "cast", "fifo", "lib", "web", "airplay", "rcp" };
|
||||
static char *labels[] = { "config", "daap", "db", "httpd", "http", "main", "mdns", "misc", "rsp", "scan", "xcode", "event", "remote", "dacp", "ffmpeg", "artwork", "player", "raop", "laudio", "dmap", "dbperf", "spotify", "scrobble", "cache", "mpd", "stream", "cast", "fifo", "lib", "web", "airplay", "rcp" };
|
||||
static char *severities[] = { "FATAL", "LOG", "WARN", "INFO", "DEBUG", "SPAM" };
|
||||
|
||||
|
||||
|
@ -28,7 +28,7 @@
|
||||
#define L_DMAP 19
|
||||
#define L_DBPERF 20
|
||||
#define L_SPOTIFY 21
|
||||
#define L_LASTFM 22
|
||||
#define L_SCROBBLE 22
|
||||
#define L_CACHE 23
|
||||
#define L_MPD 24
|
||||
#define L_STREAMING 25
|
||||
|
@ -72,6 +72,7 @@
|
||||
#ifdef LASTFM
|
||||
# include "lastfm.h"
|
||||
#endif
|
||||
#include "listenbrainz.h"
|
||||
|
||||
#define PIDFILE STATEDIR "/run/" PACKAGE ".pid"
|
||||
#define WEB_ROOT DATADIR "/htdocs"
|
||||
@ -833,6 +834,7 @@ main(int argc, char **argv)
|
||||
#ifdef LASTFM
|
||||
lastfm_init();
|
||||
#endif
|
||||
listenbrainz_init();
|
||||
|
||||
/* Start Remote pairing service */
|
||||
ret = remote_pairing_init();
|
||||
|
@ -91,6 +91,7 @@
|
||||
#ifdef LASTFM
|
||||
# include "lastfm.h"
|
||||
#endif
|
||||
#include "listenbrainz.h"
|
||||
|
||||
// The interval between each tick of the playback clock in ms. This means that
|
||||
// we read 10 ms frames from the input and pass to the output, so the clock
|
||||
@ -378,16 +379,17 @@ skipcount_inc_cb(void *arg)
|
||||
db_file_inc_skipcount(*id);
|
||||
}
|
||||
|
||||
#ifdef LASTFM
|
||||
// Callback from the worker thread (async operation as it may block)
|
||||
static void
|
||||
scrobble_cb(void *arg)
|
||||
{
|
||||
int *id = arg;
|
||||
|
||||
#ifdef LASTFM
|
||||
lastfm_scrobble(*id);
|
||||
}
|
||||
#endif
|
||||
listenbrainz_scrobble(*id);
|
||||
}
|
||||
|
||||
// This is just to be able to log the caller in a simple way
|
||||
#define status_update(x, y) status_update_impl((x), (y), __func__)
|
||||
@ -1072,9 +1074,7 @@ event_play_eof()
|
||||
if (id != DB_MEDIA_FILE_NON_PERSISTENT_ID)
|
||||
{
|
||||
worker_execute(playcount_inc_cb, &id, sizeof(int), 5);
|
||||
#ifdef LASTFM
|
||||
worker_execute(scrobble_cb, &id, sizeof(int), 8);
|
||||
#endif
|
||||
history_add(pb_session.playing_now->id, pb_session.playing_now->item_id);
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user