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
This commit is contained in:
FoxxMD
2026-08-04 14:59:05 +00:00
parent 8af56cb561
commit 7ca84ce27f
7 changed files with 260 additions and 33 deletions
@@ -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 |
| `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 |
@@ -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 | | |
| 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 |
@@ -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:
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.
<DetailsAdmo type="important" summary="Supported Auth Types">
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.
</DetailsAdmo>
#### Multiple Sources
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.
<DetailsAdmo type="tip" summary="Different URL Base (Slug)">
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/2.0/
slug: "mySlug"
```
```
http://yourMsDomain/api/lastfm/mySlug
```
which is the "standard" Last.fm server path for scrobbling
The above url base is equivalent to making calls to `http://yourMSDomain/2.0/`
Use a slug only if you need to setup multiple Last.fm Endpoint sources and cannot use different tokens.
If a slug is used then the URL will be:
```
http://localhost:9078/api/lastfm/mySlug
```
</DetailsAdmo>
## Configuration
@@ -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<typeof lastFmEndpointDataSchema>;
const envDataSchema = z.object({
LFM_SLUG: lastFmEndpointDataSchema.shape.slug,
LFM_USERNAME: lastFmEndpointDataSchema.shape.username,
LFM_API_KEY: lastFmEndpointDataSchema.shape.apiKey
});
export const envSchemas: EnvSourceSchema<typeof envDataSchema, LastFMEndpointSourceConfig> = {
@@ -31,7 +39,9 @@ export const envSchemas: EnvSourceSchema<typeof envDataSchema, LastFMEndpointSou
prefix: 'LFM',
toConfig: (partial) => ({
data: {
slug: partial.LFM_SLUG
slug: partial.LFM_SLUG,
username: partial.LFM_USERNAME,
apiKey: partial.LFM_API_KEY
}
})
};
+133 -1
View File
@@ -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;
}
+68 -9
View File
@@ -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);
}
});
}
}
+1 -4
View File
@@ -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 {