From 7ca84ce27f25b8a257d29f4aa8b8753848054237 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 4 Aug 2026 14:59:05 +0000 Subject: [PATCH] feat(lfm endpoint): Implement more api emulation #656 * Add optional username/apiKey config fields * Identify Source by optional username/sk/api_key * Emulate auth.getMobileSession response with sk derived from Source uid * Update docs with guidance for multiple sources and improved setup language --- .../sources/_env_configs/_endpointlfm.md | 4 +- .../sources/_env_configs/_tealfm.md | 14 +- .../configuration/sources/lastfm-endpoint.mdx | 43 ++++-- .../config/source/endpointlfm.ts | 12 +- src/backend/common/vendor/LastfmApiClient.ts | 134 +++++++++++++++++- src/backend/server/endpointLastfmRoutes.ts | 77 ++++++++-- src/backend/sources/EndpointLastfmSource.ts | 5 +- 7 files changed, 258 insertions(+), 31 deletions(-) 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