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

Configure Feed

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

feat: Implement database play prometheus stats

* Total plays by component/state
* Total historical plays by client

FoxxMD (Jun 5, 2026, 2:54 PM UTC) 07d64878 6222aa58

+98 -2
+2
src/backend/common/AbstractComponent.ts
··· 50 50 protected db: DbConcrete; 51 51 protected componentRepo!: DrizzleComponentRepository; 52 52 protected dbComponent!: ComponentSelect; 53 + componentId!: number; 53 54 protected retentionOpts: RetentionOptions; 54 55 55 56 protected componentType: 'source' | 'client'; ··· 100 101 uid: this.config.id ?? this.config.name ?? name, 101 102 name: this.config.name ?? name 102 103 }); 104 + this.componentId = this.dbComponent.id; 103 105 return true; 104 106 } 105 107
+7
src/backend/common/database/drizzle/repositories/PlayHistoricalRepository.ts
··· 283 283 where, 284 284 })) as PlayHistoricalSelect[]).map(x => ({...x, play: hydratePlaySelect(x)})); 285 285 } 286 + 287 + public getPlayCountByComponent = async () => { 288 + 289 + const res = await this.db.all(sql`select componentId, count(*) from plays_historical p 290 + group by componentId;`); 291 + return res; 292 + } 286 293 } 287 294 288 295 export const buildPlayHistoricalWhere = (args: PlayWhereOpts): WhereClause<'playsHistorical'> => {
+15
src/backend/common/database/drizzle/repositories/PlayRepository.ts
··· 690 690 with: buildPlayWith(qWith) 691 691 })) as PlayWith<'queueStates'>[]).map(x => ({...x, play: hydratePlaySelect(x)})); 692 692 } 693 + 694 + public getComponentPlayCountByState = async (componentId?: string) => { 695 + 696 + const res = await this.db.all(sql`select state, count(*) from plays p 697 + where componentId = ${componentId ?? this.componentId} 698 + group by state;`); 699 + return res; 700 + } 701 + 702 + public getPlayCountByState = async () => { 703 + 704 + const res = await this.db.all(sql`select state,componentId, count(*) from plays p 705 + group by state,componentId;`); 706 + return res; 707 + } 693 708 } 694 709 695 710 export const getTemporallyCloseDateCompareOp = (play: PlayObject, opts: {bufferTime?: number, useCompleted?: boolean} = {}): CompareDateOp => {
+63 -1
src/backend/server/api.ts
··· 11 11 DeadLetterScrobble, 12 12 LeveledLogData, 13 13 LogOutputConfig, 14 + PLAY_CLIENT_STATE, 15 + PLAY_SOURCE_STATE, 14 16 PlayObject, 15 17 SOURCE_SOT, 16 18 SOURCE_SOT_TYPES, ··· 35 37 import ScrobbleClients from "../scrobblers/ScrobbleClients.js"; 36 38 import prom from 'prom-client'; 37 39 import { SimpleError } from "../common/errors/MSErrors.js"; 38 - import { QueryPlaysOpts } from "../common/database/drizzle/repositories/PlayRepository.js"; 40 + import { DrizzlePlayRepository, QueryPlaysOpts } from "../common/database/drizzle/repositories/PlayRepository.js"; 39 41 import { playSelectToDeadScrobble } from "../common/database/drizzle/entityUtils.js"; 40 42 import AbstractHistoricalScrobbleClient from "../scrobblers/AbstractHistoricalScrobbleClient.js"; 43 + import { DrizzlePlayHistoricalRepository } from "../common/database/drizzle/repositories/PlayHistoricalRepository.js"; 41 44 42 45 const maxBufferSize = 300; 43 46 const output: Record<number, FixedSizeList<LogDataPretty>> = {}; ··· 588 591 } 589 592 }); 590 593 594 + 595 + let playRepo: DrizzlePlayRepository, 596 + playHistoricalRepo: DrizzlePlayHistoricalRepository; 597 + 598 + const sourcePlays = new prom.Gauge({ 599 + name: 'multiscrobbler_source_plays', 600 + help: 'Count of stored plays by state for Sources', 601 + labelNames: ['name', 'type'], 602 + async collect() { 603 + const res = await playRepo.getPlayCountByState(); 604 + for(const source of scrobbleSources.sources) { 605 + const relevant = res.filter(x => x['componentId'] === source.componentId); 606 + for(const s of PLAY_SOURCE_STATE) { 607 + const rel = relevant.find(x => x['state'] === s); 608 + const count = rel === undefined ? 0 : rel['count(*)']; 609 + this.labels({name: source.getSafeExternalName(), type: s}).set(count); 610 + } 611 + } 612 + } 613 + }); 614 + const clientPlays = new prom.Gauge({ 615 + name: 'multiscrobbler_client_plays', 616 + help: 'Count of stored plays by state for Clients', 617 + labelNames: ['name', 'type'], 618 + async collect() { 619 + const res = await playRepo.getPlayCountByState(); 620 + for(const client of scrobbleClients.clients) { 621 + const relevant = res.filter(x => x['componentId'] === client.componentId); 622 + for(const s of PLAY_CLIENT_STATE) { 623 + const rel = relevant.find(x => x['state'] === s); 624 + const count = rel === undefined ? 0 : rel['count(*)']; 625 + this.labels({name: client.getSafeExternalName(), type: s}).set(count); 626 + } 627 + } 628 + } 629 + }); 630 + const clientHistoricalPlays = new prom.Gauge({ 631 + name: 'multiscrobbler_client_historical_plays', 632 + help: 'Count of stored historical plays for Clients', 633 + labelNames: ['name', 'type'], 634 + async collect() { 635 + const res = await playHistoricalRepo.getPlayCountByComponent(); 636 + for(const client of scrobbleClients.clients) { 637 + if(client instanceof AbstractHistoricalScrobbleClient) { 638 + const relevant = res.filter(x => x['componentId'] === client.componentId); 639 + for(const rel of relevant) { 640 + this.labels({name: client.getSafeExternalName()}).set(rel['count(*)']); 641 + } 642 + } 643 + } 644 + } 645 + }); 646 + 591 647 if(process.env.PROMETHEUS_FULL === 'true') { 592 648 prom.collectDefaultMetrics(); 593 649 } 594 650 595 651 app.get('/api/metrics', async (req, res) => { 652 + 653 + if(playRepo === undefined) { 654 + const db = await getRoot().items.db(); 655 + playRepo = new DrizzlePlayRepository(db); 656 + playHistoricalRepo = new DrizzlePlayHistoricalRepository(db); 657 + } 596 658 597 659 const metricsString = await prom.register.metrics(); 598 660 return res
+11 -1
src/core/Atomic.ts
··· 622 622 (<G>() => G extends T ? 1 : 2) extends 623 623 (<G>() => G extends U ? 1 : 2) ? Y : N; 624 624 625 - export type MBID = `${string}-${string}-${string}-${string}-${string}` 625 + export type MBID = `${string}-${string}-${string}-${string}-${string}` 626 + 627 + // ['queued','discovered','discarded','scrobbled','failed','duped'] 628 + export type PlayStateCommon = 'queued' |'discarded' | 'failed'; 629 + export const PLAY_STATE_COMMON: PlayStateCommon[] = ['queued', 'discarded', 'failed']; 630 + export type PlaySourceState = PlayStateCommon | 'discovered'; 631 + export const PLAY_SOURCE_STATE: PlaySourceState[] = [...PLAY_STATE_COMMON, 'discovered']; 632 + export type PlayClientState = PlayStateCommon | 'duped' | 'scrobbled'; 633 + export const PLAY_CLIENT_STATE = [...PLAY_STATE_COMMON, 'duped', 'scrobbled']; 634 + export type PlayState = PlaySourceState | PlayClientState; 635 + export const PLAY_STATES = Array.from(new Set(...PLAY_CLIENT_STATE, ...PLAY_SOURCE_STATE));