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
Something went wrong. Try again.
43 kB · 1053 lines
TypeScript
at queueNext
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054import type {LogDataPretty, Logger, LogLevel} from "@foxxmd/logging";import type {Express} from 'express';import bsseDef from 'better-sse';import bodyParser from "body-parser";import { FixedSizeList } from 'fixed-size-list';import type { PassThrough } from "node:stream";import { Transform } from "stream";import { DEAD_QUEUE, type ClientStatusData, type DeadLetterScrobble, type LogOutputConfig, PLAY_CLIENT_STATE, PLAY_SOURCE_STATE, type PlayObject, SOURCE_SOT, type SOURCE_SOT_TYPES, type SourcePlayerJson, type SourceStatusData, queueContextSchema,} from "../../core/Atomic.ts";import { capitalize } from "../../core/StringUtils.ts";import type {ExpressHandler, LeveledLogData} from "../common/infrastructure/Atomic.ts";import { getRoot } from "../ioc.ts";import AbstractScrobbleClient from "../scrobblers/AbstractScrobbleClient.ts";import AbstractSource from "../sources/AbstractSource.ts";import MemorySource from "../sources/MemorySource.ts";import { parseBool } from "../utils.ts";import { sortByNewestPlayDate } from '../../core/PlayUtils.ts';import { setupAuthRoutes } from "./auth.ts";import { setupDeezerRoutes } from "./deezerRoutes.ts";import {setupLZEndpointRoutes} from "./endpointListenbrainzRoutes.ts";import {setupLastfmEndpointRoutes} from "./endpointLastfmRoutes.ts";import { makeClientCheckMiddle, makeClientNextMiddle, makeComponentMiddle, makeSourceCheckMiddle, makeSourceNextMiddle } from "./middleware.ts";import { setupWebscrobblerRoutes } from "./webscrobblerRoutes.ts";import type ScrobbleSources from "../sources/ScrobbleSources.ts";import type ScrobbleClients from "../scrobblers/ScrobbleClients.ts";import prom from 'prom-client';import { findAuthIssue, SimpleError } from "../common/errors/MSErrors.ts";import { DrizzlePlayRepository, type QueryPlaysOpts, type QueryPlaysOptsJson } from "../common/database/drizzle/repositories/PlayRepository.ts";import { playSelectToDeadScrobble } from "../common/database/drizzle/entityUtils.ts";import AbstractHistoricalScrobbleClient from "../scrobblers/AbstractHistoricalScrobbleClient.ts";import { DrizzlePlayHistoricalRepository } from "../common/database/drizzle/repositories/PlayHistoricalRepository.ts";import {componentStateBodySchema, type ComponentClientApiJson, type ComponentSourceApiJson, type PlayApiCommonDetailed} from "../../core/Api.ts";import { asDayjsHydratedObject } from "../../core/DataUtils.ts";import type {Dayjs} from "dayjs";import { asSerializablePlaySelect } from "../../core/PlayMarshalUtils.ts";import { serializeError } from "serialize-error";import { z } from 'zod';import type { createTypedRouter } from "@minisylar/express-typed-router";import pEvent from "p-event";
const maxBufferSize = 300;const output: Record<number, FixedSizeList<LogDataPretty>> = {};
const jsonParser = bodyParser.json({type: ['text/*', 'application/json']});
const createAddToLogBuffer = (levelMap: {[p: number]: string}) => (log: LogDataPretty) => { output[log.level].add({...log, levelLabel: levelMap[log.level]});}
const getLogs = (minLevel: number, limit: number = maxBufferSize, sort: 'asc' | 'desc' = 'desc'): LeveledLogData[] => { const allLogs: LeveledLogData[][] = []; for(const level of Object.keys(output)) { if(Number.parseInt(level) >= minLevel) { allLogs.push(output[level].data); } } if(sort === 'desc') { return allLogs.flat(1).sort((a, b) => b.time - a.time).slice(0, limit); } return allLogs.flat(1).sort((a, b) => a.time - b.time).slice(0, limit);}
export const setupApi = (app: Express, router: ReturnType<typeof createTypedRouter>, logger: Logger, appLoggerStream: PassThrough, initialLogOutput: LogDataPretty[] = [], scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => { for(const level of Object.keys(logger.levels.labels)) { output[level] = new FixedSizeList<LeveledLogData>(maxBufferSize); }
const addToLogBuffer = createAddToLogBuffer(logger.levels.labels); for(const log of initialLogOutput) { addToLogBuffer(log); } const root = getRoot();
//let logWebLevel: LogLevel = logger.level as LogLevel || (process.env.LOG_LEVEL || 'info') as LogLevel;
const logConfig: LogOutputConfig = { level: logger.level as LogLevel || (process.env.LOG_LEVEL || 'trace') as LogLevel, sort: 'descending', limit: 50, }
let logObjectStream: Transform; try { logObjectStream = new Transform({ transform: (chunk, e, cb) => { cb(null, chunk) }, objectMode: true, allowHalfOpen: true }) } catch (e) { console.log(e); }
appLoggerStream.on('data', (log: LogDataPretty) => { addToLogBuffer(log); if(log.level >= logger.levels.values[logConfig.level]) { logObjectStream.write({message: log.line, level: log.level, levelLabel: logger.levels.labels[log.level]}); } });
const clientMiddleFunc = makeClientCheckMiddle(scrobbleClients); const sourceMiddleFunc = makeSourceCheckMiddle(scrobbleSources);
const clientRequiredMiddle = clientMiddleFunc(true); const sourceRequiredMiddle = sourceMiddleFunc(true);
const componentAwareMiddle = makeComponentMiddle(scrobbleSources, scrobbleClients); const sourceAwareMiddle = makeSourceNextMiddle(scrobbleSources); const clientAwareMiddle = makeClientNextMiddle(scrobbleClients);
const setLogWebSettings: ExpressHandler = async (req, res, next) => { // @ts-expect-error logLevel not part of session const sessionLevel: LogLevel | undefined = req.session.logLevel as LogLevel | undefined; if(sessionLevel !== undefined && logConfig.level !== sessionLevel) { logConfig.level = sessionLevel; } // @ts-expect-error limit not part of session const sessionLimit: number | undefined = req.session.limit as number | undefined; if(sessionLimit !== undefined && logConfig.limit !== sessionLimit) { logConfig.limit = sessionLimit; } next(); }
app.get('/api/logs/stream', setLogWebSettings, async (req, res) => { const session = await bsseDef.createSession(req, res); await session.stream(logObjectStream); });
app.get('/api/logs', setLogWebSettings, async (req, res) => { const slicedLog = getLogs(logger.levels.values[logConfig.level], logConfig.limit + 1, logConfig.sort === 'ascending' ? 'asc' : 'desc'); return res.json({data: slicedLog, settings: logConfig}); });
app.put('/api/logs', async (req, res) => { logConfig.level = req.body.level as LogLevel | undefined ?? logConfig.level; logConfig.limit = req.body.limit ?? logConfig.limit; const slicedLog = getLogs(logger.levels.values[logConfig.level], logConfig.limit + 1, logConfig.sort === 'ascending' ? 'asc' : 'desc'); // @ts-expect-error logLevel not part of session req.session.logLevel = logConfig.level; // @ts-expect-error limit not part of session req.session.limit = logConfig.limit; return res.json({data: slicedLog, settings: logConfig}); });
router.get('/events', async (req, res) => { const { query: { next: nextQs } } = req;
const isNextapi = nextQs === 'true';
const session = await bsseDef.createSession(req, res); scrobbleSources.emitter.onAny((eventName: string, payload: any) => { if(payload !== undefined && payload.from !== undefined) { if(isNextapi) { session.push({event: eventName, ...payload}, eventName); } else { session.push({event: eventName, ...payload}, payload.from); } } }); scrobbleClients.emitter.onAny((eventName: string, payload: any) => { if(payload !== undefined && payload.from !== undefined) { if(isNextapi) { session.push({event: eventName, ...payload}, eventName); } else { session.push({event: eventName, ...payload}, payload.from); } } }); });
setupDeezerRoutes(app, logger, scrobbleSources); setupWebscrobblerRoutes(app, logger, scrobbleSources); setupLZEndpointRoutes(app, logger, scrobbleSources, scrobbleClients); setupLastfmEndpointRoutes(app, logger, scrobbleSources); setupAuthRoutes(app, router, logger, sourceRequiredMiddle, clientRequiredMiddle, scrobbleSources, scrobbleClients);
router.put('webscrobbler', {middleware: [jsonParser]}, async (req, res) => { logger.info(req.body); res.sendStatus(200); });
router.get('webscrobbler', {middleware: [jsonParser]}, async (req, res) => { logger.info(req.body); res.sendStatus(200); });
router.get('/components', async (req, res, next) => {
const sourceData = scrobbleSources.sources.filter(x => x.databaseOK).map((x) => { const { canPoll = false, polling = false, requiresAuth = false, requiresAuthInteraction = false, authed = false } = x; const base: ComponentSourceApiJson = x.getApiData(); if(!x.isReady()) { if(x.buildOK === false) { base.status = 'Initializing Data Failed'; } else if(x.connectionOK === false) { base.status = 'Communication Failed'; } else if (requiresAuth && !authed) { base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted' } else { base.status = 'Not Ready'; } } else { if (canPoll) { base.status = polling ? 'Polling' : 'Idle'; } else { base.status = !x.instantiatedAt.isSame(x.lastActivityAt) ? 'Received Data' : 'Awaiting Data'; } } return base; });
const clientData = scrobbleClients.clients.filter(x => x.databaseOK).map((x) => { const { requiresAuth = false, requiresAuthInteraction = false, authed = false, scrobbling = false, } = x; const base: ComponentClientApiJson = x.getApiData();
if (!x.isReady()) { if(x.buildOK === false) { base.status = 'Initializing Data Failed'; } else if(x.connectionOK === false) { base.status = 'Communication Failed'; } else if (requiresAuth && !authed) { base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted' } else { base.status = 'Not Ready'; } } else { base.status = scrobbling ? 'Running' : 'Idle'; } return base; });
return res.json([...sourceData, ...clientData]); });
router.get('/sources/:componentVal/players', {middleware: [sourceAwareMiddle] }, async (req, res, next) => { if(req.component instanceof MemorySource) { return res.json(req.component.playersToObject()); } return res.json({}); }); router.get('/components/:componentVal/players', {middleware: [componentAwareMiddle]}, async (req, res, next) => { if(req.component instanceof MemorySource) { return res.json(req.component.playersToObject()); } else if(req.component instanceof AbstractScrobbleClient && req.component.nowPlayingEnabled) { return res.json(req.component.getNowPlayingPlayers()); } return res.json({}); });
router.get('/sources/:componentVal/players/:platformId', {middleware: [sourceAwareMiddle]}, async (req, res, next) => { if(req.component instanceof MemorySource) { const { params: { platformId } } = req; const player = req.component.players.get(platformId as string); if(player === undefined) { return res.status(400).json({error: `No player with platform id ${platformId} exists`}); } return res.json(player); } return res.json({}); }); router.get('/components/:componentVal/players/:platformId', {middleware: [componentAwareMiddle]}, async (req, res, next) => { const { params: { platformId } } = req; if(req.component instanceof MemorySource) {
const player = req.component.players.get(platformId as string); if(player === undefined) { return res.status(400).json({error: `No player with platform id ${platformId} exists`}); } return res.json(player); } else if(req.component instanceof AbstractScrobbleClient && req.component.nowPlayingEnabled) { const players = req.component.getNowPlayingPlayers(); if(players[platformId as string] === undefined) { return res.status(400).json({error: `No player with platform id ${platformId} exists`}); } return res.json(players[platformId as string]); } return res.status(400).json({error: `Component does not support players`}); });
router.get('/components/:componentVal', {middleware: [componentAwareMiddle]}, async (req, res) => { const { component, } = req; return res.json(component.getApiData()); }); //type TA = TypedMiddleware<{component: ComponentAwareRequest['component']}>;// componentAwareMiddle, bodyParser.json({ type: ['text/*', 'application/json'] }) router.post('/components/:componentVal/state', // {bodySchema: componentStateBodySchema}, {middleware: [componentAwareMiddle, bodyParser.json({ type: ['text/*', 'application/json'] })], bodySchema: componentStateBodySchema}, async (req, res) => { const { component, body: { state, reason = 'invoked by api' } } = req; switch (state) { case 'stop': try { await component.stop({ reason: new SimpleError(reason, {simple: true, shortStack: true}) }) } catch (e) { return res.status(500).json({ error: serializeError(e) }); } break; case 'start': try { await component.start({ forceInit: true }) } catch (e) { return res.status(500).json({ error: serializeError(e) }); } break; case 'restart': try { await component.restart({ forceInit: true, reason: new SimpleError(reason, {simple: true, shortStack: true}) }) } catch (e) { return res.status(500).json({ error: serializeError(e) }); } break; case 'ignore': component.monitoringActivity = component.getSystemMonitoring() === false ? undefined : false; component.emitComponentUpdate({state: component.getRunningState()}); break; case 'monitor': component.monitoringActivity = component.getSystemMonitoring() === true ? undefined : true; component.emitComponentUpdate({state: component.getRunningState()}); break; default: return res.status(400).json({ error: { message: `'state' type ${state} was not handled` } }); } return res.sendStatus(200); });
router.post('/components/:componentVal/auth', {middleware: [componentAwareMiddle]}, async (req, res, next) => { const { component, } = req; let didAuth = false; try { logger.verbose('User requested auth test'); await component.testAuth(true); component.clearErrors({predicate: x => findAuthIssue(x) !== undefined}); didAuth = true; return res.sendStatus(200); } catch (e) { component.replaceErrors(e, {predicate: x => findAuthIssue(x) !== undefined}); return res.status(500).json({error: serializeError(e)}); } finally { const data = component.getApiData(); component.emitComponentUpdate({ errors: data.errors, state: data.state, status: didAuth ? 'Authenticated successfully' : data.status }); } });
router.get('/components/:componentVal/plays', {middleware: [componentAwareMiddle]}, async (req, res, next) => { const { component, query } = req;
const hydratedQuery = asDayjsHydratedObject<QueryPlaysOptsJson, QueryPlaysOpts<Dayjs>>(query); const playRes = await component.getPlaysPaginated(hydratedQuery);
// @ts-expect-error its fine playRes.data = playRes.data.map(x => asSerializablePlaySelect(x)) //PlayApiCommonDetailed // plus paginatioon return res.json(playRes); });
router.get('/components/:componentVal/plays/:playUid', {middleware: [componentAwareMiddle]}, async (req, res, next) => { const { component, params: { playUid } } = req;
const playRes = await component.getPlayApiResponse(playUid as string); //PlayApiCommonDetailed // plus paginatioon return res.json(asSerializablePlaySelect(playRes)); });
router.post('/components/:componentVal/plays/:playUid/queue', {middleware: [componentAwareMiddle], bodySchema: queueContextSchema.optional()}, async (req, res, next) => { const { component, params: { playUid }, body = {} } = req;
const play = await component.playRepo.findByUid(playUid); if(play === undefined) { return res.sendStatus(404); }
if(component instanceof AbstractSource) { await component.queuePlay([play], {...body, isRetry: true}); } else { await component.queueScrobble([play], {...body, isRetry: true}); } return res.sendStatus(200); });
router.delete('/components/:componentVal/plays/:playUid/queue', {middleware: [componentAwareMiddle]}, async (req, res, next) => { const { component, params: { playUid } } = req;
const play = await component.playRepo.findByUid(playUid); if(play === undefined) { return res.sendStatus(404); }
await component.cancelQueuedPlay(play); return res.sendStatus(200); });
router.delete('/components/:componentVal/plays/:playUid/dead', {middleware: [componentAwareMiddle]}, async (req, res, next) => { const { component, params: { playUid } } = req;
const play = await component.playRepo.findByUid(playUid); if(play === undefined) { return res.sendStatus(404); }
await component.removeDeadLetterScrobble(play); return res.sendStatus(200); });
router.delete('/cache/:cacheType', async (req, res) => { const cache = await getRoot().items.cache(); logger.verbose(`User request cache deletion for ${req.params.cacheType}`); switch(req.params.cacheType) { case 'external-api': await cache.cacheApi.clear(); break; case 'transforms': await cache.cacheTransform.clear(); break; default: return res.sendStatus(404); } logger.verbose('Cache cleared!'); return res.sendStatus(204); });
/** * * * new apis above * * * */
router.get('/status', async (req, res, next) => {
const sourceData = scrobbleSources.sources.map((x) => { const { type, tracksDiscovered = 0, name, canPoll = false, polling = false, requiresAuth = false, requiresAuthInteraction = false, authed = false, } = x; const base: SourceStatusData = { status: '', type, display: capitalize(type), tracksDiscovered, name, canPoll, hasAuth: requiresAuth, hasAuthInteraction: requiresAuthInteraction, authed, players: 'players' in x ? (x as MemorySource).playersToObject() as unknown as Record<string,SourcePlayerJson> : {}, sot: ('playerSourceOfTruth' in x) ? x.playerSourceOfTruth as SOURCE_SOT_TYPES : SOURCE_SOT.HISTORY, supportsUpstreamRecentlyPlayed: x.supportsUpstreamRecentlyPlayed, manualListening: x.monitoringActivity, systemListeningBehavior: x.getSystemMonitoring(), ...x.additionalApiData() }; if(!x.isReady()) { if(x.buildOK === false) { base.status = 'Initializing Data Failed'; } else if(x.connectionOK === false) { base.status = 'Communication Failed'; } else if (requiresAuth && !authed) { base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted' } else { base.status = 'Not Ready'; } } else { if (canPoll) { base.status = polling ? 'Polling' : 'Idle'; } else { base.status = !x.instantiatedAt.isSame(x.lastActivityAt) ? 'Received Data' : 'Awaiting Data'; } } return base; });
const clientData = scrobbleClients.clients.map((x) => { const { type, tracksScrobbled = 0, name, requiresAuth = false, requiresAuthInteraction = false, authed = false, scrobbling = false, } = x; const base: ClientStatusData = { status: '', type, display: capitalize(type), scrobbled: tracksScrobbled, name, hasAuth: requiresAuth, hasAuthInteraction: requiresAuthInteraction, authed, initialized: x.isReady(), deadLetterScrobbles: x.deadLetterQueued, // x.deadLetterScrobbles.length, deadLetterScrobblesTotal: x.deadLetterLength, queued: x.queuedLength, // x.queuedScrobbles.length manualListening: x.monitoringActivity, systemListeningBehavior: x.getSystemMonitoring(), }; if (!base.initialized) { if(x.buildOK === false) { base.status = 'Initializing Data Failed'; } else if(x.connectionOK === false) { base.status = 'Communication Failed'; } else if (requiresAuth && !authed) { base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted' } else { base.status = 'Not Ready'; } } else { base.status = scrobbling ? 'Running' : 'Idle'; } return base; }); return res.json({sources: sourceData, clients: clientData}); });
router.get('/recent', {middleware: [sourceMiddleFunc(false)], querySchema: z.any()}, async (req, res, next) => { const { scrobbleSource: source, query: { upstream = 'false', next: queryNext = 'false', ...rest } } = req;
let result: PlayObject[] = []; if (source !== undefined) { if (upstream === 'true' || upstream === '1') { if (!(source as AbstractSource).supportsUpstreamRecentlyPlayed) { return res.status(409).json({message: 'Fetching upstream recently played is not supported for this source'}); } try { result = await (source as AbstractSource).getUpstreamRecentlyPlayed(); } catch (e) { return res.status(500).json({message: e.message}); } } else { if(queryNext === 'true') { return res.json(await (source as AbstractSource).getRecentPlaysApi(rest)); } result = await (source as AbstractSource).getFlatRecentlyDiscoveredPlays(); } }
return res.json(result); });
router.get('/source/art', {middleware: [sourceMiddleFunc(false)]}, async (req, res, next) => { const { scrobbleSource, query: { data } } = req;
const source = scrobbleSource as AbstractSource; if(!(source instanceof MemorySource)) { return res.status(500).json({message: 'Source does not support players'}); }
if('getSourceArt' in source && typeof source.getSourceArt === 'function') { const [stream, contentType] = await source.getSourceArt(data); res.writeHead(200, {'Content-Type': contentType}); try { return stream.pipe(res); } catch (e) { logger.error(new Error(`Error occurred while trying to stream art for ${source.name} (${source.type}) | Data ${data}`, {cause: e})); return res.status(500).json({message: 'Error during art retrieval'}); } } else { return res.status(500).json({message: `Source ${source.name} (${source.type} does not support art retrieval`}); } });
router.get('/dead', {middleware: [clientMiddleFunc(true)]}, async (req, res, next) => { const { scrobbleClient: client, query = {} } = req;
const deadQuery: QueryPlaysOpts = { ...query as Partial<QueryPlaysOpts>, queues: [ { queueName: DEAD_QUEUE, queueStatus: ['queued','failed'] } ] }
const result: DeadLetterScrobble<PlayObject>[] = (await (client as AbstractScrobbleClient).getPlaysPaginated(deadQuery)).data.map(x => playSelectToDeadScrobble(x, true));
return res.json(result); });
router.put('/dead', {middleware: [clientMiddleFunc(true)]}, async (req, res, next) => { const { scrobbleClient: client, } = req;
(client as AbstractScrobbleClient).logger.verbose('User requested processing of all dead letter scrobbles via API');
res.status(200).send('OK');
await ((client as AbstractScrobbleClient).processDeadLetterQueue(1000)); });
router.put('/dead/:id', {middleware: [clientMiddleFunc(true)]}, async (req, res, next) => { const { scrobbleClient: client, params: { id } = {} } = req;
const deadId = id as string;
(client as AbstractScrobbleClient).logger.verbose(`User requested processing of dead letter scrobble ${deadId} via API call`);
try { const playEntity = await client.playRepo.findByUid(id); if(playEntity === undefined) { return res.status(404).json({message: `Play ${deadId} does not exist`}); } client.queueScrobble(playEntity, {reason: 'user requested processing via API call'}).then(() => null); const event = await pEvent(client.emitter, 'playUpdate', { timeout: 10000, filter: (val: PlayApiCommonDetailed) => val.uid === id }) as PlayApiCommonDetailed; if(event.state === 'scrobbled') { return res.status(200).send(); } else { // @ts-expect-error should be fine return res.json(playSelectToDeadScrobble(event, true)); } } catch (e) { if(e.message.includes(`Play ${deadId} does not exist`)) { logger.warn(e); return res.status(404).json({error: e}); } logger.error(e); return res.status(500).json({error: e}); } });
router.delete('/dead', {middleware: [clientMiddleFunc(true)]}, async (req, res, next) => { const { scrobbleClient: client, } = req;
(client as AbstractScrobbleClient).logger.verbose('User requested deletion of all dead letter scrobbles via API');
(client as AbstractScrobbleClient).removeDeadLetterScrobbles().then(() => null).catch((e) => logger.error(e));
return res.sendStatus(200); });
router.delete('/dead/:id', {middleware: [clientMiddleFunc(true)]}, async (req, res, next) => { const { scrobbleClient: client, params: { id } = {} } = req;
const deadId = id as string;
(client as AbstractScrobbleClient).logger.verbose(`User requested removal of dead letter scrobble ${deadId} via API call`)
const playEntity = await client.playRepo.findByUid(id); if(playEntity === undefined) { return res.status(404).json({message: `Play ${deadId} does not exist`}); }
try { await (client as AbstractScrobbleClient).removeDeadLetterScrobble(playEntity); return res.status(200).send(); } catch (e) { logger.error(e); return res.status(500).json({error: e}); } });
router.get('/scrobbled', {middleware: [clientMiddleFunc(true)]}, async (req, res, next) => { const { scrobbleClient: client, query } = req;
let result: PlayObject[] = []; if (client !== undefined) { const q: Partial<QueryPlaysOpts> = { ...query as Partial<QueryPlaysOpts>, state: ['scrobbled'] } result = [...(await (client as AbstractScrobbleClient).getPlaysPaginated(q)).data.map(x => x.play)].sort(sortByNewestPlayDate); }
return res.json(result); });
router.post('/source/init', {middleware: [sourceRequiredMiddle]}, async (req, res) => { const source = req.scrobbleSource as AbstractSource;
const { query: { force: forceQ = false } } = req;
const force = parseBool(forceQ, false);
source.logger.verbose(`User requested${force ? ' a FORCED' :''} (re)init via API call`);
res.status(200).send('OK');
if(source.polling) { source.logger.info('Source is already polling! Restarting polling...'); const stopRes = await source.tryStopPolling(new SimpleError('user initiated', {simple: true, shortStack: true})); if(stopRes === true) { source.poll({force, notify: false}).catch(e => source.logger.error(e)); } } else { source.poll({force, notify: false}).catch(e => source.logger.error(e)); } });
router.post('/source/listen', {middleware: [sourceRequiredMiddle]}, async (req, res) => { const source = req.scrobbleSource as AbstractSource;
const { query: { listening: listeningQ } } = req;
let listening: boolean | undefined; if(listeningQ !== undefined) { listening = parseBool(listeningQ) } source.logger.verbose(`User requested Monitoring status ${listening === undefined ? 'system' : listening}`);
source.monitoringActivity = listening;
res.status(200).json({listening}); });
router.post('/client/listen', {middleware: [clientRequiredMiddle]}, async (req, res) => { const client = req.scrobbleClient as AbstractScrobbleClient;
const { query: { listening: listeningQ } } = req;
let listening: boolean | undefined; if(listeningQ !== undefined) { listening = parseBool(listeningQ) } client.logger.verbose(`User requested Monitoring status ${listening === undefined ? 'system' : listening}`);
client.monitoringActivity = listening;
res.status(200).json({listening}); });
router.post('/client/init', {middleware: [clientRequiredMiddle]}, async (req, res) => { const client = req.scrobbleClient as AbstractScrobbleClient;
const { query: { force: forceQ = false } } = req;
const force = parseBool(forceQ, false);
client.logger.verbose(`User requested${force ? ' a FORCED' :''} (re)init via API call`);
client.logger.info('Checking (and trying) to stop scrobbler if already running...'); if(false === (await client.tryStopScrobbling(new SimpleError('user initiated', {simple: true, shortStack: true})))) { return res.status(500).send(); }
client.logger.info('Trying to start scrobbler...'); client.initScrobbleMonitoring({force, notify: false}).catch(e => client.logger.error(e)); res.status(200).send('OK'); });
router.post('/client/historical', {middleware: [clientRequiredMiddle]}, async (req, res) => { const client = req.scrobbleClient as AbstractScrobbleClient; if(client instanceof AbstractHistoricalScrobbleClient) { client.logger.info('User requested historical play hydration'); client.hydrateHistoricalScrobbles(); res.status(200).send('OK'); } else { client.logger.warn('This client does not have historical play capabilities'); return res.status(400).json({error: 'This client does not have historical play capabilities'}); } });
app.get('/health', async (req, res) => res.redirect(307, `/api/${req.url.slice(1)}`)); router.get('/health', async (req, res) => { const { type, name } = req.query;
const [sourcesReady, sourceMessages] = await scrobbleSources.getStatusSummary(type as string|undefined, name as string|undefined); const [clientsReady, clientMessages] = await scrobbleClients.getStatusSummary(type as string|undefined, name as string|undefined);
return res.status((clientsReady && sourcesReady) ? 200 : 500).json({messages: sourceMessages.concat(clientMessages)}); });
const issuesClientGauge = new prom.Gauge({ name: 'multiscrobbler_client_issues', help: 'Number of errors/issues with Client', labelNames: ['name', 'type'], async collect() { for(const client of scrobbleClients.clients) { let issues = 0; if(!(await client.isReady())) { issues++; } this.labels({name: client.getSafeExternalName(), type: client.type}).set(issues); } } });
const sourceIssues = new prom.Gauge({ name: 'multiscrobbler_source_issues', help: 'Number of errors/issues with Source', labelNames: ['name', 'type'], async collect() { for(const source of scrobbleSources.sources) { let issues = 0; if(source.requiresAuth && !source.authed) { issues++; } if(source.canPoll && !source.polling) { issues++; } this.labels({name: source.getSafeExternalName(), type: source.type}).set(issues); } } });
let playRepo: DrizzlePlayRepository, playHistoricalRepo: DrizzlePlayHistoricalRepository;
const sourcePlays = new prom.Gauge({ name: 'multiscrobbler_source_plays', help: 'Count of stored plays by state for Sources', labelNames: ['name', 'type', 'state'], async collect() { const res = await playRepo.getPlayCountByState(); for(const source of scrobbleSources.sources) { const relevant = res.filter(x => x['componentId'] === source.componentId); for(const s of PLAY_SOURCE_STATE) { const rel = relevant.find(x => x['state'] === s); const count = rel === undefined ? 0 : rel['count(*)']; this.labels({name: source.getSafeExternalName(), type: source.type, state: s}).set(count); } } } }); const sourceRetention = new prom.Gauge({ name: 'multiscrobbler_source_plays_compacted', help: 'Count of compacted, stored plays by compaction type for Sources', labelNames: ['name', 'type', 'compactionType'], async collect() { const res = await playRepo.getCompactedPlayCountByComponent(); for(const source of scrobbleSources.sources) { const relevant = res.filter(x => x['componentId'] === source.componentId); for(const s of ['input','transform','input-transform']) { const rel = relevant.find(x => x['compacted'] === s); const count = rel === undefined ? 0 : rel['count(*)']; this.labels({name: source.getSafeExternalName(), type: source.type, compactionType: s}).set(count); } } } }); const clientPlays = new prom.Gauge({ name: 'multiscrobbler_client_plays', help: 'Count of stored plays by state for Clients', labelNames: ['name', 'type', 'state'], async collect() { const res = await playRepo.getPlayCountByState(); for(const client of scrobbleClients.clients) { const relevant = res.filter(x => x['componentId'] === client.componentId); for(const s of PLAY_CLIENT_STATE) { const rel = relevant.find(x => x['state'] === s); const count = rel === undefined ? 0 : rel['count(*)']; this.labels({name: client.getSafeExternalName(), type: client.type, state: s}).set(count); } } } }); const clientRetention = new prom.Gauge({ name: 'multiscrobbler_client_plays_compacted', help: 'Count of compacted, stored plays by compaction type for Clients', labelNames: ['name', 'type', 'compactionType'], async collect() { const res = await playRepo.getCompactedPlayCountByComponent(); for(const client of scrobbleClients.clients) { const relevant = res.filter(x => x['componentId'] === client.componentId); for(const s of ['input','transform','input-transform']) { const rel = relevant.find(x => x['compacted'] === s); const count = rel === undefined ? 0 : rel['count(*)']; this.labels({name: client.getSafeExternalName(), type: client.type, compactionType: s}).set(count); } } } }); const clientHistoricalPlays = new prom.Gauge({ name: 'multiscrobbler_client_historical_plays', help: 'Count of stored historical plays for Clients', labelNames: ['name', 'type'], async collect() { const res = await playHistoricalRepo.getPlayCountByComponent(); for(const client of scrobbleClients.clients) { if(client instanceof AbstractHistoricalScrobbleClient) { const relevant = res.filter(x => x['componentId'] === client.componentId); for(const rel of relevant) { this.labels({name: client.getSafeExternalName(), type: client.type}).set(rel['count(*)']); } } } } });
if(process.env.PROMETHEUS_FULL === 'true') { prom.collectDefaultMetrics(); }
app.get('/api/metrics', async (req, res) => {
if(playRepo === undefined) { const db = await getRoot().items.db(); playRepo = new DrizzlePlayRepository(db); playHistoricalRepo = new DrizzlePlayHistoricalRepository(db); }
const metricsString = await prom.register.metrics(); return res .status(200) .set('Content-Type', 'text/plain') .send(metricsString);
});
app.get('/api/version', async (req, res) => { return res.json({version: root.get('version')}); });
app.use('/api/*path', async (req, res) => { const remote = req.connection.remoteAddress; const proxyRemote = req.headers["x-forwarded-for"]; const ua = req.headers["user-agent"]; logger.debug(`Server received ${req.method} request from ${remote}${proxyRemote !== undefined ? ` (${proxyRemote})` : ''}${ua !== undefined ? ` (UA: ${ua})` : ''} to unknown route: ${req.originalUrl}`); return res.sendStatus(404); });}