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.
39 kB · 926 lines
TypeScript
at debugStartup
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927import 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 { CLIENT_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,} 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 type 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 { type ComponentAwareRequest, makeClientCheckMiddle, makeClientNextMiddle, makeComponentMiddle, makeSourceCheckMiddle, makeSourceNextMiddle, type SourceAwareRequest } 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 { 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 type {ComponentClientApiJson, ComponentSourceApiJson} from "../../core/Api.ts";import { asDayjsHydratedObject } from "../../core/DataUtils.ts";import type {Dayjs} from "dayjs";import { asSerializablePlaySelect } from "../../core/PlayMarshalUtils.ts";
const maxBufferSize = 300;const output: Record<number, FixedSizeList<LogDataPretty>> = {};
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, 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}); });
app.get('/api/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.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.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, logger, sourceRequiredMiddle, clientRequiredMiddle, scrobbleSources, scrobbleClients);
app.put('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => { logger.info(req.body); res.sendStatus(200); });
app.get('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => { logger.info(req.body); res.sendStatus(200); });
app.get('/api/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]); });
app.get('/api/sources/:componentVal/players', sourceAwareMiddle, async (req: SourceAwareRequest, res, next) => { if(req.component instanceof MemorySource) { return res.json(req.component.playersToObject()); } return res.json({}); }); app.get('/api/components/:componentVal/players', componentAwareMiddle, async (req: ComponentAwareRequest, 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({}); });
app.get('/api/sources/:componentVal/players/:platformId', sourceAwareMiddle, async (req: SourceAwareRequest, 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({}); }); app.get('/api/components/:componentVal/players/:platformId', componentAwareMiddle, async (req: ComponentAwareRequest, 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`}); });
app.get('/api/components/:componentVal', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => { const { component, } = req; return res.json(component.getApiData()); });
app.get('/api/components/:componentVal/plays', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => { const { component, query } = req;
const hydratedQuery = asDayjsHydratedObject<QueryPlaysOptsJson, QueryPlaysOpts<Dayjs>>(query); const playRes = await component.getPlaysPaginated(hydratedQuery);
// @ts-expect-error playRes.data = playRes.data.map(x => asSerializablePlaySelect(x)) //PlayApiCommonDetailed // plus paginatioon return res.json(playRes); });
app.get('/api/components/:componentVal/plays/:playUid', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => { const { component, query, params: { playUid } } = req;
const playRes = await component.getPlayApiResponse(playUid as string); //PlayApiCommonDetailed // plus paginatioon return res.json(asSerializablePlaySelect(playRes)); });
app.delete('/api/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 * * * */
app.get('/api/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}); });
app.get('/api/recent', sourceMiddleFunc(false), async (req, res, next) => { const { // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message 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); });
app.get('/api/source/art', sourceMiddleFunc(false), async (req, res, next) => { const { // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message 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`}); } });
app.get('/api/dead', clientMiddleFunc(true), async (req, res, next) => { const { // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message scrobbleClient: client, query = {} } = req;
const deadQuery: QueryPlaysOpts = { ...query as Partial<QueryPlaysOpts>, queues: [ { queueName: CLIENT_DEAD_QUEUE, queueStatus: ['queued','failed'] } ] }
// @ts-ignore const result: DeadLetterScrobble<PlayObject>[] = (await (client as AbstractScrobbleClient).getPlaysPaginated(deadQuery)).data.map(x => playSelectToDeadScrobble(x, true));
return res.json(result); });
app.put('/api/dead', clientMiddleFunc(true), async (req, res, next) => { const { // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message 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)); });
app.put('/api/dead/:id', clientMiddleFunc(true), async (req, res, next) => { const { // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message 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 [scrobbled, dead] = await (client as AbstractScrobbleClient).processDeadLetterScrobble(deadId); if(scrobbled) { return res.status(200).send(); } return res.json(playSelectToDeadScrobble(dead, 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}); } });
app.delete('/api/dead', clientMiddleFunc(true), async (req, res, next) => { const { // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message scrobbleClient: client, } = req;
(client as AbstractScrobbleClient).logger.verbose('User requested deletion of all dead letter scrobbles via API');
(client as AbstractScrobbleClient).removeDeadLetterScrobbles(['queued', 'failed'], 'failed', false).then(() => null).catch((e) => logger.error(e));
return res.sendStatus(200); });
app.delete('/api/dead/:id', clientMiddleFunc(true), async (req, res, next) => { const { // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message 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`)
try { await (client as AbstractScrobbleClient).removeDeadLetterScrobble(deadId,'failed', false); return res.status(200).send(); } 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}); } });
app.get('/api/scrobbled', clientMiddleFunc(false), async (req, res, next) => { const { // @ts-expect-error scrobbleClient not part of req 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); });
app.use('/api/source/init', sourceRequiredMiddle); app.post('/api/source/init', async (req, res) => { // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message 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)); } });
app.use('/api/source/listen', sourceRequiredMiddle); app.post('/api/source/listen', async (req, res) => { // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message 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}); });
app.use('/api/client/listen', clientRequiredMiddle); app.post('/api/client/listen', async (req, res) => { // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message 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}); });
app.use('/api/client/init', clientRequiredMiddle); app.post('/api/client/init', async (req, res) => { // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message 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'); });
app.post('/api/client/historical', clientRequiredMiddle, async (req, res) => { // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message 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)}`)); app.get('/api/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); });}