[sync] Merge remote-tracking branch 'origin/master' into bulma-1.0

This commit is contained in:
Alain Nussbaumer 2025-02-04 22:08:54 +01:00
commit e9ed220853
21 changed files with 1071 additions and 564 deletions

249
ChangeLog
View File

@ -1,7 +1,21 @@
ChangeLog for OwnTone
---------------------
# Changelog
## Version 28.11 - 2025-01-26
- fix: retrieval of artwork from online sources
- fix: mpd speaker selection
- fix: autoconf warnings
- fix: Apple Music/iTunes not working on Airplay host
- web UI: Now Playing does not stop play progress updates when pausing
- web UI: add ability to access the server externally
- new: internet radio "Streamurl" options
- new: support libevent as WS server instead of libwebsockets
- new: template for VSCode
- new: mpd updates, version 0.23.0, e.g. getvol, readpicture, albumart
- new: API for setting skip_count and play_count directly
## Version 28.10 - 2024-09-12
version 28.10
- fix: playlist scanner ignoring lines starting with non-ascii chars
- fix: last seconds of a track sometimes being skipped
- fix: Apple Music password-based auth
@ -21,11 +35,12 @@ version 28.10
- config: deprecate "cache_path", replaced by "cache_dir"
- dependency: libxml2 instead of mxml
version 28.9
## Version 28.9 - 2024-01-18
- web UI improvements:
display lyrics metadata
toggle Spotify on/off in some views
many minor improvements
- display lyrics metadata
- toggle Spotify on/off in some views
- many minor improvements
- use compressed ALAC for Airplay for bandwidth + fixes esp32 issue
- don't merge Spotify albums with local albums
- handle playlist with Unicode BOM
@ -36,7 +51,8 @@ version 28.9
- fix FreeBSD possible crash
- fix crash when keys of incorrect length are used for legacy pairing
version 28.8
## Version 28.8
- fix MacOS bind error: "Protocol wrong type for socket"
- fix BSD build error (no SYS_gettid)
- fix ALAC missing end tag causing ffmpeg decoder warnings
@ -44,15 +60,16 @@ version 28.8
- fix duplicates if file within library is replaced
- fix fatal error due to mutex being zeroed
version 28.7
## Version 28.7
- fix compability with ffmpeg 6
- web UI improvements:
easier volume sliders
incorrect display of genre
Chinese translation
fix removing RSS podcasts
sort by rating for composer, genre and artist tracks
(and much more)
- easier volume sliders
- incorrect display of genre
- Chinese translation
- fix removing RSS podcasts
- sort by rating for composer, genre and artist tracks
- (and much more)
- changes to artwork search orders (easier static artwork for pipes)
- major refactor of the http server improving mp3 streaming
- support for m3u8 playlist files
@ -60,7 +77,8 @@ version 28.7
- fix issue with device name capitalization (TuneAero issue)
- drop support for libevent < 2.1.4
version 28.6
## Version 28.6
- German translation of web UI
- web UI: fix error messages not displaying
- fix low resolution Spotify artwork
@ -69,7 +87,8 @@ version 28.6
- support password authentication for Airplay 2
- support for user config ffmpeg audio filters
version 28.5
## Version 28.5
- French translation of web UI
- improved web UI loading of images
- add support for Airplay 2 password based auth
@ -79,7 +98,8 @@ version 28.5
- fix for Remote - play item from 'up next' when stopped
- use configured bind_address to set mdns network interface
version 28.4
## Version 28.4
- fix broken Spotify after libspotify sunset
- remove antlr3 dependency, use bison/flex instead
- improve search by supporting diacritics and Unicode case folding
@ -88,25 +108,26 @@ version 28.4
- smart playlists fixups and new "this week" param
- fix 'add next' when in queue shuffle mode
- web UI improvements:
migration to Vue 3 and Vite
honor "radio_playlists" config setting
display of search results for composers and playlists
add album / track count to genre and composer pages
fix incorrect sorting of album/artist searches
minor UI fixes
- migration to Vue 3 and Vite
- honor "radio_playlists" config setting
- display of search results for composers and playlists
- add album / track count to genre and composer pages
- fix incorrect sorting of album/artist searches
- minor UI fixes
- fix for Spotify config option album_override
- improved Spotify scan performance
- generic browse endpoints for the json api
- fix slow shutdown with some libwebsocket versions
version 28.3
## Version 28.3
- web UI improvements, e.g.:
composer views
partial scan (e.g. only update RSS feeds)
fix http stream button not clickable in mobile view
fix Spotify playlists not showing
handling of not playable Spotify tracks
handling of podcast play counts
- composer views
- partial scan (e.g. only update RSS feeds)
- fix http stream button not clickable in mobile view
- fix Spotify playlists not showing
- handling of not playable Spotify tracks
- handling of podcast play counts
- support for Spotify podcasts
- updates for ffmpeg 5
- better Spotify logout
@ -117,12 +138,13 @@ version 28.3
- fix rare Airplay pairing error
- many minor error handling fixes
version 28.2
## Version 28.2
- add Spotify integration that doesn't depend on libspotify
- partial support for AirPlay events (Homepod buttons)
- web UI upgraded, now 1.1.0:
show "comment" field in track details
drop double login to Spotify when not using libspotify
- show "comment" field in track details
- drop double login to Spotify when not using libspotify
- easier install by letting 'make install' add user and service files
- preserve existing conf file when running 'make install'
- support for "comment" field when making smart playlists
@ -133,10 +155,12 @@ version 28.2
- fix for CVE-2021-38383
- fix some minor time-of-check time-of-use bugs
version 28.1
## Version 28.1
- fix incompability in 28.0 with Debian Buster's libwebsockets 2.0
version 28.0
## Version 28.0
- rename forked-daapd to OwnTone + new logo
- fix web UI slow updates due to websockets 3.x changes
- support for ALAC sort tags
@ -148,22 +172,24 @@ version 28.0
- refactor how the server binds to sockets (use dual stack ipv4/6)
- configurable interface/address binding
version 27.4
## Version 27.4
- fix web server path traversal vulnerability
version 27.3
## Version 27.3
- support for AirPlay 2 speakers, incl. compressed ALAC
- web UI upgraded, now v0.8.5:
new design/layout
optimize "Recently added"
Spotify search dialogue improvements
drop separate admin web page, now integrated in main web
podcast deletion
make Radio a top level item
show release dates
new sorting options
prevent browser caching of playlists
additional settings
- new design/layout
- optimize "Recently added"
- Spotify search dialogue improvements
- drop separate admin web page, now integrated in main web
- podcast deletion
- make Radio a top level item
- show release dates
- new sorting options
- prevent browser caching of playlists
- additional settings
- improved Chromecast streaming (retransmisson, adaptive etc.)
- JSON api support for updating metadata of queue items
- JSON api new fields, e.g. time_added, time_played and seek
@ -180,22 +206,23 @@ version 27.3
- support shairport-sync metadata pipe flush event
- misc logging fixup
version 27.2
## Version 27.2
- web UI upgraded to v0.7.2:
show cover artwork in album pages and lazy loading of artwork
show playlist folders
use sass/scss for css files
add "Radio" tab to the music section
add settings for artwork sources
add pop up dialog for Remote pairing requests
support adding/removing podcast subscriptions
support marking all new podcast episodes/all episodes as played
support searching by smart pl queries
skip buttons for audiobooks and podcasts
show localized times/dates
generate colored placeholder image if cover artwork is missing
show "cast" icon for Chromecast outputs
styling changes of the navbars and moving the volume controls
- show cover artwork in album pages and lazy loading of artwork
- show playlist folders
- use sass/scss for css files
- add "Radio" tab to the music section
- add settings for artwork sources
- add pop up dialog for Remote pairing requests
- support adding/removing podcast subscriptions
- support marking all new podcast episodes/all episodes as played
- support searching by smart pl queries
- skip buttons for audiobooks and podcasts
- show localized times/dates
- generate colored placeholder image if cover artwork is missing
- show "cast" icon for Chromecast outputs
- styling changes of the navbars and moving the volume controls
- new speaker selection logic (persist user choice even after failure)
- speaker autoselect no longer enabled by default
- removed old admin page, not necessary any more
@ -216,7 +243,8 @@ version 27.2
- drop libspotify for artwork, doesn't work any more
- documentation improvements
version 27.1
## Version 27.1
- web UI upgraded to v0.6.0: settings page, display more Spotify data
- support for volumeup, volumedown and mutetoggle DACP commands
- support for multiple ALSA devices
@ -226,7 +254,8 @@ version 27.1
- fix for incorrect update of time_added metadata
- fix some small memleaks and missing cleanup
version 27.0
## Version 27.0
- no fixed resampling to 44100/16, play source quality if possible
- Chromecast: quick start, better quality (48000/16 Opus encoded)
- performance enhancements: Remote and iTunes will load quicker
@ -245,7 +274,8 @@ version 27.0
- support for some http seeking
- fix for macOS Catalinas Apple Music
version 26.5
## Version 26.5
- json api/web ui: file view
- web ui: artwork support
- web ui: "Add next" and genre tab
@ -260,14 +290,16 @@ version 26.5
- mpd version 0.20 support + support for "listfiles" command
- fix double http auth decline issue
version 26.4:
## Version 26.4
- automatic rating
- fix issue in 26.3 causing invalid time_skipped values in the db
- improved fallback to ipv4 if ipv6 fails
- fix issue returning too many queue items to clients
- fix missing prompt for library password
version 26.3:
## Version 26.3
- fix AirPlay 2 devices (e.g. Sonos Beam and Airport Express)
- fix mdns problems with ATV4 and ipv6
- fix possible segfault if null user-agent
@ -276,10 +308,12 @@ version 26.3:
- fix for crashes when client provides no User-Agent
- logging improvements
version 26.2:
## Version 26.2
- fix for db indexes not being used on fresh installs
version 26.1:
## Version 26.1
- player web interface
- support for Airplay speaker control commands
- add non-library items (e.g. radio stations) to the queue
@ -290,7 +324,8 @@ version 26.1:
- fix ffmpeg segfault when jpeg encoding
- performance improvements + misc
version 26.0:
## Version 26.0
- added web interface
- added JSON API
- new mpd commands (e.g. sticker, urlhandlers, playlistfind)
@ -309,7 +344,8 @@ version 26.0:
- ffmpeg/transcoding refactored for new ffmpeg API
- and more...
version 25.0:
## Version 25.0
- improved playback resilience
- substitute packet skipping (producing audio "clicks") with start/stop
- support for MacOSX with macports and Bonjour mDNS
@ -330,7 +366,8 @@ version 25.0:
- performance improvements
- and other fixing up...
version 24.2:
## Version 24.2
- Pulseaudio support (can be used for Bluetooth speakers)
- new pipe/"fifo" audio output
- fix misc Chromecast audio bugs
@ -345,11 +382,13 @@ version 24.2:
- fix possible segfault on http timeouts
- fix possible segfault when adding items during playback
version 24.1:
## Version 24.1
- support for Monkey's audio
- fix build problems on some platforms (e.g. OpenWrt)
version 24.0:
## Version 24.0
- support for Chromecast audio
- support more idv3 tags (eg. date released)
- support more DAAP tags (eg. datereleased, hasbeenplayed)
@ -369,7 +408,8 @@ version 24.0:
- support for old ffmpeg dropped
- misc minor bugfixing
version 23.4:
## Version 23.4
- fix freeze problem on network stream disconnects
- support for mp3 streaming
- better ipv6 handling
@ -379,7 +419,8 @@ version 23.4:
- libavresample/libswresample dependency changed to libavfilter
- improved pairinghelper.sh script
version 23.3:
## Version 23.3
- fix issue where volume gets set to -1 on startup of raop devices
- plug various minor memleaks
- audiobook improvements, eg resuming playback from saved position
@ -389,29 +430,35 @@ version 23.3:
- drop legacy ffmpeg stuff
- drop legacy flac, musepack and wma scanner
version 23.2:
## Version 23.2
- fix db lock, m3u and Windows Phone bugs
- improvements for Spotify and mpd
- fixing bugs as always
- sorting of genres and composers
version 23.1:
## Version 23.1
- support for more mpd commands
version 23.0:
## Version 23.0
- add support for the mpd protocol
- add support for smart playlists
- playlist and internet stream overhaul
version 22.2:
## Version 22.2
- fix for iTunes 12.1
- fix misc bugs
version 22.1:
## Version 22.1
- artwork cache
- some Spotify fixing up
version 22.0:
## Version 22.0
- queue handling improvements
- added DAAP cache, good for low-power devices like the RPi
- support for LastFM scrobbling
@ -424,7 +471,8 @@ version 22.0:
- fix segfault on invalid utf8 while sorting
- fix misc bugs
version 21.0:
## Version 21.0
- filescanner performance enhancements (db transactions)
- support for queue editing
- support for showing history
@ -443,7 +491,8 @@ version 21.0:
- fix bug in m3u scanner
- ICY metadata fixes
version 20.0:
## Version 20.0
- includes patch against timeouts
- configurable artwork file names
- support for Remote 3 and 4
@ -479,29 +528,34 @@ version 20.0:
- fixes for video playback
- other fixes: non apple players, ffmpeg/libav updates...
version 0.19:
## Version 0.19
- more libav 0.7 updates.
- database speedups.
- fix for iTunes 30-minute timeout.
- fixes, big and small.
version 0.18:
## Version 0.18
- add config knob for ALSA mixer channel name.
- do not elevate privileges for reopening the log file; log file
will now be owned by the user forked-daapd runs as.
- fixes, big and small.
version 0.17:
## Version 0.17
- support for libav 0.7
- fixes, big and small.
version 0.16:
## Version 0.16
- fix issue with non-UTF-8 metadata while scanning.
- use proper file size in HTTP streaming code.
- fix DAAP songlist bug with sort tags.
- small code fixes.
version 0.15:
## Version 0.15
- add support for sending metadata to AppleTV during AirTunes streaming.
- support DOS-encoded Remote pairing files.
- rework album_artist_sort handling.
@ -511,7 +565,8 @@ version 0.15:
- artwork can handle and send out both PNG and JPEG.
- fixes, big and small.
version 0.14:
## Version 0.14
- sort headers/tags handling improvements.
- better handling of tags for TV shows.
- better handling of DRM-afflicted files.
@ -519,7 +574,8 @@ version 0.14:
- fix scanning of URL files.
- fixes, big and small.
version 0.13:
## Version 0.13
- add Remote v2 support; Remote v1 is not supported anymore.
- add per-speaker volume support.
- implement RAOP retransmission.
@ -532,7 +588,8 @@ version 0.13:
- FFmpeg 0.6 support.
- fixes, big and small.
version 0.12:
## Version 0.12
- add AirTunes v2 streaming.
- add Remote support.
- add gzipped replies.
@ -540,7 +597,8 @@ version 0.12:
- check for UTF-8 correctness of metadata.
- fixes, big and small.
version 0.11:
## Version 0.11
- support iTunes 9.
- add iTunes XML playlist scanner.
- add support for TV shows.
@ -552,5 +610,6 @@ version 0.11:
- preliminary support for Remote (pairing, browsing).
- fixes, big and small.
version 0.10:
## Version 0.10
- initial release.

View File

@ -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
View File

@ -0,0 +1 @@
--8<-- "ChangeLog"

File diff suppressed because one or more lines are too long

View File

@ -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

View File

@ -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

View File

@ -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 \

View File

@ -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

View File

@ -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

View File

@ -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
}
}

View File

@ -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 }
};

View File

@ -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;
};

View File

@ -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);

View File

@ -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
View 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
View 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__ */

View File

@ -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" };

View File

@ -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

View File

@ -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();

View File

@ -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);
}