import 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> = {}; 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, logger: Logger, appLoggerStream: PassThrough, initialLogOutput: LogDataPretty[] = [], scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => { for(const level of Object.keys(logger.levels.labels)) { output[level] = new FixedSizeList(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>(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 : {}, 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, queues: [ { queueName: DEAD_QUEUE, queueStatus: ['queued','failed'] } ] } const result: DeadLetterScrobble[] = (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 = { ...query as Partial, 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); }); }