[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 live updates with sse

FoxxMD (Jun 15, 2026, 7:17 PM UTC) d0736422 9bb48fbf

+181 -95
+21
package-lock.json
··· 114 114 "@emotion/react": "^11.14.0", 115 115 "@eslint/js": "^10.0.1", 116 116 "@faker-js/faker": "^9.0.1", 117 + "@flamefrontend/sse-runtime-react": "^0.7.0", 117 118 "@fortawesome/fontawesome-svg-core": "^6.7.2", 118 119 "@fortawesome/free-solid-svg-icons": "^6.7.2", 119 120 "@fortawesome/react-fontawesome": "^0.2.6", ··· 1841 1842 "engines": { 1842 1843 "node": ">=18.0.0", 1843 1844 "npm": ">=9.0.0" 1845 + } 1846 + }, 1847 + "node_modules/@flamefrontend/sse-runtime-core": { 1848 + "version": "0.10.0", 1849 + "resolved": "https://registry.npmjs.org/@flamefrontend/sse-runtime-core/-/sse-runtime-core-0.10.0.tgz", 1850 + "integrity": "sha512-TUd7Uv/NkYI0qCv2EzsCRnCLqBRxpMJDc4CZVUcpxIZ6ol8gOW8SAvIm3COyRrqkDCZjOX6hHoQjtYIiOuA6OQ==", 1851 + "dev": true, 1852 + "license": "MIT" 1853 + }, 1854 + "node_modules/@flamefrontend/sse-runtime-react": { 1855 + "version": "0.7.0", 1856 + "resolved": "https://registry.npmjs.org/@flamefrontend/sse-runtime-react/-/sse-runtime-react-0.7.0.tgz", 1857 + "integrity": "sha512-djPvMmeNTLRS+78vDjSh6iVRiPg92f9KJtZ9d1YAmlwnzxIjg80VlyUAcOOjb0xhfMNkkDcG+v+iLVaqkgIY7Q==", 1858 + "dev": true, 1859 + "license": "MIT", 1860 + "dependencies": { 1861 + "@flamefrontend/sse-runtime-core": "0.10.0" 1862 + }, 1863 + "peerDependencies": { 1864 + "react": "^18.3.0 || ^19.0.0" 1844 1865 } 1845 1866 }, 1846 1867 "node_modules/@floating-ui/core": {
+1
package.json
··· 153 153 "@emotion/react": "^11.14.0", 154 154 "@eslint/js": "^10.0.1", 155 155 "@faker-js/faker": "^9.0.1", 156 + "@flamefrontend/sse-runtime-react": "^0.7.0", 156 157 "@fortawesome/fontawesome-svg-core": "^6.7.2", 157 158 "@fortawesome/free-solid-svg-icons": "^6.7.2", 158 159 "@fortawesome/react-fontawesome": "^0.2.6",
+18 -2
src/backend/server/api.ts
··· 147 147 }); 148 148 149 149 app.get('/api/events', async (req, res) => { 150 + const { 151 + query: { 152 + next: nextQs 153 + } 154 + } = req; 155 + 156 + const isNextapi = nextQs === 'true'; 157 + 150 158 const session = await bsseDef.createSession(req, res); 151 159 scrobbleSources.emitter.on('*', (payload: any, eventName: string) => { 152 160 if(payload.from !== undefined) { 153 - session.push({event: eventName, ...payload}, payload.from); 161 + if(isNextapi) { 162 + session.push({event: eventName, ...payload}, eventName); 163 + } else { 164 + session.push({event: eventName, ...payload}, payload.from); 165 + } 154 166 } 155 167 }); 156 168 scrobbleClients.emitter.on('*', (payload: any, eventName: string) => { 157 169 if(payload.from !== undefined) { 158 - session.push({event: eventName, ...payload}, payload.from); 170 + if(isNextapi) { 171 + session.push({event: eventName, ...payload}, eventName); 172 + } else { 173 + session.push({event: eventName, ...payload}, payload.from); 174 + } 159 175 } 160 176 }); 161 177 });
+9 -77
src/client/AppNext.tsx
··· 13 13 import RecentPage from "./recent/RecentPage"; 14 14 import ScrobbledPage from "./scrobbled/ScrobbledPage"; 15 15 import DeadPage from "./deadLetter/DeadPage"; 16 - import {clientUpdate, sourceUpdate} from "./status/ducks"; 17 - import {useEventSource, useEventSourceListener} from "@react-nano/use-event-source"; 18 16 import Version from "./Version"; 19 17 import { MSComponentList, MSComponentListFetchable } from './components/msComponent/MSComponentList'; 20 18 import { Provider } from './components/Provider'; 21 - import { Container } from '@chakra-ui/react'; 22 - import { useQueryClient } from '@tanstack/react-query' 23 - import { ComponentsApiJson, ComponentSourceApiJson } from '../core/Api'; 19 + import { Container, Box, Center } from '@chakra-ui/react'; 20 + import { MsSseEvent } from '../core/Api'; 21 + import { SSEProvider } from "@flamefrontend/sse-runtime-react"; 24 22 25 23 function NoMatch() { 26 24 const location = useLocation(); ··· 61 59 const routes: RouteObject[] = [ 62 60 { 63 61 path: "/next", 64 - element: <MSComponentListFetchable/>, 62 + element: <Center><MSComponentListFetchable/></Center>, 65 63 }, 66 64 { 67 65 path: "/docs", ··· 80 78 81 79 const router = genRouter(); 82 80 83 - // const mapDispatchToProps = (dispatch) => { 84 - // return { 85 - // updateSource: (payload) => dispatch(sourceUpdate(payload)), 86 - // updateClient: (payload) => dispatch(clientUpdate(payload)) 87 - // } 88 - // } 89 - 90 - // const connector = connect(null, mapDispatchToProps); 91 - 92 - // type PropsFromRedux = ConnectedProps<typeof connector>; 93 - 94 - // const Global = (props: PropsFromRedux) => { 95 - // const { 96 - // updateSource, 97 - // updateClient 98 - // } = props; 99 - 100 - // const [sourceEventSource, eventSourceStatus] = useEventSource("api/events", false); 101 - // useEventSourceListener(sourceEventSource, ['source', 'client'], evt => { 102 - // const data = JSON.parse(evt.data); 103 - // if(data.from === 'source') { 104 - // updateSource(data); 105 - // } else if(data.from === 'client') { 106 - // updateClient(data); 107 - // } 108 - // }, [updateSource, updateClient]); 109 - 110 - // return <span/>; 111 - // } 112 - 113 - // const ConnectedGlobal = connector(Global); 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/>; 81 + const sseProviderOptions = { 82 + key: ['events'], 83 + url: '/api/events?next=true' 153 84 } 154 85 155 86 function App() { 156 87 return ( 157 88 <Provider> 158 89 <Container maxWidth="8xl"> 159 - <Global/> 90 + <SSEProvider<MsSseEvent> options={sseProviderOptions}> 160 91 <RouterProvider router={router}/> 92 + </SSEProvider> 161 93 </Container> 162 94 </Provider> 163 95 );
+19 -8
src/client/components/chakraPlayer/Player.tsx
··· 8 8 import { ErrorAlert } from "../ErrorAlert"; 9 9 import ky from 'ky'; 10 10 import { baseUrl } from "../../utils"; 11 + import { 12 + useSSEContext, 13 + useSSEEvent, 14 + } from "@flamefrontend/sse-runtime-react"; 15 + import { MsSseEvent, MsSseEventPayload } from "../../../core/Api"; 11 16 12 17 export interface PlayerProps { 13 18 data: SourcePlayerJson ··· 102 107 sot 103 108 } = props; 104 109 const queryClient = useQueryClient(); 110 + const qKey = ['components', componentId, 'players', platformId]; 105 111 useEffect(() => { 106 - if(initData !== undefined) { 112 + if (initData !== undefined && queryClient.getQueryData(qKey) === undefined) { 107 113 queryClient.setQueryData(['components', componentId, 'players', platformId], initData); 108 114 } 109 - },[initData]); 115 + }, [initData]); 116 + 117 + const client = useSSEContext<MsSseEvent<MsSseEventPayload<SourcePlayerJson>>>(); 118 + useSSEEvent(client, "playerUpdate", (payload) => { 119 + if (payload.componentId === componentId && payload.data.platformId === platformId) { 120 + queryClient.setQueryData(['components', componentId, 'players', platformId], payload.data); 121 + } 122 + }); 110 123 111 124 const { isPending, isError, data, error } = useQuery({ 112 125 queryKey: ['components', componentId, 'players', platformId], ··· 115 128 staleTime: Infinity, 116 129 }); 117 130 118 - if(isError) { 119 - return <ErrorAlert error={error}/> 131 + if (isError) { 132 + return <ErrorAlert error={error} /> 120 133 } 121 134 122 - console.log('rendering player'); 123 - 124 - if(!isPending) { 125 - return <ChakraPlayer data={data}/> 135 + if (!isPending) { 136 + return <ChakraPlayer data={data} sot={sot} /> 126 137 } 127 138 } 128 139
+4 -3
src/client/components/msComponent/MSComponentList.tsx
··· 1 1 import React, { ComponentProps, useMemo, useState, forwardRef, Fragment } from "react" 2 2 import { Stack, SegmentGroup, Text, Box, Center, Card, SkeletonText } from '@chakra-ui/react'; 3 3 import { ComponentsApiJson } from "../../../core/Api"; 4 - import { MSComponentSummary } from "./MSComponentSummary"; 4 + import { MSComponentSummary, MSComponentSummaryFetchable } from "./MSComponentSummary"; 5 5 import { QueryFunctionContext, queryOptions, useQuery } from '@tanstack/react-query'; 6 6 import ky from 'ky'; 7 7 import { baseUrl } from "../../utils"; ··· 9 9 10 10 export interface ComponentListProps { 11 11 components: ComponentsApiJson[] 12 + fetchable?: boolean 12 13 } 13 14 14 15 export const MSComponentList = (props: ComponentListProps) => { ··· 31 32 return x.mode === 'source'; 32 33 } 33 34 return x.mode === 'client'; 34 - }).map(x => <MSComponentSummary data={x} key={x.uid} />)} 35 + }).map(x => props.fetchable ? <MSComponentSummaryFetchable componentId={x.id} data={x}/> : <MSComponentSummary data={x} key={x.uid} />)} 35 36 </Stack> 36 37 </Stack> 37 38 ) ··· 56 57 return <ErrorAlert error={error}/> 57 58 } 58 59 59 - return <MSComponentList components={data}/> 60 + return <MSComponentList fetchable components={data}/> 60 61 } 61 62 62 63 type ComponentListQueryKey = ['components'];
+92 -5
src/client/components/msComponent/MSComponentSummary.tsx
··· 1 - import React, { ComponentProps, useMemo, forwardRef, Fragment } from "react" 1 + import React, { ComponentProps, useMemo, forwardRef, Fragment, useEffect } from "react" 2 2 import { Accordion, For, Span, Stack, Text, Box, Heading, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton, Container, Collapsible, Card, LinkOverlay, LinkBox } from '@chakra-ui/react'; 3 - import { ComponentCommonApi, ComponentCommonApiJson, isComponentClientApiJson, isComponentSourceApiJson } from "../../../core/Api.js"; 3 + import { ComponentClientApiJson, ComponentCommonApi, ComponentCommonApiJson, ComponentSourceApiJson, isComponentClientApiJson, isComponentSourceApiJson, MsSseEvent } from "../../../core/Api.js"; 4 4 import { TextMuted } from "../TextMuted.js"; 5 5 import { isClientType } from "../../../backend/common/infrastructure/Atomic.js"; 6 6 import { capitalize } from "../../../core/StringUtils.js"; ··· 8 8 import { ChevronRightButton } from "../icons/ChakraIcons.js"; 9 9 import { ChakraPlayer, ChakraPlayerFetchable } from "../chakraPlayer/Player.js"; 10 10 import { InfoTip } from "../ToggleTip.js"; 11 + import { QueryFunctionContext, queryOptions, useQuery, useQueryClient } from '@tanstack/react-query'; 12 + import { ErrorAlert } from "../ErrorAlert"; 13 + import ky from 'ky'; 14 + import { baseUrl } from "../../utils"; 15 + import { 16 + useSSEContext, 17 + useSSEEvent, 18 + useSSEAnyEvent 19 + } from "@flamefrontend/sse-runtime-react"; 11 20 12 - export const MSComponentSummary = (props: { data: ComponentCommonApiJson }) => { 21 + export const MSComponentSummary = (props: { data: ComponentCommonApiJson, fetchable?: boolean }) => { 13 22 const { 14 - data 23 + data, 24 + fetchable 15 25 } = props; 16 26 const isClient = isClientType(data.type); 17 27 ··· 27 37 body = (<Card.Body px="3" py="2" paddingTop="3"> 28 38 <Stack gap="2"> 29 39 { 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>) 40 + Object.entries(players).map(([key, x]) => ( 41 + <Container bg="bg.emphasized" borderWidth="1px" p="2" py="3" rounded="md"> 42 + {fetchable ? <ChakraPlayerFetchable componentId={data.id} platformId={key} data={x}/> : <ChakraPlayer data={x}/>} 43 + </Container> 44 + )) 31 45 } 32 46 </Stack> 33 47 </Card.Body>); ··· 120 134 } 121 135 122 136 return <Badge variant="surface" colorPalette={badgeColor} {...rest}>{badgeText}</Badge> 137 + } 138 + 139 + 140 + 141 + export const MSComponentSummaryFetchable = (props: {componentId: number, data: ComponentCommonApiJson}) => { 142 + const { 143 + componentId, 144 + data: initData 145 + } = props; 146 + const queryClient = useQueryClient(); 147 + const qKey = ['components', componentId, 'summary']; 148 + useEffect(() => { 149 + if (initData !== undefined && queryClient.getQueryData(qKey) === undefined) { 150 + queryClient.setQueryData(['components', componentId, 'summary'], initData); 151 + } 152 + }, [initData]); 153 + 154 + const client = useSSEContext<MsSseEvent>(); 155 + useSSEAnyEvent(client, (payload) => { 156 + if('componentId' in (payload.data as object) && (payload.data as Record<string, any>).componentId === componentId) { 157 + switch(payload.type) { 158 + case 'discovered': 159 + queryClient.setQueryData(['components', componentId, 'summary'], (old: ComponentSourceApiJson) => { 160 + return { 161 + ...old, 162 + tracksDiscovered: old.tracksDiscovered + 1 163 + } 164 + }); 165 + break; 166 + case 'scrobbleQueued': 167 + queryClient.setQueryData(['components', componentId, 'summary'], (old: ComponentClientApiJson) => { 168 + let newData: ComponentClientApiJson = {...old}; 169 + newData.queued = old.queued + 1; 170 + return newData; 171 + }); 172 + break; 173 + case 'scrobbleDequeued': 174 + queryClient.setQueryData(['components', componentId, 'summary'], (old: ComponentClientApiJson) => { 175 + let newData: ComponentClientApiJson = {...old}; 176 + newData.queued = old.queued - 1; 177 + return newData; 178 + }); 179 + break; 180 + case 'scrobble': 181 + queryClient.setQueryData(['components', componentId, 'summary'], (old: ComponentClientApiJson) => { 182 + let newData: ComponentClientApiJson = {...old}; 183 + newData.countLive = old.countLive + 1; 184 + return newData; 185 + }); 186 + break; 187 + } 188 + } 189 + }); 190 + 191 + const { isPending, isError, data, error } = useQuery({ 192 + queryKey: ['components', componentId, 'summary'], 193 + queryFn: queryFn, 194 + structuralSharing: false, 195 + staleTime: Infinity, 196 + }); 197 + 198 + if (isError) { 199 + return <ErrorAlert error={error} /> 200 + } 201 + 202 + if(!isPending) { 203 + return <MSComponentSummary data={data} fetchable/> 204 + } 205 + } 206 + 207 + type ComponentSummaryQueryKey = ['components', number, 'summary']; 208 + const queryFn = async (context: QueryFunctionContext<ComponentSummaryQueryKey>) => { 209 + return {} as ComponentCommonApiJson; 123 210 }
+17
src/core/Api.ts
··· 89 89 90 90 export const isComponentClientApiJson = (data: ComponentCommonApiJson): data is ComponentClientApiJson => { 91 91 return data.mode === 'client'; 92 + } 93 + 94 + export type MsSseEventPayload<T extends object = Record<string, any>> = { 95 + type: SourceType | ClientType 96 + name: string 97 + componentId: number 98 + from: 'source' | 'client' 99 + data: T 100 + event: string 101 + } 102 + 103 + export type MsSseEvent<T extends object = Record<string, any>> = { 104 + playerUpdate: MsSseEventPayload<SourcePlayerJson> 105 + discovered: MsSseEventPayload 106 + scrobbleQueued: MsSseEventPayload 107 + scrobbleDequeued: MsSseEventPayload 108 + client: T 92 109 }