[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(listenbrainz): Implement MVP LZ endpoint source

FoxxMD (Mar 13, 2025, 4:20 PM UTC) d3cd07c5 59274508

+402 -1
+9
config/endpointlz.json.example
··· 1 + [ 2 + { 3 + "name": "myLz", 4 + "enable": true, 5 + "data": { 6 + "token": "myToken" 7 + } 8 + } 9 + ]
+2
src/backend/common/infrastructure/Atomic.ts
··· 15 15 | 'jellyfin' 16 16 | 'lastfm' 17 17 | 'deezer' 18 + | 'endpointlz' 18 19 | 'ytmusic' 19 20 | 'mpris' 20 21 | 'mopidy' ··· 35 36 'jellyfin', 36 37 'lastfm', 37 38 'deezer', 39 + 'endpointlz', 38 40 'ytmusic', 39 41 'mpris', 40 42 'mopidy',
+32
src/backend/common/infrastructure/config/source/endpointlz.ts
··· 1 + import { CommonSourceConfig, CommonSourceData } from "./index.js"; 2 + 3 + export interface ListenbrainzEndpointData extends CommonSourceData { 4 + /** 5 + * The URL ending that should be used to identify scrobbles for this source 6 + * 7 + * If you are using multiple Listenbrainz endpoint sources (scrobbles for many users) you can use a slug to match Sources with individual users/origins 8 + * 9 + * Example: 10 + * 11 + * * slug: 'usera' => API URL: http://localhost:9078/api/listenbrainz/usera 12 + * * slug: 'originb' => API URL: http://localhost:9078/api/listenbrainz/originb 13 + * 14 + * If no slug is found from an extension's incoming webhook event the first Listenbrainz source without a slug will be used 15 + * */ 16 + slug?: string | null 17 + 18 + /** 19 + * If an LZ submission request contains this token in the Authorization Header it will be used to match the submission with this Source 20 + * 21 + * See: https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#add-the-user-token-to-your-requests 22 + * */ 23 + token?: string | null 24 + } 25 + 26 + export interface ListenbrainzEndpointSourceConfig extends CommonSourceConfig { 27 + data?: ListenbrainzEndpointData 28 + } 29 + 30 + export interface ListenbrainzEndpointSourceAIOConfig extends ListenbrainzEndpointSourceConfig { 31 + type: 'endpointlz' 32 + }
+3
src/backend/common/infrastructure/config/source/sources.ts
··· 1 1 import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js"; 2 + import { ListenbrainzEndpointSourceAIOConfig, ListenbrainzEndpointSourceConfig } from "./endpointlz.js"; 2 3 import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js"; 3 4 import { JellyApiSourceAIOConfig, JellyApiSourceConfig, JellySourceAIOConfig, JellySourceConfig } from "./jellyfin.js"; 4 5 import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js"; ··· 24 25 | PlexApiSourceConfig 25 26 | TautulliSourceConfig 26 27 | DeezerSourceConfig 28 + | ListenbrainzEndpointSourceConfig 27 29 | SubSonicSourceConfig 28 30 | JellySourceConfig 29 31 | JellyApiSourceConfig ··· 46 48 | PlexApiSourceAIOConfig 47 49 | TautulliSourceAIOConfig 48 50 | DeezerSourceAIOConfig 51 + | ListenbrainzEndpointSourceAIOConfig 49 52 | SubsonicSourceAIOConfig 50 53 | JellySourceAIOConfig 51 54 | JellyApiSourceAIOConfig
+38 -1
src/backend/common/vendor/ListenbrainzApiClient.ts
··· 81 81 } 82 82 83 83 export interface SubmitPayload { 84 - listen_type: 'single', 84 + listen_type: 'single' | 'playing_now', 85 85 payload: [ListenPayload] 86 86 } 87 87 ··· 296 296 release_mbid: brainz.album, 297 297 release_group_mbid: brainz.releaseGroup 298 298 } 299 + } 300 + } 301 + } 302 + 303 + static listenPayloadToPlay(payload: ListenPayload, nowPlaying: boolean = false): PlayObject { 304 + const { 305 + listened_at = dayjs().unix(), 306 + track_metadata: { 307 + artist_name, 308 + track_name, 309 + additional_info: { 310 + duration, 311 + track_mbid, 312 + artist_mbids, 313 + release_mbid, 314 + release_group_mbid 315 + } = {} 316 + } = {}, 317 + } = payload; 318 + 319 + return { 320 + data: { 321 + playDate: typeof listened_at === 'number' ? dayjs.unix(listened_at) : dayjs(listened_at), 322 + track: track_name, 323 + artists: [artist_name], 324 + duration, 325 + meta: { 326 + brainz: { 327 + artist: artist_mbids !== undefined ? artist_mbids : undefined, 328 + album: release_mbid, 329 + albumArtist: release_group_mbid, 330 + track: track_mbid 331 + } 332 + } 333 + }, 334 + meta: { 335 + nowPlaying, 299 336 } 300 337 } 301 338 }
+2
src/backend/server/api.ts
··· 24 24 import { setupAuthRoutes } from "./auth.js"; 25 25 import { setupDeezerRoutes } from "./deezerRoutes.js"; 26 26 import { setupJellyfinRoutes } from "./jellyfinRoutes.js"; 27 + import {setupLZEndpointRoutes} from "./endpointListenbrainzRoutes.js"; 27 28 import { makeClientCheckMiddle, makeSourceCheckMiddle } from "./middleware.js"; 28 29 import { setupPlexRoutes } from "./plexRoutes.js"; 29 30 import { setupTautulliRoutes } from "./tautulliRoutes.js"; ··· 152 153 setupJellyfinRoutes(app, logger, scrobbleSources); 153 154 setupDeezerRoutes(app, logger, scrobbleSources); 154 155 setupWebscrobblerRoutes(app, logger, scrobbleSources); 156 + setupLZEndpointRoutes(app, logger, scrobbleSources); 155 157 setupAuthRoutes(app, logger, sourceRequiredMiddle, clientRequiredMiddle, scrobbleSources, scrobbleClients); 156 158 157 159 app.putAsync('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => {
+54
src/backend/server/endpointListenbrainzRoutes.ts
··· 1 + /* eslint-disable prefer-arrow-functions/prefer-arrow-functions */ 2 + import { ExpressWithAsync } from "@awaitjs/express"; 3 + import { childLogger, Logger } from "@foxxmd/logging"; 4 + import bodyParser from "body-parser"; 5 + import { EndpointListenbrainzSource, playStateFromRequest } from "../sources/EndpointListenbrainzSource.js"; 6 + import { LZEndpointNotifier } from "../sources/ingressNotifiers/LZEndpointNotifier.js"; 7 + import ScrobbleSources from "../sources/ScrobbleSources.js"; 8 + import { nonEmptyBody } from "./middleware.js"; 9 + 10 + export const setupLZEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logger, scrobbleSources: ScrobbleSources) => { 11 + 12 + const logger = childLogger(parentLogger, ['Ingress', 'Listenbrainz']); 13 + 14 + const lzJsonParser = bodyParser.json({ 15 + type: ['text/*', 'application/json'], 16 + }); 17 + const nonEmptyCheck = nonEmptyBody(logger, 'LZ Endpoint'); 18 + 19 + const webhookIngress = new LZEndpointNotifier(logger); 20 + app.useAsync(/\/api\/listenbrainz.*/, 21 + async function (req, res, next) { 22 + // track request before parsing body to ensure we at least log that something is happening 23 + // (in the event body parsing does not work or request is not POST/PATCH) 24 + webhookIngress.trackIngress(req, true); 25 + if (req.method !== 'POST') { 26 + return res.sendStatus(405); 27 + } 28 + next(); 29 + }, 30 + lzJsonParser, nonEmptyCheck, async function (req, res) { 31 + webhookIngress.trackIngress(req, false); 32 + 33 + res.sendStatus(200); 34 + 35 + 36 + const sources = scrobbleSources.getByType('endpointlz') as EndpointListenbrainzSource[]; 37 + if (sources.length === 0) { 38 + logger.warn('Received Listenbrainz endpoint payload but no Listenbrainz endpoint sources are configured'); 39 + } 40 + 41 + const validSources = sources.filter(x => x.matchRequest(req)); 42 + if (validSources.length === 0) { 43 + const [slug, token] = EndpointListenbrainzSource.parseDisplayIdentifiersFromRequest(req); 44 + logger.warn(`No Listenbrainz endpoint config matched => Slug: ${slug} | Token: ${token}`); 45 + } 46 + 47 + const playerState = playStateFromRequest(req.body); 48 + 49 + for (const source of validSources) { 50 + await source.handle(playerState); 51 + } 52 + }); 53 + } 54 +
+191
src/backend/sources/EndpointListenbrainzSource.ts
··· 1 + import dayjs from "dayjs"; 2 + import EventEmitter from "events"; 3 + import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js"; 4 + import { 5 + ExpressRequest, 6 + FormatPlayObjectOptions, 7 + InternalConfig, 8 + NO_USER, 9 + PlayerStateData, 10 + REPORTED_PLAYER_STATUSES, 11 + ReportedPlayerStatus 12 + } from "../common/infrastructure/Atomic.js"; 13 + import { ListenbrainzEndpointSourceConfig } from "../common/infrastructure/config/source/endpointlz.js"; 14 + import { ListenbrainzApiClient, ListenPayload, SubmitPayload } from "../common/vendor/ListenbrainzApiClient.js"; 15 + import { parseRegexSingleOrFail } from "../utils.js"; 16 + import MemorySource from "./MemorySource.js"; 17 + 18 + const noSlugMatch = new RegExp(/\/api\/listenbrainz(?:\/?|\/1\/?|\/1\/submit-listens\/?)$/i); 19 + const slugMatch = new RegExp(/\/api\/listenbrainz\/([^\/]+)(?:\/?|\/1\/?|\/1\/submit-listens\/?)$/i); 20 + 21 + export const authHeaderRegex = new RegExp(/Token (.+)$/i); 22 + 23 + export class EndpointListenbrainzSource extends MemorySource { 24 + 25 + declare config: ListenbrainzEndpointSourceConfig; 26 + 27 + constructor(name: any, config: ListenbrainzEndpointSourceConfig, internal: InternalConfig, emitter: EventEmitter) { 28 + super('endpointlz', name, config, internal, emitter); 29 + this.multiPlatform = true; 30 + this.playerSourceOfTruth = SOURCE_SOT.HISTORY; 31 + 32 + const { 33 + data = {}, 34 + data: { 35 + slug, 36 + } = {} 37 + } = this.config; 38 + this.config.data = { 39 + token: undefined, 40 + ...data, 41 + slug: slug === null ? undefined : slug, 42 + }; 43 + } 44 + 45 + static parseSlugFromString(path: string): string | false | undefined { 46 + const noSlug = parseRegexSingleOrFail(noSlugMatch, path); 47 + if (noSlug !== undefined) { 48 + return undefined; 49 + } 50 + const slugResult = parseRegexSingleOrFail(slugMatch, path); 51 + if (slugResult !== undefined) { 52 + return slugResult.groups[0]; 53 + } 54 + return false; 55 + } 56 + 57 + static parseSlugFromRequest(req: ExpressRequest): string | false | undefined { 58 + return EndpointListenbrainzSource.parseSlugFromString(req.baseUrl); 59 + } 60 + 61 + static parseTokenFromString(str: string): string | undefined { 62 + const tokenMatch = parseRegexSingleOrFail(authHeaderRegex, str); 63 + if(tokenMatch !== undefined) { 64 + return tokenMatch.groups[0]; 65 + } 66 + return undefined; 67 + } 68 + 69 + static parseTokenFromRequest(req: ExpressRequest): string | false | undefined { 70 + const auth = req.header('Authorization'); 71 + if(typeof auth === 'string' && auth !== '') { 72 + const matchedToken = EndpointListenbrainzSource.parseTokenFromString(auth); 73 + if(matchedToken === undefined) { 74 + return false; 75 + } 76 + return matchedToken; 77 + } 78 + return undefined; 79 + } 80 + 81 + static parseIdentifiersFromRequest(req: ExpressRequest): [string | false | undefined, false | string | undefined] { 82 + const slug = EndpointListenbrainzSource.parseSlugFromRequest(req); 83 + const token = EndpointListenbrainzSource.parseTokenFromRequest(req); 84 + 85 + return [slug, token]; 86 + } 87 + 88 + static parseDisplayIdentifiersFromRequest(req: ExpressRequest): [string, string] { 89 + const [slug, token] = EndpointListenbrainzSource.parseIdentifiersFromRequest(req); 90 + let slugStr = '(no slug)'; 91 + if (slug === false) { 92 + slugStr = '(invalid slug)'; 93 + } else if (slug !== undefined) { 94 + slugStr = slug; 95 + } 96 + 97 + let tokenStr = '(no token)'; 98 + if (token === false) { 99 + tokenStr = '(invalid token)'; 100 + } else if (token !== undefined) { 101 + tokenStr = `${token.substring(0,3)}****` 102 + } 103 + return [slugStr, tokenStr]; 104 + } 105 + 106 + matchRequest(req: ExpressRequest): boolean { 107 + let matchesToken = this.config.data.token === undefined; 108 + const reqToken = EndpointListenbrainzSource.parseTokenFromRequest(req); 109 + if (reqToken === false) { 110 + return false; 111 + } 112 + matchesToken = this.config.data.token === undefined && reqToken === undefined || 113 + (reqToken !== undefined && this.config.data.token !== undefined 114 + && this.config.data.token.toLowerCase().trim() === reqToken.toLowerCase().trim()); 115 + 116 + if (!matchesToken) { 117 + return false; 118 + } 119 + 120 + let matchesPath = false; 121 + const slug = EndpointListenbrainzSource.parseSlugFromRequest(req); 122 + if (slug === false) { 123 + return false; 124 + } else { 125 + matchesPath = (this.config.data.slug === undefined && slug === undefined) || (slug !== undefined && this.config.data.slug !== undefined && this.config.data.slug.toLowerCase().trim() === slug.toLocaleLowerCase().trim()); 126 + } 127 + 128 + return matchesToken && matchesPath; 129 + } 130 + 131 + static listenTypeAsPlayerStatus(event: string): ReportedPlayerStatus { 132 + switch (event) { 133 + case 'single': 134 + case 'playing_now': 135 + return REPORTED_PLAYER_STATUSES.playing; 136 + default: 137 + return REPORTED_PLAYER_STATUSES.unknown; 138 + } 139 + } 140 + 141 + static formatPlayObj(obj: ListenPayload, options: FormatPlayObjectOptions & { 142 + nowPlaying?: boolean 143 + } = {}): PlayObject { 144 + return ListenbrainzApiClient.listenPayloadToPlay(obj, options.nowPlaying); 145 + } 146 + 147 + getRecentlyPlayed = async (options = {}) => { 148 + return this.getFlatRecentlyDiscoveredPlays(); 149 + } 150 + 151 + isValidScrobble = (playObj: PlayObject) => { 152 + return true; 153 + } 154 + 155 + handle = async (stateData: PlayerStateData) => { 156 + 157 + this.processRecentPlays([stateData]); 158 + 159 + if (stateData.play.meta.nowPlaying === false && this.isValidScrobble(stateData.play)) { 160 + const discovered = this.discover([stateData.play]); 161 + if (discovered.length > 0) { 162 + this.scrobble(discovered); 163 + } 164 + } 165 + } 166 + } 167 + 168 + export const playStateFromRequest = (obj: SubmitPayload): PlayerStateData => { 169 + const { 170 + listen_type, 171 + payload, 172 + } = obj; 173 + 174 + const play = ListenbrainzApiClient.listenPayloadToPlay(payload[0], listen_type === 'playing_now'); 175 + return { 176 + platformId: [play.meta.deviceId, NO_USER], 177 + play, 178 + status: listenTypeAsPlayerStatus(listen_type), 179 + timestamp: dayjs() 180 + } 181 + } 182 + 183 + export const listenTypeAsPlayerStatus = (event: string): ReportedPlayerStatus => { 184 + switch (event) { 185 + case 'single': 186 + case 'playing_now': 187 + return REPORTED_PLAYER_STATUSES.playing; 188 + default: 189 + return REPORTED_PLAYER_STATUSES.unknown; 190 + } 191 + }
+25
src/backend/sources/ScrobbleSources.ts
··· 5 5 import { AIOConfig, SourceDefaults } from "../common/infrastructure/config/aioConfig.js"; 6 6 import { ChromecastSourceConfig } from "../common/infrastructure/config/source/chromecast.js"; 7 7 import { DeezerData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js"; 8 + import { ListenbrainzEndpointSourceConfig, ListenbrainzEndpointData } from "../common/infrastructure/config/source/endpointlz.js"; 8 9 import { 9 10 JellyApiData, 10 11 JellyApiSourceConfig, ··· 33 34 import AbstractSource from "./AbstractSource.js"; 34 35 import { ChromecastSource } from "./ChromecastSource.js"; 35 36 import DeezerSource from "./DeezerSource.js"; 37 + import { EndpointListenbrainzSource } from "./EndpointListenbrainzSource.js"; 36 38 import JellyfinApiSource from "./JellyfinApiSource.js"; 37 39 import JellyfinSource from "./JellyfinSource.js"; 38 40 import { JRiverSource } from "./JRiverSource.js"; ··· 129 131 break; 130 132 case 'deezer': 131 133 this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("DeezerSourceConfig"); 134 + break; 135 + case 'endpointlz': 136 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ListenbrainzEndpointSourceConfig"); 132 137 break; 133 138 case 'subsonic': 134 139 this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("SubSonicSourceConfig"); ··· 377 382 // sane default for lastfm is that user want to scrobble TO it, not FROM it -- this is also existing behavior 378 383 defaultConfigureAs = 'client'; 379 384 break; 385 + case 'endpointlz': 386 + const lzShouldUse = parseBool(process.env.LZENDPOINT_ENABLE); 387 + const lze = { 388 + slug: process.env.LZE_SLUG, 389 + token: process.env.LZE_TOKEN 390 + } 391 + if (!Object.values(lze).every(x => x === undefined) || lzShouldUse) { 392 + configs.push({ 393 + type: 'endpointlz', 394 + name: 'unnamed', 395 + source: 'ENV', 396 + mode: 'single', 397 + configureAs: defaultConfigureAs, 398 + data: lze as ListenbrainzEndpointData 399 + }); 400 + } 401 + break; 380 402 case 'jriver': 381 403 const jr = { 382 404 url: process.env.JRIVER_URL, ··· 662 684 break; 663 685 case 'listenbrainz': 664 686 newSource = await new ListenbrainzSource(name, compositeConfig as ListenBrainzSourceConfig, this.internalConfig, this.emitter); 687 + break; 688 + case 'endpointlz': 689 + newSource = await new EndpointListenbrainzSource(name, compositeConfig as ListenbrainzEndpointSourceConfig, this.internalConfig, this.emitter); 665 690 break; 666 691 case 'jriver': 667 692 newSource = await new JRiverSource(name, compositeConfig as JRiverSourceConfig, this.internalConfig, this.emitter);
+46
src/backend/sources/ingressNotifiers/LZEndpointNotifier.ts
··· 1 + import { Logger } from "@foxxmd/logging"; 2 + import { Request } from "express"; 3 + import { EndpointListenbrainzSource } from "../EndpointListenbrainzSource.js"; 4 + import { IngressNotifier } from "./IngressNotifier.js"; 5 + 6 + export class LZEndpointNotifier extends IngressNotifier { 7 + 8 + constructor(logger: Logger) { 9 + super('Listenbrainz Endpoint', logger); 10 + } 11 + 12 + seenSlugs: Record<string, boolean> = {}; 13 + notifyBySource(req: Request, isRaw: boolean): [boolean, (string | undefined)] { 14 + 15 + if(!isRaw) { 16 + 17 + const [slug, token] = EndpointListenbrainzSource.parseIdentifiersFromRequest(req); 18 + if(slug === false) { 19 + return [false, `Request URL was not a valid: ${req.baseUrl}`]; 20 + } 21 + const slugStr = slug ?? '(no slug)'; 22 + 23 + if(token === false) { 24 + return [false, `Request URL was valid and 'Authorization' header was present but invalid. Authorization header should be 'Token tokenValue' but was '${req.header('Authorization')}'`]; 25 + } 26 + const tokenStr = token ?? '(no token)'; 27 + const redactedToken = token !== undefined ? `${(token as string).substring(0,3)}****` : '(no token)'; 28 + 29 + const identifier = `${slugStr}-${tokenStr}`; 30 + 31 + if(this.seenSlugs[identifier] === undefined) { 32 + this.seenSlugs[identifier] = true; 33 + return [true, `Received a well formed request to endpoint with -- Slug: ${slugStr} | Token: ${redactedToken} -- for the first time.`]; 34 + } 35 + } 36 + 37 + return [true, undefined]; 38 + } 39 + 40 + notifyByRequest(req: Request, isRaw: boolean): string | undefined { 41 + if(req.method !== 'POST') { 42 + return `Expected POST request (submit-listen payload) but received ${req.method}`; 43 + } 44 + return; 45 + } 46 + }