Something went wrong. Try again.
[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
Something went wrong. Try again.
20 kB · 522 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522import { faker } from "@faker-js/faker";import type {ComponentClientApi, ComponentClientApiJson, ComponentCommonApi, ComponentCommonApiJson, ComponentHistoricalApi, ComponentSourceApi, ComponentSourceApiJson, ComponentState, PlayApiCommon, PlayApiCommonDetailed, PlayInputApi, QueueStateApi} from "../../Api.ts";import { INGRESS_QUEUE, COMPONENT_AUTH_TYPE, type ComponentType, type JsonPlayObject, type PlayObject, QUEUE_STATUSES, type SourcePlayerJson, sourceSotTypes } from "../../Atomic.ts";import { generatePlay, normalizePlays } from "./PlayTestUtils.ts";import { generatePlayInput, generatePlayWithLifecycle, playWithLifecycleScrobble, randomPlayState } from "./fixtures.ts";import { asJsonPlayObject } from "../../PlayMarshalUtils.ts";import { generatePlayUid } from "../../StringUtils.ts";import dayjs, { type Dayjs } from "dayjs";import { isSourceType } from "../../Atomic.ts";import { sourceTypes } from "../../Atomic.ts";import { clientTypes } from "../../Atomic.ts";import { isClientType } from '../../Atomic.ts';import { CALCULATED_PLAYER_STATUSES } from '../../Atomic.ts';import { REPORTED_PLAYER_STATUSES } from '../../Atomic.ts';import { generateArray } from "../../DataUtils.ts";import type {ErrorIsh} from "../../ErrorUtils.ts";import { serializeError } from "serialize-error";
export const generatePlayApiCommon = (commonData: Partial<PlayApiCommon> & {play?: JsonPlayObject | PlayObject } = {}, ...playOpts: Parameters<typeof generatePlay>): PlayApiCommon => { let play: JsonPlayObject | PlayObject; const { play: cPlay, ...rest } = commonData; if(cPlay !== undefined) { play = cPlay } else { play = generatePlay(...playOpts); }
const { playedAt = typeof play.data.playDate === 'string' ? play.data.playDate : play.data.playDate.toISOString(), seenAt = playedAt, updatedAt = dayjs().toISOString(), compacted = false, state = randomPlayState(), componentId = faker.number.int({min: 1, max: 10}), uid = generatePlayUid() } = commonData;
return { play: asJsonPlayObject(play), ...rest, playedAt, seenAt, updatedAt, compacted, state, componentId, uid }}
export const generatePlayInputApi = (inputData: Partial<PlayInputApi> = {}, ...args: Parameters<typeof generatePlayInput>): PlayInputApi => { const res = generatePlayInput(...args); let createdAt: string = dayjs().toISOString(); if(res.play?.data?.playDate !== undefined) { if(typeof res.play?.data?.playDate === 'string') { createdAt = res.play?.data?.playDate; } else { createdAt = res.play?.data?.playDate.toISOString(); } } return { id: faker.number.int({min: 1, max: 100}), createdAt, data: res.data, play: res.play !== undefined ? asJsonPlayObject(res.play) : undefined, ...inputData, }}
export const generateQueueStateApi = (data: Partial<QueueStateApi>): QueueStateApi => { const cAt = faker.date.recent().toISOString(); return { id: faker.number.int({min: 1, max: 100}), queueName: INGRESS_QUEUE, queueStatus: faker.helpers.arrayElement(QUEUE_STATUSES), updatedAt: cAt, retries: 0, createdAt: cAt, ...data }}
export const generatePlayApiCommonDetailed = (opts: { playOpts?: Parameters<typeof generatePlayApiCommon>, inputOpts?: Parameters<typeof generatePlayInputApi>, queueOpts?: Parameters<typeof generateQueueStateApi>} = {}, error?: ErrorIsh): PlayApiCommonDetailed => { const { playOpts = [], inputOpts = [], queueOpts = [], } = opts;
const playCommon = generatePlayApiCommon(...playOpts); const inputRes = generatePlayInputApi(...inputOpts); const queueRes = generateQueueStateApi(queueOpts[0]);
return { ...playCommon, input: inputRes, queueStates: [queueRes], error, events: [ {eventName: 'queueStateChange', playId: 1, data: {queueName: 'ingress', queueStatus: 'queued', retries: 2, error: serializeError(generateFakeError()), context: {reason: 'Bulk scrobble for dead Plays by system', dupeCheck: false, transform: true}}}, {eventName: 'playStateChange', playId: 1, data: {state: 'discarded', reason: 'Monitoring was off'}}, {eventName: 'queueStateChange', playId: 1, data: {queueName: 'ingress', retries: 1, queueStatus: 'failed', error: serializeError(generateFakeError())}}, ] }}
const statusSamples = ['Sleeping 💤', 'Processing Queue', '⚠️ Authentication Failed', 'Updating Now Playing', 'Monitoring Players', '⚠️ Upstream error'];
export const generateComponentCommonApiJson = (data: Partial<ComponentCommonApi> = {}): ComponentCommonApiJson => { const { type = faker.helpers.arrayElement([...sourceTypes, ...clientTypes]), createdAt = dayjs(), lastActiveAt = dayjs(), lastReadyAt = dayjs(), state = faker.number.int({min: 1, max: 7}) as ComponentState, monitoringStatus = { monitoring: faker.datatype.boolean({probability: 0.1}), origin: 'system' }, players = {}, queued = faker.number.int({min: 1, max: 2000}), deadLetterPlays = faker.number.int({min: 1, max: 2000}), deadLetterPlaysTotal = faker.number.int({min: deadLetterPlays, max: 2000}), ...rest } = data;
let mode: ComponentType = data.mode; if(mode === undefined) { if(isSourceType(type)) { mode = 'source'; } else { mode = faker.helpers.arrayElement(['source', 'client']) } }
return { id: faker.number.int({min: 1, max: 100}), uid: generatePlayUid(), name: `${faker.word.adjective()} ${faker.word.noun()}`, createdAt: createdAt.toISOString(), lastActiveAt: lastActiveAt.toISOString(), lastReadyAt: lastReadyAt.toISOString(), type, mode, countLive: faker.number.int({min: 0, max: 2000}), countNonLive: 0, state, players, errors: [], status: faker.helpers.arrayElement(statusSamples), monitoringStatus, queued, deadLetterPlays, deadLetterPlaysTotal, ...rest }}
export const generateHistoricalPlayApi = (data: Partial<ComponentHistoricalApi> = {}): ComponentHistoricalApi => { let isSynced; if(data.synced !== undefined) { isSynced = data.synced; } else if(data.syncedReason !== undefined || data.syncError !== undefined) { isSynced = false; } const importDate = dayjs(faker.date.recent().toISOString()); if(isSynced) { return { synced: true, syncedReason: undefined, syncError: undefined, lastImportSuccess: importDate, lastImport: importDate, ...data } } const reason = faker.helpers.arrayElement(['last attempted import failed','no historical imports exist','component was inactive for more than an hour and last import was before last activity. There may be missed plays during the period of inactivity.']); return { synced: false, syncedReason: reason, syncError: reason.includes('failed') ? generateFakeError() : undefined, lastImport: reason.includes('no historical') ? undefined : importDate, lastImportSuccess: undefined }}
export const generateSourceApiJson = (data: Partial<ComponentSourceApi> = {}): ComponentSourceApiJson => { const { mode, type = faker.helpers.arrayElement(sourceTypes), ...rest } = data; const common = generateComponentCommonApiJson({ mode: 'source', type, ...rest }); const { sot = faker.helpers.arrayElement(sourceSotTypes), supportsUpstreamRecentlyPlayed = faker.datatype.boolean(), tracksDiscovered = faker.number.int({min: 1, max: 2000}), players = (data.players ?? {}), sleeping = false, } = data; return { lastReadyAt: undefined, lastImport: undefined, lastImportSuccess: undefined, ...common, sot, supportsUpstreamRecentlyPlayed, tracksDiscovered, players, sleeping, initialized: true, authType: COMPONENT_AUTH_TYPE.unattended, hasAuth: true, hasAuthInteraction: true, authed: true }}
export const generateClientApiJson = (data: Partial<ComponentClientApi> = {}): ComponentClientApiJson => { const { mode, type = faker.helpers.arrayElement(clientTypes), ...rest } = data; const common = generateComponentCommonApiJson({ mode: 'client', type, ...rest }); const { players = (data.players ?? {}), } = data; return { lastReadyAt: undefined, lastImport: undefined, lastImportSuccess: undefined, ...common, tracksScrobbled: common.countLive, players, supportsNowPlaying: Object.keys(players).length > 0, initialized: true, authType: COMPONENT_AUTH_TYPE.unattended, hasAuth: true, hasAuthInteraction: true, authed: true }}
export const generateComponentApiJson = (data: Partial<ComponentCommonApi> = {}): ComponentClientApiJson | ComponentSourceApiJson => { const { mode: modeData, type: typeData } = data;
let mode: ComponentCommonApi['mode'], type: ComponentCommonApi['type'];
if(modeData === undefined && typeData === undefined) { mode = faker.helpers.arrayElement(['source', 'client']); type = faker.helpers.arrayElement(mode === 'source' ? sourceTypes : clientTypes) } else if(modeData !== undefined && typeData === undefined) { mode = modeData; type = faker.helpers.arrayElement(mode === 'source' ? sourceTypes : clientTypes) } else if(typeData !== undefined) { type = typeData; mode = isClientType(type) ? 'client' : 'source'; }
if(mode === 'source') { return generateSourceApiJson({mode, type, ...data}); } return generateClientApiJson({mode, type, ...data});}
export const generateSourcePlayerJson = (data: Partial<SourcePlayerJson> = {}, opts: {art?: boolean, position?: boolean, status?: boolean} = {}): SourcePlayerJson => { const { platformId = `${faker.word.noun()}-${faker.word.adjective()}`, play = asJsonPlayObject(generatePlay()), position = opts.position === false ? undefined : (play.meta.trackProgressPosition ?? faker.number.int({min: 0, max: play.data.duration})), listenedDuration = play.data.listenedFor ?? faker.number.int({min: 0, max: play.data.duration}), playFirstSeenAt = dayjs().subtract(30, 's').toISOString(), playLastUpdatedAt = dayjs().subtract(10, 's').toISOString(), playerLastUpdatedAt = dayjs().subtract(10, 's').toISOString(), createdAt = dayjs().unix(), status: { reported = REPORTED_PLAYER_STATUSES.playing, calculated = CALCULATED_PLAYER_STATUSES.playing, stale = false, orphaned = false } = {} } = data;
if(opts.art) { play.meta = { ...(play.meta), art: { album: 'https://placehold.co/400', ...(play.meta?.art ?? {}) } } }
return { platformId, play, position, listenedDuration, playerLastUpdatedAt, playFirstSeenAt, playLastUpdatedAt, createdAt, status: { reported: opts.status === false ? REPORTED_PLAYER_STATUSES.unknown : reported, calculated, stale, orphaned } }}
const traceMessage = "[2026-06-16 13:02:42.674 -0400] \u001b[90mTRACE\u001b[39m : \u001b[36m\u001b[90m[App]\u001b[36m \u001b[90m[Scrobblers]\u001b[36m \u001b[90m[Koito - koito]\u001b[36m \u001b[90m[Now Playing]\u001b[36m Not updating, previous matches current update --BUT-- time since last update (194s) is less than max threshold 238.121s\u001b[39m";const debugMessage = "[2026-06-16 13:03:20.150 -0400] \u001b[34mDEBUG\u001b[39m : \u001b[36m\u001b[90m[App]\u001b[36m \u001b[90m[Sources]\u001b[36m \u001b[90m[Spotify - default]\u001b[36m Temporarily decreasing polling interval to 1.00s due to Player c98a8fb80e-foxx-arch-SingleUser reporting track duration remaining (2.01s) less than normal interval (10.00s)\u001b[39m";const verboseMessage = "[2026-06-16 13:03:23.568 -0400] \u001b[35mVERBOSE\u001b[39m: \u001b[36m\u001b[90m[App]\u001b[36m \u001b[90m[Sources]\u001b[36m \u001b[90m[Spotify - default]\u001b[36m \u001b[90m[Player c98a8fb80e-foxx-arch-SingleUser]\u001b[36m New Play: (2KcQh1rHrJ23eaxax1L1PG) Strutman Lane - One of a Kind\u001b[39m";const infoMessage = "[2026-06-16 13:03:24.129 -0400] \u001b[32mINFO\u001b[39m : \u001b[36m\u001b[90m[App]\u001b[36m \u001b[90m[Scrobblers]\u001b[36m \u001b[90m[Koito - koito]\u001b[36m Scrobbled (New) => (Spotify) Couch - Jessie @ 2026-06-16T13:03:23-04:00 (C)\u001b[39m";const errorMessage = "[2026-06-16 10:41:13.948 -0400] \u001b[31mERROR\u001b[39m : \u001b[36m\u001b[90m[App]\u001b[36m \u001b[90m[Scrobblers]\u001b[36m \u001b[90m[Lastfm - mylfm]\u001b[36m Scrobble Error (New)\u001b[39m\n playInfo: \"Alice Auer - Unknown @ 2026-06-16T10:06:07-04:00 (C)\"\n payload: {\n \"artist\": \"Alice Auer\",\n \"track\": \"Unknown\",\n \"album\": \"Unknown\",\n \"timestamp\": 1781618767,\n \"mbid\": \"9957324d-3c86-47cd-b844-0c92cf374ec1\",\n \"duration\": 214\n }";
const messages = [traceMessage, debugMessage, verboseMessage, infoMessage, errorMessage];
export const LOG_MESSAGE_FIXTURE = { trace: traceMessage, debug: debugMessage, verbose: verboseMessage, info: infoMessage, error: errorMessage, messages}
export const logsApiResponse = () => { const time = dayjs().subtract(10, 'm').unix(); return {data: messages.map((x, index) => ({line: x, time: time + index, levelLabel: 'debug', level: 'debug'})) }}
export const generatePlayApiCommonDetailedList = async (opts: {endDate?: Dayjs, initialDate?: Dayjs} = {}) => { const queued = normalizePlays(generateArray(7, () => generatePlayWithLifecycle()), { endDate: opts.initialDate === undefined ? opts.endDate ?? dayjs() : undefined, initialDate: opts.initialDate }).map(x => { const jsonPlay = asJsonPlayObject(x); return generatePlayApiCommonDetailed({ playOpts: [{ state: 'queued', play: jsonPlay }], inputOpts: [{ play: jsonPlay }] }) }); const scrobbledPlay = asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle({ lifecycleSteps: { preCompare: [true, 'skipped', true] } }))); const scrobbledApi = generatePlayApiCommonDetailed({ playOpts: [{ play: scrobbledPlay, state: 'scrobbled' }], inputOpts: [{ play: scrobbledPlay }] }); const scrobbleErrorPlay = asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle(), { error: true })); const scrobbleError = generatePlayApiCommonDetailed({ playOpts: [{ play: scrobbleErrorPlay, state: 'failed' }], inputOpts: [{ play: scrobbleErrorPlay }] }); const promisedScrobbled = generateArray(10, () => playWithLifecycleScrobble(generatePlayWithLifecycle({ lifecycleSteps: { preCompare: [true, 'skipped', true] } }))); const promised = await Promise.all(promisedScrobbled); const yesterdayScrobbled = normalizePlays(promised, { endDate: dayjs().subtract(1, 'd').subtract(100, 'm') }).map((x) => { const jPlay = asJsonPlayObject(x); return generatePlayApiCommonDetailed({ playOpts: [{ play: jPlay, state: 'scrobbled' }], inputOpts: [{ play: jPlay }] }); }); return [ ...queued, scrobbledApi, scrobbleError, ...yesterdayScrobbled ];}
/** * Generates a fake Error with nested cause errors for testing. * Each error has a realistic-looking stack trace with plausible call sites. * * @param depth - Number of nested cause errors to create (0 = just one error, no cause chain) * @returns An Error object with nested cause errors */export function generateFakeError(depth: number = 0): Error { const commonFunctions = [ 'handleRequest', 'processData', 'validateInput', 'fetchUser', 'parseJSON', 'connectToDatabase', 'executeQuery', 'mapResponse', 'transformData', 'authenticateUser', 'authorizeAccess', 'fetchFromCache', 'updateRecord', 'deleteResource', 'createTransaction', 'rollbackChanges', 'serializeObject', 'deserializePayload', 'encryptData', 'decryptData', ]; const filePaths = [ 'src/handlers/userController.ts', 'src/services/authService.ts', 'src/repositories/userRepository.ts', 'src/middleware/errorHandler.ts', 'src/utils/dataTransformer.ts', 'src/database/connection.ts', 'src/api/routes/users.ts', 'src/validators/schema.ts', 'src/lib/helpers.ts', 'node_modules/express/index.js', 'src/cache/redis.ts', 'src/external/api-client.ts', ]; const generateStackTrace = (functionName: string): string => { const lines: string[] = []; const callDepth = Math.floor(Math.random() * 8) + 3; // 3-10 call sites for (let i = 0; i < callDepth; i++) { const func = i === 0 ? functionName : commonFunctions[Math.floor(Math.random() * commonFunctions.length)]; const file = filePaths[Math.floor(Math.random() * filePaths.length)]; const line = Math.floor(Math.random() * 500) + 1; const column = Math.floor(Math.random() * 80) + 1; lines.push(` at ${func} (${file}:${line}:${column})`); } return lines.join('\n'); }; const messages = [ 'Connection timeout after 30000ms', 'Failed to parse JSON response', 'Invalid user credentials', 'Database query failed', 'Resource not found', 'Permission denied', 'Network request failed', 'Serialization error', 'Type validation failed', 'Authentication required', 'Cache miss', 'Service unavailable', ]; const createErrorAtDepth = (currentDepth: number): Error => { const message = messages[Math.floor(Math.random() * messages.length)]; const func = commonFunctions[Math.floor(Math.random() * commonFunctions.length)]; const error = new Error(message); // Set the function name (shows in stack trace) Object.defineProperty(error, 'name', { value: 'CustomError', writable: true, enumerable: false, }); // Create a fake stack trace const stackTrace = `CustomError: ${message}\n${generateStackTrace(func)}`; Object.defineProperty(error, 'stack', { value: stackTrace, writable: true, enumerable: false, }); // Add nested cause if depth allows if (currentDepth < depth) { const causeError = createErrorAtDepth(currentDepth + 1); Object.defineProperty(error, 'cause', { value: causeError, writable: true, enumerable: true, }) } return error; }; return createErrorAtDepth(0);}
export const generateNormalHttpUrl = () => { const g = faker.helpers.arrayElement([ () => faker.internet.url(), () => faker.internet.domainName(), () => `${faker.word.noun({length :1})}:${faker.helpers.arrayElement([443,80,faker.internet.port()])}` ]); return g();}
export const generateNormalWSUrl = () => { const g = faker.helpers.arrayElement([ () => faker.internet.url({ // @ts-expect-error this should be able to take ws or wss its a real protocol protocol: faker.helpers.arrayElement(['ws','wss', faker.internet.protocol()]) }), () => faker.internet.domainName(), () => `${faker.word.noun({length :1})}:${faker.helpers.arrayElement([443,80,faker.internet.port()])}` ]) return g();}