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 {