diff --git a/docsite/docs/configuration/sources/_env_configs/_endpointlfm.md b/docsite/docs/configuration/sources/_env_configs/_endpointlfm.md index df70de16..b885cf29 100644 --- a/docsite/docs/configuration/sources/_env_configs/_endpointlfm.md +++ b/docsite/docs/configuration/sources/_env_configs/_endpointlfm.md @@ -3,4 +3,6 @@ | _**`LFM_ID`**_ | string | | A globally unique ID EX `myComponentId` | | `LFM_NAME` | string | Value of `LFM_ID` | A vanity name EX `My Cool Component` | | `LFM_ENABLE` | boolean | true | Should this component be used? | -| `LFM_SLUG` | string | | The URL ending that should be used to identify scrobbles for this source | \ No newline at end of file +| `LFM_SLUG` | string | | The URL ending that should be used to identify scrobbles for this source | +| `LFM_USERNAME` | string | | A fake username to differentiate LFM Endpoint Sources | +| `LFM_API_KEY` | string | | A fake api key to differentiate LFM Endpoint Sources | \ No newline at end of file diff --git a/docsite/docs/configuration/sources/_env_configs/_tealfm.md b/docsite/docs/configuration/sources/_env_configs/_tealfm.md index d10d475e..779b846b 100644 --- a/docsite/docs/configuration/sources/_env_configs/_tealfm.md +++ b/docsite/docs/configuration/sources/_env_configs/_tealfm.md @@ -1,7 +1,7 @@ -| Environmental Variable | Type | Default | Description | -| -------------------------------- | ------- | --------------------------- | --------------------------------------- | -| _**`SOURCE_TEALFM_ID`**_ | string | | A globally unique ID EX `myComponentId` | -| `SOURCE_TEALFM_NAME` | string | Value of `SOURCE_TEALFM_ID` | A vanity name EX `My Cool Component` | -| `SOURCE_TEALFM_ENABLE` | boolean | true | Should this component be used? | -| _**`SOURCE_TEALFM_IDENTIFIER`**_ | string | | Identify the account to login as | -| `SOURCE_TEALFM_APP_PW` | string | | | \ No newline at end of file +| Environmental Variable | Type | Default | Description | +| -------------------------------- | ------- | --------------------------- | --------------------------------------------------------------------------------------------- | +| _**`SOURCE_TEALFM_ID`**_ | string | | A globally unique ID EX `myComponentId` | +| `SOURCE_TEALFM_NAME` | string | Value of `SOURCE_TEALFM_ID` | A vanity name EX `My Cool Component` | +| `SOURCE_TEALFM_ENABLE` | boolean | true | Should this component be used? | +| _**`SOURCE_TEALFM_IDENTIFIER`**_ | string | | Identify the account to login as | +| _**`SOURCE_TEALFM_APP_PW`**_ | string | | The [App Password](https://atproto.com/specs/xrpc#app-passwords) you created for your account | \ No newline at end of file diff --git a/docsite/docs/configuration/sources/lastfm-endpoint.mdx b/docsite/docs/configuration/sources/lastfm-endpoint.mdx index 8369f128..48100eed 100644 --- a/docsite/docs/configuration/sources/lastfm-endpoint.mdx +++ b/docsite/docs/configuration/sources/lastfm-endpoint.mdx @@ -19,23 +19,50 @@ This Source enables multi-scrobbler to accept scrobbles from outside application ::: +## Setup + ### URL -If a **slug** is **not** provided in configuration then multi-scrobbler will accept Last.fm scrobbles at +When setting up your Last.fm client to communicate with Multi-Scrobbler replace the Last.fm domain with your Multi-Scrobbler domain: -``` -http://localhost:9078/2.0/ -``` +https://**last.fm** => https://**yourMSDomain** + +MS accepts Last.fm API calls with the same **url base** and structure as the [last.fm api](https://www.last.fm/api/intro), IE `http://yourMSDomain/2.0/` + +### Authentication + +If you are only setting up **one** Lastfm Endpoint Source then you do not need to configure any explicit username/apiKey/password for MS. If your Last.fm Client requires these credentials use any fake values you want. + + + +Currently, Multi-Scrobbler only supports the [**Mobile Application**](https://www.last.fm/api/mobileauth) auth flow. If your client requires one of the other authentication flows please [open an issue](https://github.com/FoxxMD/multi-scrobbler/issues/new?template=02-feature-request.yml). + +**Note:** Your client does not **need** to implement any auth in order to use a Lastfm Endpoint Source. You can directly make [`track.scrobble`](https://www.last.fm/api/show/track.scrobble) or [`track.updateNowPlaying`](https://www.last.fm/api/show/track.updateNowPlaying) api calls to `http://yourMSDomain/2.0/` with any fake auth data you want. -which is the "standard" Last.fm server path for scrobbling + -Use a slug only if you need to setup multiple Last.fm Endpoint sources and cannot use different tokens. +#### Multiple Sources -If a slug is used then the URL will be: +If you have **more than one** Lastfm Endpoint Source then you should configure username/apiKey for MS, per Source. This enables MS to differentiate scrobbles for each Source. + +Use the same **username** and/or **API Key** you configure with MS when setting up your Last.fm Client. These values can be anything you want, as long as they match between MS and your client. Password can be anything and is not checked. + + + +If you cannot use different username/apiKey per Source (or Last.fm Client) you can still differentiate Sources by using a different **url base (slug)** for Last.fm communication. + +Setting a **slug** in config will change the **url base** for MS like this: ``` -http://localhost:9078/api/lastfm/mySlug +slug: "mySlug" ``` +``` +http://yourMsDomain/api/lastfm/mySlug +``` + +The above url base is equivalent to making calls to `http://yourMSDomain/2.0/` + + ## Configuration diff --git a/src/backend/common/infrastructure/config/source/endpointlfm.ts b/src/backend/common/infrastructure/config/source/endpointlfm.ts index eacea887..84e23fd8 100644 --- a/src/backend/common/infrastructure/config/source/endpointlfm.ts +++ b/src/backend/common/infrastructure/config/source/endpointlfm.ts @@ -18,12 +18,20 @@ export const lastFmEndpointDataSchema = z.object({ slug: z.string().optional().meta({ description: "The URL ending that should be used to identify scrobbles for this source" }), + username: z.string().optional().meta({ + description: 'A fake username to differentiate LFM Endpoint Sources' + }), + apiKey: z.string().optional().meta({ + description: 'A fake api key to differentiate LFM Endpoint Sources' + }), }); export type LastFMEndpointData = z.infer; const envDataSchema = z.object({ LFM_SLUG: lastFmEndpointDataSchema.shape.slug, + LFM_USERNAME: lastFmEndpointDataSchema.shape.username, + LFM_API_KEY: lastFmEndpointDataSchema.shape.apiKey }); export const envSchemas: EnvSourceSchema = { @@ -31,7 +39,9 @@ export const envSchemas: EnvSourceSchema ({ data: { - slug: partial.LFM_SLUG + slug: partial.LFM_SLUG, + username: partial.LFM_USERNAME, + apiKey: partial.LFM_API_KEY } }) }; diff --git a/src/backend/common/vendor/LastfmApiClient.ts b/src/backend/common/vendor/LastfmApiClient.ts index 40032383..f21a5721 100644 --- a/src/backend/common/vendor/LastfmApiClient.ts +++ b/src/backend/common/vendor/LastfmApiClient.ts @@ -20,6 +20,7 @@ import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.ts"; import { ScrobbleSubmitError, SimpleError } from "../errors/MSErrors.ts"; import { redactString } from "@foxxmd/redact-string"; import dns from 'node:dns/promises'; +import xml2js from 'xml2js'; const badErrors = [ 'api key suspended', @@ -514,7 +515,7 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina } = {} } = response; if (ignoreCode > 0) { - this.logger.warn({payload: rest}), `Service ignored this scrobble => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/show/track.updateNowPlaying for more information`; + this.logger.warn({payload: rest}, `Service ignored this scrobble => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/show/track.updateNowPlaying for more information`); } return response; } catch (e) { @@ -813,4 +814,135 @@ export interface LastFMScrobblePayload { export interface LastFMScrobbleRequestPayload extends LastFMScrobblePayload { method: string +} + +export const playToScrobbleApiResponseJson = (play: PlayObject) => { + const jsonPayload: LastFMTrackScrobbleResponse = { + scrobbles: { + '@attr': { + accepted: 1, + ignored: 0 + }, + scrobble: { + track: { + corrected: 0, + '#text': play.data.track + }, + artist: { + corrected: 0, + '#text': play.data.artists?.join(',') + }, + album: { + corrected: 0, + '#text': play.data.album + }, + albumArtist: { + corrected: 0, + '#text': play.data.albumArtists?.join(',') + }, + timestamp: dayjs().unix(), + ignoredMessage: { + code: 0, + '#text': '' + } + } + } + } + return jsonPayload; +} + +export const playToNowPlayingApiResponseJson = (play: PlayObject) => { + const jsonPayload = { + nowplaying: { + track: { + corrected: 0, + '#text': play.data.track + }, + artist: { + corrected: 0, + '#text': play.data.artists?.join(',') + }, + album: { + corrected: 0, + '#text': play.data.album + }, + albumArtist: { + corrected: 0, + '#text': play.data.albumArtists?.join(',') + }, + ignoredMessage: { + code: 0, + '#text': '' + } + } + } + return jsonPayload; +} + +export const playToScrobbleApiResponseXml = (play: PlayObject) => { + const builder = new xml2js.Builder(); + const xml = builder.buildObject({ + lfm: { + $: { status: "ok" }, + scrobbles: { + $: {accepted: 2, ignored: 0}, + scrobble: { + track: { + $: {corrected: 0}, + _: play.data.track + }, + artist: { + $: {corrected: 0}, + _: play.data.artists?.join(',') + }, + album: { + $: {corrected: 0}, + _: play.data.album + }, + albumArtist: { + $: {corrected: 0}, + _: play.data.albumArtists?.join(',') + }, + timestamp: { + _: dayjs().unix(), + }, + ignoredMessage: { + $: {code: 0} + } + } + } + } + }); + return xml; +} + +export const playToNowPlayingApiResponseXml = (play: PlayObject) => { + const builder = new xml2js.Builder(); + const xml = builder.buildObject({ + lfm: { + $: { status: "ok" }, + nowplaying: { + track: { + $: { corrected: 0 }, + _: play.data.track + }, + artist: { + $: { corrected: 0 }, + _: play.data.artists?.join(',') + }, + album: { + $: { corrected: 0 }, + _: play.data.album + }, + albumArtist: { + $: { corrected: 0 }, + _: play.data.albumArtists?.join(',') + }, + ignoredMessage: { + $: { code: 0 } + } + } + } + }); + return xml; } \ No newline at end of file diff --git a/src/backend/server/endpointLastfmRoutes.ts b/src/backend/server/endpointLastfmRoutes.ts index 462376c5..a3fb9764 100644 --- a/src/backend/server/endpointLastfmRoutes.ts +++ b/src/backend/server/endpointLastfmRoutes.ts @@ -7,7 +7,11 @@ import { nonEmptyBody } from "./middleware.ts"; import { LFMEndpointNotifier } from "../sources/ingressNotifiers/LFMEndpointNotifier.ts"; import type { EndpointLastfmSource} from "../sources/EndpointLastfmSource.ts"; import { playStateFromRequest, parseDisplayIdentifiersFromRequest } from "../sources/EndpointLastfmSource.ts"; -import type {LastFMScrobbleRequestPayload} from "../common/vendor/LastfmApiClient.ts"; +import {playToNowPlayingApiResponseJson, playToNowPlayingApiResponseXml, playToScrobbleApiResponseJson, playToScrobbleApiResponseXml, type LastFMScrobbleRequestPayload} from "../common/vendor/LastfmApiClient.ts"; +import xml2js from 'xml2js'; +import crypto from 'node:crypto'; + +const unmatchIdentifierWarn: string[] = []; export const setupLastfmEndpointRoutes = (app: Express, parentLogger: Logger, scrobbleSources: ScrobbleSources) => { @@ -39,23 +43,78 @@ export const setupLastfmEndpointRoutes = (app: Express, parentLogger: Logger, sc if (validSources.length === 0) { const [slug] = parseDisplayIdentifiersFromRequest(req); logger.warn(`No Lastfm endpoint config matched => Slug: ${slug}`); + return res.status(409); } if(!('method' in req.body)) { return res.status(400).json({error: `Missing 'method' param`}); } const method = (req.body as LastFMScrobbleRequestPayload).method; - if(!['track.updateNowPlaying','track.scrobble'].includes(method)) { - return res.status(400).json({error: `Unexpected 'method' param value '${method}', expected either 'track.updateNowPlaying' or 'track.scrobble'`}); + + let source: EndpointLastfmSource; + // try to find by username or api_key or sk + if(req.body.api_key !== undefined) { + source = validSources.find(x => x.config.data?.apiKey === req.body.api_key); + if(source === undefined) { + const level = unmatchIdentifierWarn.includes(req.body.api_key) ? 'trace' : 'warn'; + logger[level](`No LFM Endpoint Source has the apiKey '${req.body.api_key}' configured so will use the first Endpoint Source listed instead.`); + unmatchIdentifierWarn.push(req.body.api_key); + } + } else if(req.body.username !== undefined) { + source = validSources.find(x => x.config.data?.username === req.body.username); + if(source === undefined) { + const level = unmatchIdentifierWarn.includes(req.body.username) ? 'trace' : 'warn'; + logger[level](`No LFM Endpoint Source has the username '${req.body.username}' configured so will use the first Endpoint Source listed instead.`); + unmatchIdentifierWarn.push(req.body.username); + } + } else if(req.body.sk !== undefined) { + source = validSources.find(x => crypto.createHash('md5').update(x.getUid()).digest('hex') === req.body.sk); + if(source === undefined) { + const level = unmatchIdentifierWarn.includes(req.body.sk) ? 'trace' : 'warn'; + logger[level](`No LFM Endpoint Source has an ID md5 that matches the provided session key (sk) '${req.body.sk}' configured so will use the first Endpoint Source listed instead.`); + unmatchIdentifierWarn.push(req.body.sk); + } } - res.sendStatus(200); + if(source === undefined) { + source = validSources[0]; + } - const playerState = playStateFromRequest(req.body); + switch (method) { + case 'auth.getMobileSession': { + const resp = { + session: { + name: req.body.name ?? source.getUid(), + key: crypto.createHash('md5').update(source.getUid()).digest('hex'), + subscriber: 0 + } + }; + if (req.query.format === 'json') { + return res.status(200).json(resp); + } + const builder = new xml2js.Builder(); + const xml = builder.buildObject({ lfm: { $: { status: "ok" }, ...resp } }); + return res.status(200).setHeader('Content-Type', 'application/xml').send(xml); + } + case 'track.updateNowPlaying': + case 'track.scrobble': { + const playerState = playStateFromRequest(req.body); + if (method === 'track.scrobble') { + if (req.query.format === 'json') { + res.status(200).json(playToScrobbleApiResponseJson(playerState.play)) + } + res.status(200).setHeader('Content-Type', 'application/xml').send(playToScrobbleApiResponseXml(playerState.play)); + } else { + if (req.query.format === 'json') { + res.status(200).json(playToNowPlayingApiResponseJson(playerState.play)) + } + res.status(200).setHeader('Content-Type', 'application/xml').send(playToNowPlayingApiResponseXml(playerState.play)); + } + await source.handle(playerState) + } break; + default: + return res.status(400).json({ error: `Unexpected 'method' param value '${method}', expected one of: track.updateNowPlaying | track.scrobble | auth.getMobileSession` }); - for (const source of validSources) { - await source.handle(playerState); } }); -} - +} \ No newline at end of file diff --git a/src/backend/sources/EndpointLastfmSource.ts b/src/backend/sources/EndpointLastfmSource.ts index 0c8b6242..85114068 100644 --- a/src/backend/sources/EndpointLastfmSource.ts +++ b/src/backend/sources/EndpointLastfmSource.ts @@ -44,15 +44,12 @@ export class EndpointLastfmSource extends MemorySource { } matchRequest(req: ExpressRequest): boolean { - let matchesPath = false; const slug = parseSlugFromRequest(req); if (slug === false) { return false; - } else { - matchesPath = (this.config.data.slug === undefined && slug === undefined) || (slug !== undefined && this.config.data.slug !== undefined && this.config.data.slug.toLowerCase().trim() === slug.toLocaleLowerCase().trim()); } - return matchesPath; + return (this.config.data.slug === undefined && slug === undefined) || (slug !== undefined && this.config.data.slug !== undefined && this.config.data.slug.toLowerCase().trim() === slug.toLocaleLowerCase().trim()); } static formatPlayObj(obj: LastFMScrobbleRequestPayload, options: FormatPlayObjectOptions = {}): PlayObject { -- 2.51.2 From fc027e855463d3328bcd717b340724af4fa3b736 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 4 Aug 2026 16:07:11 +0000 Subject: [PATCH 2/8] fix(lfm endpoint): remove duplicated method restrictions --- src/backend/sources/ingressNotifiers/LFMEndpointNotifier.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/backend/sources/ingressNotifiers/LFMEndpointNotifier.ts b/src/backend/sources/ingressNotifiers/LFMEndpointNotifier.ts index 439bd3d4..17e93c9f 100644 --- a/src/backend/sources/ingressNotifiers/LFMEndpointNotifier.ts +++ b/src/backend/sources/ingressNotifiers/LFMEndpointNotifier.ts @@ -34,16 +34,12 @@ export class LFMEndpointNotifier extends IngressNotifier { notifyByRequest(req: Request, isRaw: boolean): string | undefined { if(req.method !== 'POST') { - return `Expected POST request (track.scrobble payload) but received ${req.method}`; + return `Expected POST request but received ${req.method}`; } if(!isRaw) { if(!('method' in req.body)) { return `Body is missing 'method' param` } - const method = (req.body as LastFMScrobbleRequestPayload).method; - if(!['track.updateNowPlaying','track.scrobble'].includes(method)) { - return `Unexpected 'method' param value '${method}', expected either 'track.updateNowPlaying' or 'track.scrobble'` - } } return; } -- 2.51.2 From b6b8d3423fd0243e654009edd1f71321759f3fac Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 4 Aug 2026 18:06:45 +0000 Subject: [PATCH 3/8] fix(lfm endpoint): Detect desired json response from accept header --- src/backend/server/endpointLastfmRoutes.ts | 24 ++++++++++++++++----- src/backend/sources/EndpointLastfmSource.ts | 2 +- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/backend/server/endpointLastfmRoutes.ts b/src/backend/server/endpointLastfmRoutes.ts index a3fb9764..98353c8f 100644 --- a/src/backend/server/endpointLastfmRoutes.ts +++ b/src/backend/server/endpointLastfmRoutes.ts @@ -51,6 +51,18 @@ export const setupLastfmEndpointRoutes = (app: Express, parentLogger: Logger, sc } const method = (req.body as LastFMScrobbleRequestPayload).method; + let wantsJson: boolean = false; + if(req.query.format === 'json') { + wantsJson = true; + } else { + // some players, like ArchiveTune, use the accept header to signal they want json + // rather than using the official format=json qs lastfm wants + const a = req.header('accept'); + if(a !== undefined && a.includes('json')) { + wantsJson = true; + } + } + let source: EndpointLastfmSource; // try to find by username or api_key or sk if(req.body.api_key !== undefined) { @@ -89,7 +101,7 @@ export const setupLastfmEndpointRoutes = (app: Express, parentLogger: Logger, sc subscriber: 0 } }; - if (req.query.format === 'json') { + if (wantsJson) { return res.status(200).json(resp); } const builder = new xml2js.Builder(); @@ -100,15 +112,17 @@ export const setupLastfmEndpointRoutes = (app: Express, parentLogger: Logger, sc case 'track.scrobble': { const playerState = playStateFromRequest(req.body); if (method === 'track.scrobble') { - if (req.query.format === 'json') { + if (wantsJson) { res.status(200).json(playToScrobbleApiResponseJson(playerState.play)) + } else { + res.status(200).setHeader('Content-Type', 'application/xml').send(playToScrobbleApiResponseXml(playerState.play)); } - res.status(200).setHeader('Content-Type', 'application/xml').send(playToScrobbleApiResponseXml(playerState.play)); } else { - if (req.query.format === 'json') { + if (wantsJson) { res.status(200).json(playToNowPlayingApiResponseJson(playerState.play)) + } else { + res.status(200).setHeader('Content-Type', 'application/xml').send(playToNowPlayingApiResponseXml(playerState.play)); } - res.status(200).setHeader('Content-Type', 'application/xml').send(playToNowPlayingApiResponseXml(playerState.play)); } await source.handle(playerState) } break; diff --git a/src/backend/sources/EndpointLastfmSource.ts b/src/backend/sources/EndpointLastfmSource.ts index 85114068..56282ebd 100644 --- a/src/backend/sources/EndpointLastfmSource.ts +++ b/src/backend/sources/EndpointLastfmSource.ts @@ -66,7 +66,7 @@ export class EndpointLastfmSource extends MemorySource { handle = async (stateData: PlayerStateData) => { - if(stateData[0].play.meta.nowPlaying === true) { + if(stateData.play.meta.nowPlaying === true) { this.setStatus('Received Now Playing'); } else { this.setStatus('Received Play'); -- 2.51.2 From eca8ad2ce1803d30b96aa8bae5e2fef02ab7e910 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 4 Aug 2026 18:16:39 +0000 Subject: [PATCH 4/8] fix(lfm endpoint): Fix slug regex match --- src/backend/sources/EndpointLastfmSource.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/sources/EndpointLastfmSource.ts b/src/backend/sources/EndpointLastfmSource.ts index 56282ebd..c3569e3a 100644 --- a/src/backend/sources/EndpointLastfmSource.ts +++ b/src/backend/sources/EndpointLastfmSource.ts @@ -18,7 +18,7 @@ import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.ts"; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; const noSlugMatch = new RegExp(/(?:\/api\/lastfm\/?)$|(?:\/1\/?|\/2.0\/?)$/i); -const slugMatch = new RegExp(/\/api\/lastfm\/([^\/]+)$/i); +const slugMatch = new RegExp(/\/api\/lastfm\/([^\/]+)\/?$/i); export const authHeaderRegex = new RegExp(/Token (.+)$/i); @@ -114,7 +114,7 @@ export const parseSlugFromString = (path: string): string | false | undefined => return false; } -export const parseSlugFromRequest = (req: ExpressRequest): string | false | undefined => parseSlugFromString(req.baseUrl); +export const parseSlugFromRequest = (req: ExpressRequest): string | false | undefined => parseSlugFromString(req.originalUrl); export const parseIdentifiersFromRequest = (req: ExpressRequest): [string | false | undefined] => { const slug = parseSlugFromRequest(req); -- 2.51.2 From 8723bc889078457daad5855f441345ec60d2c668 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 4 Aug 2026 18:29:41 +0000 Subject: [PATCH 5/8] fix(lfm endpoint): Improve regex slug matching to handle more variants --- src/backend/sources/EndpointLastfmSource.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/sources/EndpointLastfmSource.ts b/src/backend/sources/EndpointLastfmSource.ts index c3569e3a..a6ce061a 100644 --- a/src/backend/sources/EndpointLastfmSource.ts +++ b/src/backend/sources/EndpointLastfmSource.ts @@ -17,8 +17,8 @@ import type {PlayerStateOptions} from "./PlayerState/AbstractPlayerState.ts"; import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.ts"; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; -const noSlugMatch = new RegExp(/(?:\/api\/lastfm\/?)$|(?:\/1\/?|\/2.0\/?)$/i); -const slugMatch = new RegExp(/\/api\/lastfm\/([^\/]+)\/?$/i); +const noSlugMatch = new RegExp(/(?:\/api\/lastfm\/?)$|(?:^\/1\/?|^\/2.0\/?)$/i); +const slugMatch = new RegExp(/\/api\/lastfm\/([^\/]+)(\/|\/2.0\/)?$/i); export const authHeaderRegex = new RegExp(/Token (.+)$/i); -- 2.51.2 From b53ae78d552d74fe98a831f82c25196ff8e4e085 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 4 Aug 2026 18:30:16 +0000 Subject: [PATCH 6/8] fix(lfm endpoint): Fix no response on no slug matched --- src/backend/server/endpointLastfmRoutes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/server/endpointLastfmRoutes.ts b/src/backend/server/endpointLastfmRoutes.ts index 98353c8f..9f07058d 100644 --- a/src/backend/server/endpointLastfmRoutes.ts +++ b/src/backend/server/endpointLastfmRoutes.ts @@ -43,7 +43,7 @@ export const setupLastfmEndpointRoutes = (app: Express, parentLogger: Logger, sc if (validSources.length === 0) { const [slug] = parseDisplayIdentifiersFromRequest(req); logger.warn(`No Lastfm endpoint config matched => Slug: ${slug}`); - return res.status(409); + return res.sendStatus(409); } if(!('method' in req.body)) { -- 2.51.2 From 6f8debc3047ae229b5014a6b658b95d8f06bd895 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 4 Aug 2026 18:50:59 +0000 Subject: [PATCH 7/8] docs: Add lastfm endpoint alternative guidance --- docsite/docs/configuration/sources/lastfm-endpoint.mdx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docsite/docs/configuration/sources/lastfm-endpoint.mdx b/docsite/docs/configuration/sources/lastfm-endpoint.mdx index 48100eed..867f4c67 100644 --- a/docsite/docs/configuration/sources/lastfm-endpoint.mdx +++ b/docsite/docs/configuration/sources/lastfm-endpoint.mdx @@ -19,6 +19,14 @@ This Source enables multi-scrobbler to accept scrobbles from outside application ::: + + +This Source has the same data limitations as the [Last.fm (Source)](/configuration/sources/lastfm-source) and [Last.fm (Client)](/configuration/clients/lastfm) have: Last.fm does not support separating artists/album artists fields which means any multi-artist tracks will have all artists combined into one string. This makes scrobbling to other services, and using metadata corrections like [Musicbrainz](/configuration/transforms/musicbrainz), more difficult. + +If your application has the option to scrobble using Listenbrainz then use the [Listenbrainz (Endpoint)](/configuration/sources/listenbrainz-endpoint) Source instead. + + + ## Setup ### URL -- 2.51.2 From 9d2557cf6a1da8c809d725dc523dac880dcda3e4 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 4 Aug 2026 22:14:03 +0000 Subject: [PATCH 8/8] feat(lfm endpoint): Handle scrobble payload variants --- src/backend/common/vendor/LastfmApiClient.ts | 59 ++++++++++++++++++- src/backend/server/endpointLastfmRoutes.ts | 9 +-- src/backend/sources/EndpointLastfmSource.ts | 46 ++++++++------- .../ingressNotifiers/LFMEndpointNotifier.ts | 1 - 4 files changed, 87 insertions(+), 28 deletions(-) diff --git a/src/backend/common/vendor/LastfmApiClient.ts b/src/backend/common/vendor/LastfmApiClient.ts index f21a5721..e8b4292a 100644 --- a/src/backend/common/vendor/LastfmApiClient.ts +++ b/src/backend/common/vendor/LastfmApiClient.ts @@ -816,6 +816,63 @@ export interface LastFMScrobbleRequestPayload extends LastFMScrobblePayload { method: string } +export type LastFMPayloadkey = keyof LastFMScrobbleRequestPayload; +const lfmPayloadKeysRequired: LastFMPayloadkey[] = ['track','artist']; +//const lfmPayloadKeysOptional: LastFMPayloadkey[] = ['duration','album','albumArtist','mbid']; +//const lfmPayloadKeys: LastFMPayloadkey[] = [...lfmPayloadKeysRequired, ...lfmPayloadKeysOptional]; + +export const ingressPayloads = (obj: Record): LastFMScrobbleRequestPayload[] => { + const keys = Object.keys(obj); + let allObject = true; + for(const k of lfmPayloadKeysRequired) { + if(!keys.includes(k)) { + throw new Error(`Missing required key '${k}'`); + } + if(Array.isArray(obj[k])) { + allObject = false; + } else if(allObject === false) { + throw new Error('Payload is an unexpected mix of arrays and objects'); + } + } + const payloads: LastFMScrobbleRequestPayload[] = []; + + if(allObject) { + payloads.push(obj as LastFMScrobbleRequestPayload); + } else { + let index = 0; + for(const t of (obj.track as string[])) { + payloads.push({ + track: t, + artist: obj.artist[index], + timestamp: obj.timestamp !== undefined ? obj.timestamp[index] : dayjs().unix(), + album: obj.album !== undefined ? obj.album[index] : undefined, + mbid: obj.mbid !== undefined ? obj.mbid[index] : undefined, + duration: obj.duration !== undefined ? obj.duration[index] : undefined, + albumArtist: obj.albumArtist !== undefined ? obj.albumArtist[index] : undefined, + method: obj.method as string + }) + index++; + } + } + + return payloads.map(x => { + const cleaned: LastFMScrobbleRequestPayload = x; + if(typeof cleaned.duration === 'string') { + cleaned.duration = Number.parseInt(cleaned.duration); + } + if(isNaN(cleaned.duration) || cleaned.duration <= 0) { + cleaned.duration = undefined; + } + if(typeof cleaned.timestamp === 'string') { + cleaned.timestamp = Number.parseInt(cleaned.timestamp); + } + if(isNaN(cleaned.timestamp)) { + cleaned.timestamp = dayjs().unix(); + } + return cleaned; + }) +} + export const playToScrobbleApiResponseJson = (play: PlayObject) => { const jsonPayload: LastFMTrackScrobbleResponse = { scrobbles: { @@ -885,7 +942,7 @@ export const playToScrobbleApiResponseXml = (play: PlayObject) => { lfm: { $: { status: "ok" }, scrobbles: { - $: {accepted: 2, ignored: 0}, + $: {accepted: 1, ignored: 0}, scrobble: { track: { $: {corrected: 0}, diff --git a/src/backend/server/endpointLastfmRoutes.ts b/src/backend/server/endpointLastfmRoutes.ts index 9f07058d..71773bd6 100644 --- a/src/backend/server/endpointLastfmRoutes.ts +++ b/src/backend/server/endpointLastfmRoutes.ts @@ -101,6 +101,7 @@ export const setupLastfmEndpointRoutes = (app: Express, parentLogger: Logger, sc subscriber: 0 } }; + source.logger.info(`Authenticating with username ${resp.session.name}`); if (wantsJson) { return res.status(200).json(resp); } @@ -113,15 +114,15 @@ export const setupLastfmEndpointRoutes = (app: Express, parentLogger: Logger, sc const playerState = playStateFromRequest(req.body); if (method === 'track.scrobble') { if (wantsJson) { - res.status(200).json(playToScrobbleApiResponseJson(playerState.play)) + res.status(200).json(playToScrobbleApiResponseJson(playerState[0].play)) } else { - res.status(200).setHeader('Content-Type', 'application/xml').send(playToScrobbleApiResponseXml(playerState.play)); + res.status(200).setHeader('Content-Type', 'application/xml').send(playToScrobbleApiResponseXml(playerState[0].play)); } } else { if (wantsJson) { - res.status(200).json(playToNowPlayingApiResponseJson(playerState.play)) + res.status(200).json(playToNowPlayingApiResponseJson(playerState[0].play)) } else { - res.status(200).setHeader('Content-Type', 'application/xml').send(playToNowPlayingApiResponseXml(playerState.play)); + res.status(200).setHeader('Content-Type', 'application/xml').send(playToNowPlayingApiResponseXml(playerState[0].play)); } } await source.handle(playerState) diff --git a/src/backend/sources/EndpointLastfmSource.ts b/src/backend/sources/EndpointLastfmSource.ts index a6ce061a..c363e5c3 100644 --- a/src/backend/sources/EndpointLastfmSource.ts +++ b/src/backend/sources/EndpointLastfmSource.ts @@ -11,7 +11,7 @@ import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts'; import type {PlayPlatformId} from '../../core/Atomic.ts'; import MemorySource from "./MemorySource.ts"; import type {LastFMEndpointSourceConfig} from "../common/infrastructure/config/source/endpointlfm.ts"; -import { type LastFMScrobbleRequestPayload, scrobblePayloadToPlay } from "../common/vendor/LastfmApiClient.ts"; +import { ingressPayloads, type LastFMPayloadkey, type LastFMScrobbleRequestPayload, scrobblePayloadToPlay } from "../common/vendor/LastfmApiClient.ts"; import type {Logger} from "@foxxmd/logging"; import type {PlayerStateOptions} from "./PlayerState/AbstractPlayerState.ts"; import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.ts"; @@ -64,20 +64,23 @@ export class EndpointLastfmSource extends MemorySource { return true; } - handle = async (stateData: PlayerStateData) => { + handle = async (stateData: PlayerStateData[]) => { - if(stateData.play.meta.nowPlaying === true) { - this.setStatus('Received Now Playing'); + if(stateData.length === 1) { + if(stateData[0].play.meta.nowPlaying === true) { + this.setStatus('Received Now Playing'); + } else { + this.setStatus('Received Play'); + } + await this.processRecentPlays(stateData); } else { - this.setStatus('Received Play'); + this.setStatus(`Received ${stateData.length} batch Plays`); } - await this.processRecentPlays([stateData]); - if (stateData.play.meta.nowPlaying === false && this.isValidScrobble(stateData.play)) { - const discovered = await this.discover([stateData.play]); - if (discovered.length > 0) { - await this.scrobble(discovered); - } + const discoverable = stateData.filter(x => x.play.meta.nowPlaying === false); + const discovered = await this.discover(discoverable.map(x => x.play)); + if (discovered.length > 0) { + await this.scrobble(discovered); } this.componentRepo.updateById(this.dbComponent.id, {lastActiveAt: dayjs()}); this.setStatus('Waiting for Plays'); @@ -90,17 +93,16 @@ export class EndpointLastfmSource extends MemorySource { getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts); } -export const playStateFromRequest = (obj: LastFMScrobbleRequestPayload): PlayerStateData => { - - const play = scrobblePayloadToPlay(obj); - play.meta.sourceSOT = SOURCE_SOT.HISTORY; - return { - platformId: [play.meta.deviceId, NO_USER], - play, - status: obj.method === 'track.updateNowPlaying' ? REPORTED_PLAYER_STATUSES.playing : REPORTED_PLAYER_STATUSES.unknown, - stateUpdatedAt: dayjs() - } -} +export const playStateFromRequest = (obj: Record): PlayerStateData[] => ingressPayloads(obj).map(x => { + const play = scrobblePayloadToPlay(x); + play.meta.sourceSOT = SOURCE_SOT.HISTORY; + return { + platformId: [play.meta.deviceId, NO_USER], + play, + status: obj.method === 'track.updateNowPlaying' ? REPORTED_PLAYER_STATUSES.playing : REPORTED_PLAYER_STATUSES.unknown, + stateUpdatedAt: dayjs() + } + }) export const parseSlugFromString = (path: string): string | false | undefined => { const noSlug = parseRegexSingle(noSlugMatch, path); diff --git a/src/backend/sources/ingressNotifiers/LFMEndpointNotifier.ts b/src/backend/sources/ingressNotifiers/LFMEndpointNotifier.ts index 17e93c9f..ec53983a 100644 --- a/src/backend/sources/ingressNotifiers/LFMEndpointNotifier.ts +++ b/src/backend/sources/ingressNotifiers/LFMEndpointNotifier.ts @@ -2,7 +2,6 @@ import type {Logger} from "@foxxmd/logging"; import type {Request} from "express"; import { parseIdentifiersFromRequest } from "../EndpointLastfmSource.ts"; import { IngressNotifier } from "./IngressNotifier.ts"; -import type {LastFMScrobbleRequestPayload} from "../../common/vendor/LastfmApiClient.ts"; export class LFMEndpointNotifier extends IngressNotifier {