From 1988fc5f5491daeabe4583397419596732fcb6b4 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 26 Aug 2026 01:48:50 +0000 Subject: [PATCH] mostly figured out lfm validation... --- src/backend/common/vendor/LastfmApiClient.ts | 74 ++++++++++++++------ src/backend/server/endpointLastfmRoutes.ts | 57 ++++++++------- src/backend/server/middleware.ts | 1 - src/backend/sources/EndpointLastfmSource.ts | 16 +++-- src/backend/tests/ingress/ingress.test.ts | 68 +++++++++++++++++- 5 files changed, 163 insertions(+), 53 deletions(-) diff --git a/src/backend/common/vendor/LastfmApiClient.ts b/src/backend/common/vendor/LastfmApiClient.ts index bc8b1ec2..b1158ed2 100644 --- a/src/backend/common/vendor/LastfmApiClient.ts +++ b/src/backend/common/vendor/LastfmApiClient.ts @@ -570,7 +570,7 @@ export class LastFMIgnoredScrobble extends UpstreamError { } -export const scrobblePayloadToPlay = (obj: LastFMScrobbleRequestPayload): PlayObject => { +export const scrobblePayloadToPlay = (obj: LastFmSingleSubmitPayload): PlayObject => { const { artist, track, @@ -841,33 +841,67 @@ export interface LastFMScrobblePayload { mbid?: string } -export const lastfmScrobblePayloadSchema = z.object({ +export const lastfmSubmitPayloadSchema = z.object({ artist: z.string(), track: z.string(), - timestmap: z.number(), - duration: z.number().optional(), album: z.string().optional(), albumArtist: z.string().optional(), - mbid: z.string().optional() + mbid: z.string().optional(), + duration: z.int().positive().optional(), + sk: z.string().optional(), + api_key: z.string().optional() }); -export const lastfmRequestPayloadSchema = z.looseObject({ - //method: z.union([z.enum(['track.updateNowPlaying','track.scrobble']), z.string()]), +export const lastfmSubmitMultiPayloadSchema = z.object({ + artist: z.array(z.string()), + track: z.array(z.string()), + album: z.array(z.string()).optional(), + albumArtist: z.array(z.string()).optional(), + mbid: z.array(z.string()).optional(), + duration: z.array(z.int().positive()).optional(), sk: z.string().optional(), - api_key: z.string() + api_key: z.string().optional() +}); + +export const lastfmScrobblePayloadSchema = z.object({ + method: z.literal('track.scrobble'), + ...lastfmSubmitPayloadSchema.shape, + timestamp: z.int().positive(), }); +export type LastfmScrobblePayload = z.infer; +export const lastfmScrobbleMultiPayloadSchema = z.object({ + method: z.literal('track.scrobble'), + ...lastfmSubmitMultiPayloadSchema.shape, + timestamp: z.array(z.int().positive()), +}); +export const lastfmScrobbleXorPayloadSchema = z.union([lastfmScrobblePayloadSchema,lastfmScrobbleMultiPayloadSchema]); +export type LastfmScrobbleMaybeMultiPayload = z.infer; + +export type LastFmScrobblePayload = z.infer; + +export const lastfmNowPlayingPayloadSchema = z.object({ + method: z.literal('track.updateNowPlaying'), + ...lastfmSubmitPayloadSchema.shape, +}); +export type LastFmNowPlayingPayload = z.infer; + +export type LastFmSubmitPayload = LastfmScrobbleMaybeMultiPayload | LastFmNowPlayingPayload; +export type LastFmSingleSubmitPayload = LastfmScrobblePayload | LastFmNowPlayingPayload; export const lastfmAuthRequestPayloadSchema = z.object({ - method: z.union([z.literal('auth.getMobileSession'), z.string()]), + method: z.literal('auth.getMobileSession'), username: z.string().optional(), - api_key: z.string(), + password: z.string().optional(), + api_key: z.string().optional(), }); -export const lastfmScrobbleRequestSchema = z.object({ - method: z.union([z.enum(['track.updateNowPlaying','track.scrobble']), z.string()]), - ...lastfmScrobblePayloadSchema.shape, - ...lastfmRequestPayloadSchema.shape -}) +export const lastfmRequestSchema = z.union([ + lastfmScrobbleXorPayloadSchema, + z.discriminatedUnion("method", [ + lastfmNowPlayingPayloadSchema, + lastfmAuthRequestPayloadSchema + ]) +]); export interface LastFMScrobbleRequestPayload extends LastFMScrobblePayload { method: string @@ -878,7 +912,7 @@ const lfmPayloadKeysRequired: LastFMPayloadkey[] = ['track','artist']; //const lfmPayloadKeysOptional: LastFMPayloadkey[] = ['duration','album','albumArtist','mbid']; //const lfmPayloadKeys: LastFMPayloadkey[] = [...lfmPayloadKeysRequired, ...lfmPayloadKeysOptional]; -export const ingressPayloads = (obj: Record): LastFMScrobbleRequestPayload[] => { +export const ingressPayloads = (obj: LastfmScrobbleMaybeMultiPayload): LastfmScrobblePayload[] => { const keys = Object.keys(obj); let allObject = true; for(const k of lfmPayloadKeysRequired) { @@ -891,10 +925,10 @@ export const ingressPayloads = (obj: Record): LastFMS throw new Error('Payload is an unexpected mix of arrays and objects'); } } - const payloads: LastFMScrobbleRequestPayload[] = []; + const payloads: LastfmScrobblePayload[] = []; if(allObject) { - payloads.push(obj as LastFMScrobbleRequestPayload); + payloads.push(obj as LastfmScrobblePayload); } else { let index = 0; for(const t of (obj.track as string[])) { @@ -906,14 +940,14 @@ export const ingressPayloads = (obj: Record): LastFMS 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 + method: obj.method }) index++; } } return payloads.map(x => { - const cleaned: LastFMScrobbleRequestPayload = x; + const cleaned: LastfmScrobblePayload = x; if(typeof cleaned.duration === 'string') { cleaned.duration = Number.parseInt(cleaned.duration); } diff --git a/src/backend/server/endpointLastfmRoutes.ts b/src/backend/server/endpointLastfmRoutes.ts index 3268c3a2..b05113f6 100644 --- a/src/backend/server/endpointLastfmRoutes.ts +++ b/src/backend/server/endpointLastfmRoutes.ts @@ -8,7 +8,7 @@ import { nonEmptyBody } from "./middleware.ts"; import { LFMEndpointNotifier } from "../sources/ingressNotifiers/LFMEndpointNotifier.ts"; import type { EndpointLastfmSource} from "../sources/EndpointLastfmSource.ts"; import { playStateFromRequest, requestMatchers } from "../sources/EndpointLastfmSource.ts"; -import {lastfmAuthRequestPayloadSchema, lastfmScrobbleRequestSchema, playToNowPlayingApiResponseJson, playToNowPlayingApiResponseXml, playToScrobbleApiResponseJson, playToScrobbleApiResponseXml} from "../common/vendor/LastfmApiClient.ts"; +import {lastfmRequestSchema, playToNowPlayingApiResponseJson, playToNowPlayingApiResponseXml, playToScrobbleApiResponseJson, playToScrobbleApiResponseXml} from "../common/vendor/LastfmApiClient.ts"; import xml2js from 'xml2js'; import crypto from 'node:crypto'; import type { createTypedRouter, TypedMiddleware, InferSchemaHandler } from "@minisylar/express-typed-router"; @@ -17,7 +17,7 @@ import * as z from 'zod'; const unmatchIdentifierWarn: string[] = []; -const looseFmBody = z.union([lastfmScrobbleRequestSchema, lastfmAuthRequestPayloadSchema]); +const looseFmBody = lastfmRequestSchema; type LooseFmBody = typeof looseFmBody; const looseQuery = z.looseObject({format: z.string().optional()}); type LooseQuery = typeof looseQuery; @@ -66,7 +66,6 @@ export const setupLastfmEndpointRoutes = (app: Express, router: ReturnType 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(`sk` in req.body && req.body.sk !== undefined) { - // @ts-expect-error need TS to narrow this more intelligently - 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); + } else { + if (req.body.method === 'auth.getMobileSession') { + if (`username` in req.body && req.body.username !== undefined) { + const u = req.body.username; + source = validSources.find(x => x.config.data?.username === u); + 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(source === undefined && `sk` in req.body && req.body.sk !== undefined) { + const sk = req.body.sk; + source = validSources.find(x => crypto.createHash('md5').update(x.getUid()).digest('hex') === 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); + } } } @@ -111,19 +114,20 @@ export const setupLastfmEndpointRoutes = (app: Express, router: ReturnType, bodySchema: looseFmBody, querySchema: looseQuery, @@ -170,7 +173,7 @@ export const setupLastfmEndpointRoutes = (app: Express, router: ReturnType new NowPlayingPlayerState(logger, id, opts); } -export const playStateFromRequest = (obj: Record): PlayerStateData[] => ingressPayloads(obj).map(x => { +export const playStateFromRequest = (obj: LastFmSubmitPayload): PlayerStateData[] => { + let payloads: LastFmSingleSubmitPayload[]; + if(obj.method === 'track.updateNowPlaying') { + payloads = [obj]; + } else { + payloads = ingressPayloads(obj); + } + return payloads.map(x => { const play = scrobblePayloadToPlay(x); play.meta.sourceSOT = SOURCE_SOT.INGRESS; return { @@ -107,7 +114,8 @@ export const playStateFromRequest = (obj: Record): Pl 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/tests/ingress/ingress.test.ts b/src/backend/tests/ingress/ingress.test.ts index 61176b14..8dcc63d3 100644 --- a/src/backend/tests/ingress/ingress.test.ts +++ b/src/backend/tests/ingress/ingress.test.ts @@ -4,7 +4,7 @@ import { describe, it } from 'mocha'; import request from 'supertest'; import ScrobbleSources from '../../sources/ScrobbleSources.ts'; import { WildcardEmitter } from '../../common/WildcardEmitter.ts'; -import { loggerTest } from '@foxxmd/logging'; +import { loggerDebug, loggerTest } from '@foxxmd/logging'; import type { ListenbrainzEndpointSourceConfig } from '../../common/infrastructure/config/source/endpointlz.ts'; import { initServer } from '../../server/index.ts'; import ScrobbleClients from '../../scrobblers/ScrobbleClients.ts'; @@ -17,6 +17,9 @@ import dayjs from 'dayjs'; import { faker } from '@faker-js/faker'; import * as z from 'zod'; import pEvent from 'p-event'; +import type { LastFMEndpointSourceConfig } from '../../common/infrastructure/config/source/endpointlfm.ts'; +import { lastfmScrobblePayloadSchema } from '../../common/vendor/LastfmApiClient.ts'; +import { removeUndefinedKeys } from '../../../core/DataUtils.ts'; chai.use(asPromised); @@ -34,6 +37,13 @@ const defaultWebscrobblerConfig: WebScrobblerSourceConfig & {source: string} = { data: {}, source: 'file' }; +const defaultLfmConfig: LastFMEndpointSourceConfig & {source: string} = { + id: 'test', + enable: true, + data: {}, + source: 'file' +}; + const generateSources = () => new ScrobbleSources(new WildcardEmitter(), internalConfig, loggerTest); describe('Listenbrainz Endpoint', function() { @@ -191,4 +201,60 @@ describe('Webscrobbler Endpoint', function() { throw e; } }); +}); + +describe('Last.fm Endpoint', function () { + + let clients: ScrobbleClients; + before(function () { + clients = new ScrobbleClients(new WildcardEmitter(), new WildcardEmitter(), internalConfig, loggerTest); + }); + + describe('Accepts requests on slug endpoints', function () { + it('accepts request to /api/lastfm with no slug', async function () { + const sources = generateSources(); + await sources.addSource('endpointlfm', [defaultLfmConfig]); + const source = sources.sources[0]; + source.queueIdleMs = 2; + await source.initialize(); + const [app] = await initServer({ sources, clients }, { testMode: true, logger: loggerDebug }); + + const payload = removeUndefinedKeys(zocker(lastfmScrobblePayloadSchema) + .override(z.ZodString, () => faker.word.words({ count: { min: 1, max: 5 } })) + .supply(lastfmScrobblePayloadSchema.shape.duration, faker.number.int({ min: 30, max: 400 })) + .supply(lastfmScrobblePayloadSchema.shape.timestamp, dayjs(faker.date.recent()).unix()) + .generate()); + //payload.method = 'track.scrobble'; + + try { + const [response] = await Promise.all([ + request(app).post('/api/lastfm') + //.set('Content-Type', 'x-www-form-urlencoded') + // @ts-expect-error its fine + .send(new URLSearchParams(payload).toString()), + //pEvent(source.emitter, 'playInsert', { timeout: 10000 }) + ]); + expect(response.status).eq(200); + expect(source.getApiData().queued, JSON.stringify(payload)).eq(1); + } catch (e) { + console.log(JSON.stringify(payload)); + throw e; + } + }); + }); + + it('accepts request to /2.0/ for auth.getMobileSession', async function () { + const sources = generateSources(); + await sources.addSource('endpointlfm', [defaultLfmConfig]); + const source = sources.sources[0]; + source.queueIdleMs = 2; + await source.initialize(); + const [app] = await initServer({ sources, clients }, { testMode: true }); + + const response = await request(app).post('/api/lastfm') + .set('Accept', 'application/json') + .send(new URLSearchParams({username: 'atest', password: 'anything', api_key: '1234', api_sig: '5678', method: 'auth.getMobileSession'}).toString()); + expect(response.status).eq(200); + expect(response.body.session.key).exist; + }); }); \ No newline at end of file -- 2.51.2