[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.

POC player live update

FoxxMD (Jun 15, 2026, 5:17 PM UTC) 9bb48fbf bf8f1175

+182 -4
+30
package-lock.json
··· 125 125 "@storybook/react-vite": "^10.4.3", 126 126 "@tailwindcss/vite": "^4.2.2", 127 127 "@tanstack/react-query": "^5.101.0", 128 + "@tanstack/react-query-devtools": "^5.101.0", 128 129 "@tsconfig/node18": "^1.0.1", 129 130 "@types/chai": "^4.3.0", 130 131 "@types/chai-as-promised": "^7.1.5", ··· 5184 5185 "url": "https://github.com/sponsors/tannerlinsley" 5185 5186 } 5186 5187 }, 5188 + "node_modules/@tanstack/query-devtools": { 5189 + "version": "5.101.0", 5190 + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.0.tgz", 5191 + "integrity": "sha512-MVqw17k08RQtGGLEL654+dX/btbX9p/8WjkznO//zusLTMaObxi3Q+MoFwGVkC9K3tqjn8qrrNhJevXx4fJTeQ==", 5192 + "dev": true, 5193 + "license": "MIT", 5194 + "funding": { 5195 + "type": "github", 5196 + "url": "https://github.com/sponsors/tannerlinsley" 5197 + } 5198 + }, 5187 5199 "node_modules/@tanstack/react-query": { 5188 5200 "version": "5.101.0", 5189 5201 "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", ··· 5198 5210 "url": "https://github.com/sponsors/tannerlinsley" 5199 5211 }, 5200 5212 "peerDependencies": { 5213 + "react": "^18 || ^19" 5214 + } 5215 + }, 5216 + "node_modules/@tanstack/react-query-devtools": { 5217 + "version": "5.101.0", 5218 + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.0.tgz", 5219 + "integrity": "sha512-cpZA0+WqKXwrwMfiWZEGGF6QrIWVQFbhBtxqDF5sQsAfrFf47HIE6fiPbQU3wyAUEN2+7UNqLCQe7oG6m3f93w==", 5220 + "dev": true, 5221 + "license": "MIT", 5222 + "dependencies": { 5223 + "@tanstack/query-devtools": "5.101.0" 5224 + }, 5225 + "funding": { 5226 + "type": "github", 5227 + "url": "https://github.com/sponsors/tannerlinsley" 5228 + }, 5229 + "peerDependencies": { 5230 + "@tanstack/react-query": "^5.101.0", 5201 5231 "react": "^18 || ^19" 5202 5232 } 5203 5233 },
+1
package.json
··· 164 164 "@storybook/react-vite": "^10.4.3", 165 165 "@tailwindcss/vite": "^4.2.2", 166 166 "@tanstack/react-query": "^5.101.0", 167 + "@tanstack/react-query-devtools": "^5.101.0", 167 168 "@tsconfig/node18": "^1.0.1", 168 169 "@types/chai": "^4.3.0", 169 170 "@types/chai-as-promised": "^7.1.5",
+1
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 1731 1731 data: payload, 1732 1732 type: this.type, 1733 1733 name: this.name, 1734 + componentId: this.dbComponent.id, 1734 1735 from: 'client' 1735 1736 }); 1736 1737 }
+25 -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, makeComponentMiddle, makeSourceCheckMiddle } from "./middleware.js"; 34 + import { makeClientCheckMiddle, makeComponentMiddle, makeSourceCheckMiddle, makeSourceNextMiddle, SourceAwareRequest } 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"; ··· 109 109 const sourceRequiredMiddle = sourceMiddleFunc(true); 110 110 111 111 const componentMiddle = makeComponentMiddle(scrobbleSources, scrobbleClients); 112 + const sourceAwareMiddle = makeSourceNextMiddle(scrobbleSources); 112 113 113 114 const setLogWebSettings: ExpressHandler = async (req, res, next) => { 114 115 // @ts-expect-error logLevel not part of session ··· 233 234 }); 234 235 235 236 return res.json([...sourceData, ...clientData]); 237 + }); 238 + 239 + app.get('/api/sources/:componentVal/players', sourceAwareMiddle, async (req: SourceAwareRequest, res, next) => { 240 + if(req.component instanceof MemorySource) { 241 + return res.json(req.component.playersToObject()); 242 + } 243 + return res.json({}); 244 + }); 245 + 246 + app.get('/api/sources/:componentVal/players/:platformId', sourceAwareMiddle, async (req: SourceAwareRequest, res, next) => { 247 + if(req.component instanceof MemorySource) { 248 + const { 249 + params: { 250 + platformId 251 + } 252 + } = req; 253 + const player = req.component.players.get(platformId as string); 254 + if(player === undefined) { 255 + return res.status(400).json({error: `No player with platform id ${platformId} exists`}); 256 + } 257 + return res.json(player); 258 + } 259 + return res.json({}); 236 260 }); 237 261 238 262 app.get('/api/status', async (req, res, next) => {
+27
src/backend/server/middleware.ts
··· 100 100 (req as ComponentAwareRequest).component = component; 101 101 102 102 next(); 103 + } 104 + 105 + export interface SourceAwareRequest extends Request { 106 + component: AbstractSource 107 + } 108 + 109 + export const makeSourceNextMiddle = (sources: ScrobbleSources): ExpressHandler => async (req: Request, res: Response, next: NextFunction) => { 110 + const { 111 + params: { 112 + componentVal 113 + } 114 + } = req; 115 + 116 + const componentId = Number.parseInt(componentVal as string); 117 + if (isNaN(componentId)) { 118 + return res.status(400).json({ error: 'Source Id must be a number' }); 119 + } 120 + 121 + let component: AbstractSource; 122 + component = sources.sources.find(x => x.componentId === componentId); 123 + if(component === undefined) { 124 + return res.status(404).json({error: `No Source with the Id ${componentId} exists`}); 125 + } 126 + 127 + (req as SourceAwareRequest).component = component; 128 + 129 + next(); 103 130 }
+1
src/backend/sources/AbstractSource.ts
··· 805 805 this.emitter.emit(eventName, { 806 806 type: this.type, 807 807 name: this.name, 808 + componentId: this.dbComponent.id, 808 809 from: 'source', 809 810 data: payload, 810 811 });
+43
src/client/AppNext.tsx
··· 19 19 import { MSComponentList, MSComponentListFetchable } from './components/msComponent/MSComponentList'; 20 20 import { Provider } from './components/Provider'; 21 21 import { Container } from '@chakra-ui/react'; 22 + import { useQueryClient } from '@tanstack/react-query' 23 + import { ComponentsApiJson, ComponentSourceApiJson } from '../core/Api'; 22 24 23 25 function NoMatch() { 24 26 const location = useLocation(); ··· 110 112 111 113 // const ConnectedGlobal = connector(Global); 112 114 115 + const Global = (props) => { 116 + 117 + const queryClient = useQueryClient(); 118 + const [sourceEventSource, eventSourceStatus] = useEventSource("/api/events", false); 119 + useEventSourceListener(sourceEventSource, ['source', 'client'], evt => { 120 + const data = JSON.parse(evt.data); 121 + if(data.event === 'playerUpdate') { 122 + 123 + console.log(`Updated => Component ${data.componentId} Player ${data.data.platformId}`); 124 + queryClient.setQueryData(['components', data.componentId, 'players', data.data.platformId], data.data); 125 + 126 + // queryClient.setQueryData(['components'], (old: ComponentsApiJson[]) => { 127 + // const componentIndex = old.findIndex(x => x.id === data.componentId); 128 + // if(componentIndex !== -1) { 129 + // console.log(`Updated => Component ${data.componentId} Player ${data.data.platformId}`); 130 + // //(old[componentIndex] as ComponentSourceApiJson).players[data.data.platformId] = data.data; 131 + // const newData = [...old]; 132 + // newData[componentIndex] = { 133 + // ...old[componentIndex], 134 + // players: { 135 + // // @ts-ignore 136 + // ...old[componentIndex].players, 137 + // [data.data.platformId]: data.data 138 + // } 139 + // } 140 + // return newData; 141 + // } 142 + // //console.log(old); 143 + // }); 144 + } 145 + if(data.from === 'source') { 146 + console.log(data); 147 + } else if(data.from === 'client') { 148 + console.log(data); 149 + } 150 + }, []); 151 + 152 + return <span/>; 153 + } 154 + 113 155 function App() { 114 156 return ( 115 157 <Provider> 116 158 <Container maxWidth="8xl"> 159 + <Global/> 117 160 <RouterProvider router={router}/> 118 161 </Container> 119 162 </Provider>
+2
src/client/components/Provider.tsx
··· 12 12 QueryClient, 13 13 QueryClientProvider, 14 14 } from '@tanstack/react-query' 15 + import { ReactQueryDevtools } from '@tanstack/react-query-devtools' 15 16 16 17 const queryClient = new QueryClient() 17 18 ··· 21 22 <ChakraProvider value={defaultSystem}> 22 23 <ColorModeProvider {...props} /> 23 24 </ChakraProvider> 25 + <ReactQueryDevtools initialIsOpen={true} /> 24 26 </QueryClientProvider> 25 27 ) 26 28 }
+49 -1
src/client/components/chakraPlayer/Player.tsx
··· 1 - import React, { ComponentProps, useMemo, forwardRef, Fragment } from "react" 1 + import React, { ComponentProps, useMemo, forwardRef, Fragment, useEffect } from "react" 2 2 import { Accordion, Progress, For, Span, Stack, Spacer, Text, Image, Box, Heading, AbsoluteCenter, Button, Separator, HStack, Flex, Center, Badge, IconButton, Container, Collapsible, Card, LinkOverlay, LinkBox } from '@chakra-ui/react'; 3 3 import { TextMuted } from "../TextMuted"; 4 4 import { SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerJson } from "../../../core/Atomic"; 5 5 import { timeToHumanTimestamp } from "../../../core/TimeUtils"; 6 6 import { capitalize } from "../../../core/StringUtils"; 7 + import { QueryFunctionContext, queryOptions, useQuery, useQueryClient } from '@tanstack/react-query'; 8 + import { ErrorAlert } from "../ErrorAlert"; 9 + import ky from 'ky'; 10 + import { baseUrl } from "../../utils"; 7 11 8 12 export interface PlayerProps { 9 13 data: SourcePlayerJson ··· 81 85 </Flex> 82 86 </Stack> 83 87 88 + } 89 + 90 + export interface ChakraPlayerFetchableProps { 91 + componentId: number 92 + platformId: string 93 + data: SourcePlayerJson 94 + sot?: SOURCE_SOT_TYPES 95 + } 96 + 97 + export const ChakraPlayerFetchable = (props: ChakraPlayerFetchableProps) => { 98 + const { 99 + componentId, 100 + platformId, 101 + data: initData, 102 + sot 103 + } = props; 104 + const queryClient = useQueryClient(); 105 + useEffect(() => { 106 + if(initData !== undefined) { 107 + queryClient.setQueryData(['components', componentId, 'players', platformId], initData); 108 + } 109 + },[initData]); 110 + 111 + const { isPending, isError, data, error } = useQuery({ 112 + queryKey: ['components', componentId, 'players', platformId], 113 + queryFn: queryFn, 114 + structuralSharing: false, 115 + staleTime: Infinity, 116 + }); 117 + 118 + if(isError) { 119 + return <ErrorAlert error={error}/> 120 + } 121 + 122 + console.log('rendering player'); 123 + 124 + if(!isPending) { 125 + return <ChakraPlayer data={data}/> 126 + } 127 + } 128 + 129 + type PlayerQueryKey = ['components', number, 'players', string]; 130 + const queryFn = async (context: QueryFunctionContext<PlayerQueryKey>) => { 131 + return await ky.get(`sources/${context.queryKey[1]}/players/${context.queryKey[3]}`, { baseUrl: baseUrl }).json() as SourcePlayerJson; 84 132 }
+1
src/client/components/msComponent/MSComponentList.tsx
··· 12 12 } 13 13 14 14 export const MSComponentList = (props: ComponentListProps) => { 15 + console.log('rendering component list'); 15 16 const [shownType, setShownType] = useState("All"); 16 17 return ( 17 18 <Stack gap="3">
+2 -2
src/client/components/msComponent/MSComponentSummary.tsx
··· 6 6 import { capitalize } from "../../../core/StringUtils.js"; 7 7 import { ShortDateDisplay } from "../DateDisplay.js"; 8 8 import { ChevronRightButton } from "../icons/ChakraIcons.js"; 9 - import { ChakraPlayer } from "../chakraPlayer/Player.js"; 9 + import { ChakraPlayer, ChakraPlayerFetchable } from "../chakraPlayer/Player.js"; 10 10 import { InfoTip } from "../ToggleTip.js"; 11 11 12 12 export const MSComponentSummary = (props: { data: ComponentCommonApiJson }) => { ··· 27 27 body = (<Card.Body px="3" py="2" paddingTop="3"> 28 28 <Stack gap="2"> 29 29 { 30 - Object.values(players).map((x) => <Container bg="bg.emphasized" borderWidth="1px" p="2" py="3" rounded="md"><ChakraPlayer data={x}/></Container>) 30 + Object.entries(players).map(([key, x]) => <Container bg="bg.emphasized" borderWidth="1px" p="2" py="3" rounded="md"><ChakraPlayerFetchable componentId={data.id} platformId={key} data={x}/></Container>) 31 31 } 32 32 </Stack> 33 33 </Card.Body>);