[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor: Remove indexed prop from PlayMeta

* Remove indexed any prop so meta is stricter (better catching bugs)
* Implement mergeable generic with meta where we do need extra prop
* Fix bad usage of meta now that indexed prop is gone

FoxxMD (Jun 10, 2026, 1:54 AM UTC) b0955270 a194f752

+80 -77
+4 -1
src/backend/common/database/appMigrations/001_lifecycleLoc.ts
··· 3 3 import { plays as drizzlePlays } from '../drizzle/schema/schema.js'; 4 4 import clone from 'clone'; 5 5 import { eq } from 'drizzle-orm'; 6 + import { PlayLifecycle, PlayObject } from '../../../../core/Atomic.js'; 6 7 7 8 8 9 export const up: Migration<MigrateBaseContext>['up'] = async (db: SqliteDatabase, ctx: MigrateBaseContext): Promise<void> => { ··· 16 17 while (more) { 17 18 const playRows = await ctx.db.select().from(drizzlePlays).limit(100).offset(offset); 18 19 for (const row of playRows) { 20 + const play = row.play as PlayObject<{lifecycle?: PlayLifecycle}>; 19 21 try { 20 22 const { 21 23 meta: { ··· 25 27 scrobble 26 28 } = {} 27 29 } = {} 28 - } = row.play; 30 + } = play; 29 31 if (lifecycle === undefined) { 30 32 // already migrated or unneeded 31 33 processed++; ··· 37 39 if (scrobble !== undefined) { 38 40 row.play.scrobble = clone(scrobble); 39 41 } 42 + // @ts-expect-error 40 43 delete row.play.meta.lifecycle; 41 44 await ctx.db.update(drizzlePlays).set({ play: row.play }).where(eq(drizzlePlays.id, row.id)); 42 45 updated++;
+2 -4
src/backend/common/vendor/LastfmApiClient.ts
··· 311 311 playDate, 312 312 }, 313 313 meta: { 314 - mbid, 315 314 nowPlaying, 316 315 } 317 316 } = formatted; ··· 319 318 // if the track is "now playing" it doesn't get a timestamp so we can't determine when it started playing 320 319 // and don't want to accidentally count the same track at different timestamps by artificially assigning it 'now' as a timestamp 321 320 // so we'll just ignore it in the context of recent tracks since really we only want "tracks that have already finished being played" anyway 322 - this.logger.trace({ track, mbid }, `Ignoring 'now playing' track returned from ${this.upstreamName} client`); 321 + this.logger.trace({ track }, `Ignoring 'now playing' track returned from ${this.upstreamName} client`); 323 322 return acc; 324 323 } else if (playDate === undefined) { 325 324 if(nowPlaying === true) { 326 325 formatted.data.playDate = dayjs(); 327 326 } else { 328 - this.logger.warn({ track, mbid }, `${this.upstreamName} recently scrobbled track did not contain a timestamp, omitting from time frame check`); 327 + this.logger.warn({ track }, `${this.upstreamName} recently scrobbled track did not contain a timestamp, omitting from time frame check`); 329 328 return acc; 330 329 } 331 330 } ··· 706 705 }, 707 706 meta: { 708 707 nowPlaying: nowplaying === 'true', 709 - mbid, 710 708 source, 711 709 url: { 712 710 web: url,
+1 -5
src/backend/common/vendor/ListenbrainzApiClient.ts
··· 701 701 deviceId: combinePartsToString([music_service_name ?? music_service, submission_client, submission_client_version]) 702 702 } 703 703 } 704 - 705 - if(trackId !== undefined) { 706 - play.meta.trackid = trackId; 707 - } 708 - 704 + 709 705 const brainzMeta = removeUndefinedKeys<BrainzMeta>({ 710 706 album: release_mbid, 711 707 releaseGroup: release_group_mbid,
+32 -32
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 17 17 ErrorLike, 18 18 CLIENT_INGRESS_QUEUE, 19 19 CLIENT_DEAD_QUEUE, 20 - PlayOriginal 20 + PlayOriginal, 21 + PlayLifecycle 21 22 } from "../../core/Atomic.js"; 22 23 import { artistNamesToCredits, buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js"; 23 24 import AbstractComponent from "../common/AbstractComponent.js"; ··· 87 88 type NowPlayingQueue = Map<string, PlatformMappedPlays>; 88 89 89 90 const platformTruncate = truncateStringToLength(10); 90 - 91 91 92 92 export default abstract class AbstractScrobbleClient extends AbstractComponent implements Authenticatable { 93 93 ··· 567 567 if (shouldMigrate) { 568 568 const migrationEntry: ComponentMigrationNew = migration !== undefined ? migration : {componentId: this.dbComponent.id, name: 'cachedScrobbles'}; 569 569 try { 570 - const cachedQueue = (await this.cache.cacheScrobble.get(`${this.getMachineId()}-queue`) as QueuedScrobble<PlayObject>[] ?? []); 570 + const cachedQueue = (await this.cache.cacheScrobble.get(`${this.getMachineId()}-queue`) as QueuedScrobble<PlayObject<{migrated?: boolean, lifecycle?: PlayLifecycle}>>[] ?? []); 571 571 const migratedQueue: QueuedScrobble<PlayObject>[] = []; 572 572 let allGood = true; 573 573 if (cachedQueue.length > 0) { ··· 577 577 logger.debug(`Skipping already migrated play => ${buildTrackString(cachedQueuedScrobble.play)}`); 578 578 continue; 579 579 } 580 - const play = asPlay(cachedQueuedScrobble.play); 580 + const play = asPlay(cachedQueuedScrobble.play) as PlayObject<{migrated?: boolean, lifecycle?: PlayLifecycle}>; 581 581 const { 582 582 meta: { 583 - lifecycle = {}, 583 + lifecycle, 584 584 ...metaRest 585 585 }, 586 586 data: { ··· 599 599 ...dataRest 600 600 }, 601 601 meta: metaRest, 602 - // @ts-expect-error 603 - lifecycle: lifecycle.steps 602 + 603 + lifecycle: lifecycle?.steps 604 604 } 605 - if('scrobble' in lifecycle) { 606 - updatedPlay.scrobble = lifecycle.scrobble; 607 - } 608 - if('input' in lifecycle || 'original' in lifecycle) { 609 - updatedPlay.original = removeUndefinedKeys<PlayOriginal>({ 610 - // @ts-expect-error 611 - data: lifecycle.input, 612 - // @ts-expect-error 613 - play: lifecycle.original 614 - }) 605 + if(lifecycle !== undefined) { 606 + if('scrobble' in lifecycle) { 607 + updatedPlay.scrobble = lifecycle.scrobble; 608 + } 609 + if('input' in lifecycle || 'original' in lifecycle) { 610 + updatedPlay.original = removeUndefinedKeys<PlayOriginal>({ 611 + 612 + data: lifecycle.input, 613 + play: lifecycle.original 614 + }) 615 + } 615 616 } 616 617 // return play object without going through transform since it was (presumably) already transformed before being cached 617 618 const res = await this.queueScrobble(updatedPlay, updatedPlay.meta.source, async (x) => x); ··· 632 633 logger.info('No scrobbles to migrate'); 633 634 } 634 635 635 - const cachedDead = (await this.cache.cacheScrobble.get(`${this.getMachineId()}-dead`) as DeadLetterScrobble<PlayObject>[] ?? []); 636 + const cachedDead = (await this.cache.cacheScrobble.get(`${this.getMachineId()}-dead`) as DeadLetterScrobble<PlayObject<{migrated?: boolean, lifecycle?: PlayLifecycle}>>[] ?? []); 636 637 const migratedDead: DeadLetterScrobble<PlayObject>[] = []; 637 638 if (cachedDead.length > 0) { 638 639 logger.info('Migrating failed scrobbles to database...'); ··· 642 643 logger.debug(`Skipping already migrated play => ${buildTrackString(cDeadScrobble.play)}`) 643 644 continue; 644 645 } 645 - const play = asPlay(cDeadScrobble.play); 646 + const play = asPlay(cDeadScrobble.play) as PlayObject<{migrated?: boolean, lifecycle?: PlayLifecycle}>; 646 647 const { 647 648 meta: { 648 - lifecycle = {}, 649 + lifecycle, 649 650 ...metaRest 650 651 }, 651 652 data: { ··· 663 664 ...dataRest 664 665 }, 665 666 meta: metaRest, 666 - // @ts-expect-error 667 - lifecycle: lifecycle.steps 667 + lifecycle: lifecycle?.steps 668 668 } 669 - if('scrobble' in lifecycle) { 670 - updatedDeadPlay.scrobble = lifecycle.scrobble; 671 - } 672 - if('input' in lifecycle || 'original' in lifecycle) { 673 - updatedDeadPlay.original = removeUndefinedKeys<PlayOriginal>({ 674 - // @ts-expect-error 675 - data: lifecycle.input, 676 - // @ts-expect-error 677 - play: lifecycle.original 678 - }) 669 + if(lifecycle !== undefined) { 670 + if('scrobble' in lifecycle) { 671 + updatedDeadPlay.scrobble = lifecycle.scrobble; 672 + } 673 + if('input' in lifecycle || 'original' in lifecycle) { 674 + updatedDeadPlay.original = removeUndefinedKeys<PlayOriginal>({ 675 + data: lifecycle.input, 676 + play: lifecycle.original 677 + }) 678 + } 679 679 } 680 680 try { 681 681 const res = await this.playRepo.createPlays([
+4 -2
src/backend/sources/MPDSource.ts
··· 261 261 albumArtists: albumArtists !== undefined ? artistNamesToCredits(albumArtists) : [], 262 262 album, 263 263 track: trackName, 264 - duration 264 + duration, 265 + meta: { 266 + brainz 267 + } 265 268 }, 266 269 meta: { 267 - brainz, 268 270 trackProgressPosition: position, 269 271 mediaPlayerName: 'mpd' 270 272 }
+3 -3
src/backend/sources/PlayerState/JellyfinPlayerState.ts
··· 12 12 13 13 update(state: PlayerStateDataMaybePlay) { 14 14 let stat: ReportedPlayerStatus = state.status; 15 - if(stat === undefined && state.play?.meta?.event === 'PlaybackProgress') { 16 - stat = 'playing'; 17 - } 15 + // if(stat === undefined && state.play?.meta?.event === 'PlaybackProgress') { 16 + // stat = 'playing'; 17 + // } 18 18 return super.update({...state, status: stat}); 19 19 } 20 20 }
+10 -4
src/backend/sources/WebScrobblerSource.ts
··· 1 - import dayjs from "dayjs"; 1 + import dayjs, { Dayjs } from "dayjs"; 2 2 import EventEmitter from "events"; 3 3 import { PlayObject, PlayObjectMinimal, SOURCE_SOT } from "../../core/Atomic.js"; 4 4 import { ··· 25 25 import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 26 26 import { artistCreditToName, artistNameToCredit } from "../../core/StringUtils.js"; 27 27 28 + interface WebScrobbleMeta { 29 + scrobbleAllowed?: boolean 30 + } 31 + 32 + type WebScrobblerPlayObject = PlayObjectMinimal<Dayjs, WebScrobbleMeta>; 33 + 28 34 export class WebScrobblerSource extends MemorySource { 29 35 30 36 declare config: WebScrobblerSourceConfig; ··· 97 103 98 104 static formatPlayObj(obj: WebScrobblerSong, options: FormatPlayObjectOptions & { 99 105 nowPlaying?: boolean 100 - } = {}): PlayObject { 106 + } = {}): WebScrobblerPlayObject { 101 107 const { 102 108 connectorLabel, 103 109 connector: { ··· 125 131 const albumArtist = processed.albumArtist ?? parsed.albumArtist; 126 132 const duration = parsed.duration ?? processed.duration; 127 133 128 - const play: PlayObjectMinimal = { 134 + const play: PlayObjectMinimal<Dayjs, WebScrobbleMeta> = { 129 135 data: { 130 136 track, 131 137 artists: [artistNameToCredit(artist)], ··· 158 164 159 165 getRecentlyPlayed = async (options = {}) => await this.getFlatRecentlyDiscoveredPlays() 160 166 161 - isValidScrobble = (playObj: PlayObject) => { 167 + isValidScrobble = (playObj: WebScrobblerPlayObject) => { 162 168 if (playObj.meta?.scrobbleAllowed === false) { 163 169 this.logger.debug(`Will not scrobble play because it was marked as 'Do Not Scrobble' by extension`); 164 170 return false;
+1 -2
src/backend/tests/jellyfin/jellyfin.test.ts
··· 18 18 // @ts-expect-error weird typings? 19 19 import { getImageApi } from "@jellyfin/sdk/lib/utils/api/index.js"; 20 20 import { PlayerStateDataMaybePlay } from "../../common/infrastructure/Atomic.js"; 21 - import { MarkOptional } from "ts-essentials"; 22 21 23 22 const dataAsFixture = (data: any): TestFixture => { 24 23 return data as TestFixture; ··· 44 43 platformId: ['1234', 'MyUser'], 45 44 play: generatePlay({}, {mediaType: 'Audio', user: 'MyUser', deviceId: '1234'}) 46 45 } 47 - const playWithMeta = (meta: MarkOptional<PlayMeta, 'lifecycle'>): PlayerStateDataMaybePlay => { 46 + const playWithMeta = (meta: PlayMeta): PlayerStateDataMaybePlay => { 48 47 const {user, deviceId} = meta; 49 48 const platformId = validPlayerState.platformId; 50 49 return {
+2 -3
src/backend/tests/plex/plex.test.ts
··· 2 2 import { assert, expect } from 'chai'; 3 3 import EventEmitter from "events"; 4 4 import { describe, it } from 'mocha'; 5 - import { JsonPlayObject, PlayMeta, PlayObject } from "../../../core/Atomic.js"; 5 + import { PlayMeta } from "../../../core/Atomic.js"; 6 6 7 7 import validSessionResponse from './validSession.json' with { type: "json" }; 8 8 import { generatePlay } from "../../../core/PlayTestUtils.js"; ··· 10 10 import { PlexApiData } from "../../common/infrastructure/config/source/plex.js"; 11 11 import PlexApiSource from "../../sources/PlexApiSource.js"; 12 12 import { GetSessionsMetadata } from "@lukehagar/plexjs/sdk/models/operations/getsessions.js"; 13 - import { MarkOptional } from "ts-essentials"; 14 13 15 14 const validSession = validSessionResponse.object.mediaContainer.metadata[0]; 16 15 ··· 34 33 platformId: ['1234', 'MyUser'], 35 34 play: generatePlay({}, {mediaType: 'track', user: 'MyUser', deviceId: '1234', library: 'Music'}) 36 35 } 37 - const playWithMeta = (meta: MarkOptional<PlayMeta, 'lifecycle'>): PlayerStateDataMaybePlay => { 36 + const playWithMeta = (meta: PlayMeta): PlayerStateDataMaybePlay => { 38 37 const {user, deviceId} = meta; 39 38 const platformId = validPlayerState.platformId; 40 39 return {
+16 -15
src/core/Atomic.ts
··· 1 1 import { LogDataPretty, LogLevel } from "@foxxmd/logging"; 2 2 import { Dayjs } from "dayjs"; 3 - import { ListenProgress } from "../backend/sources/PlayerState/ListenProgress.js"; 4 3 import { AdditionalTrackInfoResponse } from "../backend/common/vendor/listenbrainz/interfaces.js"; 5 - import { MarkOptional, RequiredKeys } from "ts-essentials"; 4 + import { Merge, RequiredKeys } from "ts-essentials"; 6 5 import { ErrorObject } from "serialize-error"; 7 6 import { PlayPlatformIdStr } from "../backend/common/infrastructure/Atomic.js"; 8 7 import { FlowControlTerm, TransformHook } from "../backend/common/infrastructure/Transform.js"; ··· 201 200 artist?: string 202 201 } 203 202 204 - export interface PlayMeta<D extends DateLike = Dayjs> { 203 + export type PlayMeta<D extends DateLike = Dayjs, T = {}> = Merge<PlayMetaBase<D>, T>; 204 + 205 + export interface PlayMetaBase<D extends DateLike = Dayjs> { 205 206 source?: string 206 207 sourceSOT?: SOURCE_SOT_TYPES 207 208 ··· 285 286 //lifecycle: PlayLifecycle<D> 286 287 lifecycleInputs?: LifecycleInput[] 287 288 288 - [key: string]: any 289 + //[key: string]: any 289 290 } 290 291 291 292 export interface LifecycleInput { ··· 304 305 mergedScrobble?: AmbPlayObjectMinimal<D> 305 306 } 306 307 307 - // export interface PlayLifecycle<D extends DateLike = Dayjs> { 308 - // //input?: object 309 - // //original?: PlayObjectLifecycleless<D> 310 - // //steps: LifecycleStep[] 311 - // //scrobble?: ScrobbleResult<D> 312 - // } 308 + export interface PlayLifecycle<D extends DateLike = Dayjs> { 309 + input?: object 310 + original?: PlayObjectMinimal<D> 311 + steps: LifecycleStep[] 312 + scrobble?: ScrobbleResult<D> 313 + } 313 314 314 315 export interface LifecycleStep { 315 316 stageName: string ··· 361 362 play?: PlayObjectMinimal<D> 362 363 } 363 364 364 - export interface AmbPlayObject<D extends DateLike = Dayjs> { 365 + export interface AmbPlayObject<D extends DateLike = Dayjs, T = {}> { 365 366 id?: number 366 367 uid?: string 367 368 data: PlayData<D>, 368 - meta: PlayMeta<D> 369 + meta: PlayMeta<D,T> 369 370 original?: PlayOriginal<D> 370 371 scrobble?: ScrobbleResult<D> 371 372 lifecycle?: LifecycleStep[] 372 373 } 373 374 374 - export type AmbPlayObjectMinimal<D extends DateLike = Dayjs> = Pick<AmbPlayObject<D>, RequiredKeys<AmbPlayObject<D>>>; 375 + export type AmbPlayObjectMinimal<D extends DateLike = Dayjs, T = {}> = Pick<AmbPlayObject<D,T>, RequiredKeys<AmbPlayObject<D>>>; 375 376 376 377 export const isPlayObject = (obj: object): obj is PlayObject => { 377 378 return obj !== undefined && obj !== null && 'data' in obj && typeof obj.data === 'object' && 'meta' in obj && typeof obj.meta === 'object'; 378 379 } 379 380 380 - export type PlayObject = AmbPlayObject<Dayjs>; 381 - export type PlayObjectMinimal<D extends DateLike = Dayjs> = AmbPlayObjectMinimal<D>; 381 + export type PlayObject<T = {}> = AmbPlayObject<Dayjs,T>; 382 + export type PlayObjectMinimal<D extends DateLike = Dayjs, T = {}> = AmbPlayObjectMinimal<D,T>; 382 383 export interface PlayActivity { 383 384 play: JsonPlayObject 384 385 status: string
+4 -5
src/core/PlayTestUtils.ts
··· 1 - import { faker, fakerEL, FakerError } from '@faker-js/faker'; 1 + import { faker } from '@faker-js/faker'; 2 2 import dayjs, { Dayjs } from "dayjs"; 3 3 import duration from "dayjs/plugin/duration.js"; 4 4 import isBetween from "dayjs/plugin/isBetween.js"; ··· 14 14 import { ListRecord } from '../backend/common/infrastructure/config/client/tealfm.js'; 15 15 import { nanoid } from 'nanoid'; 16 16 import { LastFMTrackObject } from '../backend/common/vendor/LastfmApiClient.js'; 17 - import { MarkOptional } from 'ts-essentials'; 18 17 import clone from 'clone'; 19 18 import { removeUndefinedKeys } from '../backend/utils.js'; 20 19 import { FmTealAlphaFeedPlay } from '../backend/common/vendor/teal/lexicons/index.js'; ··· 32 31 endDate?: Dayjs 33 32 defaultDuration?: number, 34 33 defaultData?: ObjectPlayData, 35 - defaultMeta?: MarkOptional<PlayMeta, 'lifecycle'> 34 + defaultMeta?: PlayMeta 36 35 } 37 36 ): PlayObject[] => { 38 37 const { ··· 165 164 playDateCompleted?: boolean, 166 165 listenRanges?: boolean 167 166 } 168 - export const generatePlay = (data: ObjectPlayData = {}, meta: MarkOptional<PlayMeta, 'lifecycle'> = {}, opts: GeneratePlayOpts = {}): PlayObject => { 167 + export const generatePlay = (data: ObjectPlayData = {}, meta: PlayMeta = {}, opts: GeneratePlayOpts = {}): PlayObject => { 169 168 const { 170 169 playDateCompleted = false, 171 170 listenRanges = false, ··· 292 291 return [did, uid]; 293 292 } 294 293 295 - export const generatePlays = (numberOfPlays: number, data: ObjectPlayData = {}, meta: MarkOptional<PlayMeta, 'lifecycle'> = {}, opts: GeneratePlayOpts = {}): PlayObject[] => { 294 + export const generatePlays = (numberOfPlays: number, data: ObjectPlayData = {}, meta: PlayMeta = {}, opts: GeneratePlayOpts = {}): PlayObject[] => { 296 295 return Array.from(Array(numberOfPlays), () => generatePlay(data, meta, opts)); 297 296 } 298 297
+1 -1
src/core/tests/utils/fixtures.ts
··· 21 21 export interface GeneratePlayWithLifecycleOptions { 22 22 original?: { 23 23 data?: ObjectPlayData, 24 - meta?: MarkOptional<PlayMeta, 'lifecycle'> 24 + meta?: PlayMeta 25 25 opts?: GeneratePlayOpts 26 26 }, 27 27 lifecycleSteps?: {