[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(ui): Implement static component list in app entrypoint

FoxxMD (Jun 15, 2026, 2:43 PM UTC) bf8f1175 4bfcc405

+216 -11
+16 -1
src/backend/common/AbstractComponent.ts
··· 30 30 import { RetentionOptions } from "./infrastructure/config/database.js"; 31 31 import { getRetentionCompactAfterFromEnv, getRetentionDeleteAfterFromEnv, isCompactableProperty, parseRetentionOptions, parseRetentionOptionsDurations } from "./database/Database.js"; 32 32 import { DbConcrete } from "./database/drizzle/drizzleUtils.js"; 33 - import { ComponentSelect } from "./database/drizzle/drizzleTypes.js"; 33 + import { ComponentMinimalSelect, ComponentSelect } from "./database/drizzle/drizzleTypes.js"; 34 34 import { DrizzlePlayRepository } from "./database/drizzle/repositories/PlayRepository.js"; 35 35 import { ClientType } from "./infrastructure/config/client/clients.js"; 36 36 import { SourceType } from "./infrastructure/config/source/sources.js"; ··· 501 501 } 502 502 503 503 return [step, newTransformedPlay]; 504 + } 505 + 506 + public getApiData(): Omit<ComponentMinimalSelect, 'type'> { 507 + return { 508 + id: this.dbComponent.id, 509 + uid: this.dbComponent.uid, 510 + name: this.dbComponent.name, 511 + mode: this.dbComponent.mode, 512 + countLive: this.dbComponent.countLive, 513 + countNonLive: this.dbComponent.countNonLive, 514 + createdAt: this.dbComponent.createdAt, 515 + lastReadyAt: this.dbComponent.lastReadyAt, 516 + lastActiveAt: this.dbComponent.lastActiveAt, 517 + ...this.additionalApiData() 518 + } 504 519 } 505 520 }
+22
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 83 83 import { DrizzleQueueRepository } from "../common/database/drizzle/repositories/QueueRepository.js"; 84 84 import { GenericRepository } from "../common/database/drizzle/repositories/BaseRepository.js"; 85 85 import assert from "node:assert"; 86 + import { ComponentClientApi } from "../../core/Api.js"; 86 87 87 88 type PlatformMappedPlays = Map<string, {player: SourcePlayerObj, source: SourceIdentifier}>; 88 89 type NowPlayingQueue = Map<string, PlatformMappedPlays>; ··· 405 406 406 407 protected getPrometheusLabels() { 407 408 return {name: this.getSafeExternalName(), type: this.type}; 409 + } 410 + 411 + protected getComponentApiData() { 412 + return { 413 + hasAuth: this.requiresAuth, 414 + hasAuthInteraction: this.requiresAuthInteraction, 415 + authed: this.authed, 416 + } 417 + } 418 + 419 + public getApiData(): ComponentClientApi { 420 + return { 421 + ...super.getApiData(), 422 + ...this.getComponentApiData(), 423 + type: this.type, 424 + state: 'idle', 425 + status: 'idle', 426 + queued: this.queuedLength, 427 + deadLetterScrobbles: this.deadLetterQueued, 428 + deadLetterScrobblesTotal: this.deadLetterLength, 429 + } 408 430 } 409 431 410 432 public notify = async (payload: WebhookPayload) => {
+64 -1
src/backend/server/api.ts
··· 31 31 import { setupDeezerRoutes } from "./deezerRoutes.js"; 32 32 import {setupLZEndpointRoutes} from "./endpointListenbrainzRoutes.js"; 33 33 import {setupLastfmEndpointRoutes} from "./endpointLastfmRoutes.js"; 34 - import { makeClientCheckMiddle, makeSourceCheckMiddle } from "./middleware.js"; 34 + import { makeClientCheckMiddle, makeComponentMiddle, makeSourceCheckMiddle } from "./middleware.js"; 35 35 import { setupWebscrobblerRoutes } from "./webscrobblerRoutes.js"; 36 36 import ScrobbleSources from "../sources/ScrobbleSources.js"; 37 37 import ScrobbleClients from "../scrobblers/ScrobbleClients.js"; ··· 41 41 import { playSelectToDeadScrobble } from "../common/database/drizzle/entityUtils.js"; 42 42 import AbstractHistoricalScrobbleClient from "../scrobblers/AbstractHistoricalScrobbleClient.js"; 43 43 import { DrizzlePlayHistoricalRepository } from "../common/database/drizzle/repositories/PlayHistoricalRepository.js"; 44 + import { ComponentClientApi, ComponentSourceApi, ComponentSourceApiJson } from "../../core/Api.js"; 44 45 45 46 const maxBufferSize = 300; 46 47 const output: Record<number, FixedSizeList<LogDataPretty>> = {}; ··· 106 107 107 108 const clientRequiredMiddle = clientMiddleFunc(true); 108 109 const sourceRequiredMiddle = sourceMiddleFunc(true); 110 + 111 + const componentMiddle = makeComponentMiddle(scrobbleSources, scrobbleClients); 109 112 110 113 const setLogWebSettings: ExpressHandler = async (req, res, next) => { 111 114 // @ts-expect-error logLevel not part of session ··· 170 173 app.get('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => { 171 174 logger.info(req.body); 172 175 res.sendStatus(200); 176 + }); 177 + 178 + app.get('/api/components', async (req, res, next) => { 179 + 180 + const sourceData = scrobbleSources.sources.map((x) => { 181 + const { 182 + canPoll = false, 183 + polling = false, 184 + requiresAuth = false, 185 + requiresAuthInteraction = false, 186 + authed = false, 187 + } = x; 188 + const base: ComponentSourceApi = x.getApiData(); 189 + if(!x.isReady()) { 190 + if(x.buildOK === false) { 191 + base.status = 'Initializing Data Failed'; 192 + } else if(x.connectionOK === false) { 193 + base.status = 'Communication Failed'; 194 + } else if (requiresAuth && !authed) { 195 + base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted' 196 + } else { 197 + base.status = 'Not Ready'; 198 + } 199 + } else { 200 + if (canPoll) { 201 + base.status = polling ? 'Polling' : 'Idle'; 202 + } else { 203 + base.status = !x.instantiatedAt.isSame(x.lastActivityAt) ? 'Received Data' : 'Awaiting Data'; 204 + } 205 + } 206 + return base; 207 + }); 208 + 209 + 210 + const clientData = scrobbleClients.clients.map((x) => { 211 + const { 212 + requiresAuth = false, 213 + requiresAuthInteraction = false, 214 + authed = false, 215 + scrobbling = false, 216 + } = x; 217 + const base: ComponentClientApi = x.getApiData(); 218 + 219 + if (!x.isReady()) { 220 + if(x.buildOK === false) { 221 + base.status = 'Initializing Data Failed'; 222 + } else if(x.connectionOK === false) { 223 + base.status = 'Communication Failed'; 224 + } else if (requiresAuth && !authed) { 225 + base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted' 226 + } else { 227 + base.status = 'Not Ready'; 228 + } 229 + } else { 230 + base.status = scrobbling ? 'Running' : 'Idle'; 231 + } 232 + return base; 233 + }); 234 + 235 + return res.json([...sourceData, ...clientData]); 173 236 }); 174 237 175 238 app.get('/api/status', async (req, res, next) => {
+38 -2
src/backend/server/middleware.ts
··· 1 1 import { Logger } from "@foxxmd/logging"; 2 2 import { ExpressHandler } from "../common/infrastructure/Atomic.js"; 3 + import ScrobbleClients from "../scrobblers/ScrobbleClients.js"; 4 + import ScrobbleSources from "../sources/ScrobbleSources.js"; 5 + import { Request, Response, NextFunction } from "express"; 6 + import AbstractSource from "../sources/AbstractSource.js"; 7 + import AbstractScrobbleClient from "../scrobblers/AbstractScrobbleClient.js"; 3 8 4 - export const makeSourceCheckMiddle = (sources: any) => (required: boolean ): ExpressHandler => (req: any, res: any, next: any) => { 9 + export const makeSourceCheckMiddle = (sources: any) => (required: boolean): ExpressHandler => (req: any, res: any, next: any) => { 5 10 const { 6 11 query: { 7 12 name, ··· 11 16 12 17 if (required && name === undefined) { 13 18 return res.status(404).send('Source name must be defined'); 14 - } else if(name !== undefined) { 19 + } else if (name !== undefined) { 15 20 const source = sources.getByNameAndType(name, type); 16 21 17 22 if (source === undefined) { ··· 65 70 } 66 71 next(); 67 72 } 73 + 74 + export interface ComponentAwareRequest extends Request { 75 + component: AbstractSource | AbstractScrobbleClient 76 + } 77 + 78 + export const makeComponentMiddle = (sources: ScrobbleSources, clients: ScrobbleClients): ExpressHandler => async (req: Request, res: Response, next: NextFunction) => { 79 + const { 80 + params: { 81 + componentVal 82 + } 83 + } = req; 84 + 85 + const componentId = Number.parseInt(componentVal as string); 86 + if (isNaN(componentId)) { 87 + return res.status(400).json({ error: 'Component id must be a number' }); 88 + } 89 + 90 + let component: AbstractSource | AbstractScrobbleClient; 91 + component = sources.sources.find(x => x.componentId === componentId); 92 + if (component === undefined) { 93 + component = clients.clients.find(x => x.componentId === componentId); 94 + } 95 + 96 + if(component === undefined) { 97 + return res.status(404).json({error: `No Component with the Id ${componentId} exists`}); 98 + } 99 + 100 + (req as ComponentAwareRequest).component = component; 101 + 102 + next(); 103 + }
+28 -1
src/backend/sources/AbstractSource.ts
··· 2 2 import dayjs, { Dayjs } from "dayjs"; 3 3 import { EventEmitter } from "events"; 4 4 import { FixedSizeList } from "fixed-size-list"; 5 - import { JsonPlayObject, PlayMatchResult, PlayObject } from "../../core/Atomic.js"; 5 + import { JsonPlayObject, PlayMatchResult, PlayObject, SOURCE_SOT } from "../../core/Atomic.js"; 6 6 import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js"; 7 7 import AbstractComponent from "../common/AbstractComponent.js"; 8 8 import { ··· 49 49 import { DrizzlePlayRepository, playToRepositoryCreatePlayOpts, queryArgsFromRequest, QueryPlaysOpts, RequestPlayQuery } from '../common/database/drizzle/repositories/PlayRepository.js'; 50 50 import { asPlay } from '../../core/PlayMarshalUtils.js'; 51 51 import { AsyncTask, SimpleIntervalJob, ToadScheduler } from 'toad-scheduler'; 52 + import { ComponentMinimalSelect } from '../common/database/drizzle/drizzleTypes.js'; 53 + import { ComponentSourceApi, ComponentSourceApiJson } from '../../core/Api.js'; 52 54 53 55 export interface RecentlyPlayedOptions { 54 56 limit?: number ··· 258 260 259 261 protected getPrometheusLabels() { 260 262 return {name: this.getSafeExternalName(), type: this.type}; 263 + } 264 + 265 + protected getComponentApiData() { 266 + return { 267 + hasAuth: this.requiresAuth, 268 + hasAuthInteraction: this.requiresAuthInteraction, 269 + authed: this.authed, 270 + } 271 + } 272 + 273 + public getApiData(): ComponentSourceApi { 274 + return { 275 + ...super.getApiData(), 276 + ...this.getComponentApiData(), 277 + type: this.type, 278 + state: 'idle', 279 + status: 'idle', 280 + players: {}, 281 + tracksDiscovered: this.tracksDiscovered, 282 + sot: SOURCE_SOT.HISTORY, 283 + supportsUpstreamRecentlyPlayed: this.supportsUpstreamRecentlyPlayed, 284 + supportsManualListening: this.supportsManualListening, 285 + manualListening: this.manualListening, 286 + systemListeningBehavior: this.getSystemListeningBehavior(), 287 + } 261 288 } 262 289 263 290 getSystemListeningBehavior = (): boolean | undefined => {
+10 -1
src/backend/sources/MemorySource.ts
··· 2 2 import dayjs, { Dayjs } from "dayjs"; 3 3 import { EventEmitter } from "events"; 4 4 import { AsyncTask, SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler"; 5 - import { PlayObject, SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerObj } from "../../core/Atomic.js"; 5 + import { PlayObject, SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerJson, SourcePlayerObj } from "../../core/Atomic.js"; 6 6 import { buildTrackString } from "../../core/StringUtils.js"; 7 7 import { 8 8 asPlayerStateDataMaybePlay, ··· 29 29 import { GenericPlayerState } from "./PlayerState/GenericPlayerState.js"; 30 30 import { hashObject } from "../utils/StringUtils.js"; 31 31 import { useDebugValue } from "react"; 32 + import { ComponentSourceApi } from "../../core/Api.js"; 32 33 33 34 const EXPECTED_NON_DISCOVERED_REASON = 'not added because an identical play with the same timestamp was already discovered.'; 34 35 ··· 176 177 record[k] = v.getApiState(); 177 178 } 178 179 return record; 180 + } 181 + 182 + public getApiData(): ComponentSourceApi { 183 + return { 184 + ...super.getApiData(), 185 + sot: this.playerSourceOfTruth, 186 + players: this.playersToObject() as unknown as Record<string, SourcePlayerJson> 187 + } 179 188 } 180 189 181 190 getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions): AbstractPlayerState => new GenericPlayerState(logger, id, opts)
+6 -3
src/client/AppNext.tsx
··· 16 16 import {clientUpdate, sourceUpdate} from "./status/ducks"; 17 17 import {useEventSource, useEventSourceListener} from "@react-nano/use-event-source"; 18 18 import Version from "./Version"; 19 - import { MSComponentList } from './components/msComponent/MSComponentList'; 19 + import { MSComponentList, MSComponentListFetchable } from './components/msComponent/MSComponentList'; 20 20 import { Provider } from './components/Provider'; 21 + import { Container } from '@chakra-ui/react'; 21 22 22 23 function NoMatch() { 23 24 const location = useLocation(); ··· 58 59 const routes: RouteObject[] = [ 59 60 { 60 61 path: "/next", 61 - element: <MSComponentList components={[]}/>, 62 + element: <MSComponentListFetchable/>, 62 63 }, 63 64 { 64 65 path: "/docs", ··· 112 113 function App() { 113 114 return ( 114 115 <Provider> 115 - <RouterProvider router={router}/> 116 + <Container maxWidth="8xl"> 117 + <RouterProvider router={router}/> 118 + </Container> 116 119 </Provider> 117 120 ); 118 121 }
+32 -2
src/client/components/msComponent/MSComponentList.tsx
··· 1 1 import React, { ComponentProps, useMemo, useState, forwardRef, Fragment } from "react" 2 - import { CheckboxCard, Span, Stack, SegmentGroup, Text, Box, Center, Heading, Button, Separator, HStack, Flex, Badge } from '@chakra-ui/react'; 2 + import { Stack, SegmentGroup, Text, Box, Center, Card, SkeletonText } from '@chakra-ui/react'; 3 3 import { ComponentsApiJson } from "../../../core/Api"; 4 - import { components } from "storybook/internal/components"; 5 4 import { MSComponentSummary } from "./MSComponentSummary"; 5 + import { QueryFunctionContext, queryOptions, useQuery } from '@tanstack/react-query'; 6 + import ky from 'ky'; 7 + import { baseUrl } from "../../utils"; 8 + import { ErrorAlert } from "../ErrorAlert"; 6 9 7 10 export interface ComponentListProps { 8 11 components: ComponentsApiJson[] ··· 31 34 </Stack> 32 35 </Stack> 33 36 ) 37 + } 38 + 39 + export const MSComponentListFetchable = () => { 40 + const { isPending, isError, data, error } = useQuery({ 41 + queryKey: ['components'], 42 + queryFn: queryFn 43 + }); 44 + 45 + if(isPending) { 46 + return (<Card.Root variant="subtle"> 47 + <Card.Header> 48 + <SkeletonText noOfLines={2}/> 49 + </Card.Header> 50 + <Card.Footer/> 51 + </Card.Root>) 52 + } 53 + 54 + if(isError) { 55 + return <ErrorAlert error={error}/> 56 + } 57 + 58 + return <MSComponentList components={data}/> 59 + } 60 + 61 + type ComponentListQueryKey = ['components']; 62 + const queryFn = async (context: QueryFunctionContext<ComponentListQueryKey>) => { 63 + return await ky.get(`components`, { baseUrl: baseUrl }).json() as ComponentsApiJson[]; 34 64 }