[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 plays refresh

FoxxMD (Jul 2, 2026, 3:55 PM UTC) c4b1d72f fb452eb4

+106 -27
+17 -7
src/client/components/icons/ChakraIcons.tsx
··· 18 18 LuPowerOff, 19 19 LuEye, 20 20 LuEyeClosed, 21 - LuCalendar 21 + LuCalendar, 22 + LuRefreshCw 22 23 } from "react-icons/lu" 23 24 import { VscDebugRestart } from 'react-icons/vsc'; 24 25 import { RiZzzFill } from "react-icons/ri"; 25 26 import { SiGoogledocs } from "react-icons/si"; 26 - import { IconButton, Clipboard, useClipboard } from "@chakra-ui/react" 27 + import { IconButton, Clipboard, useClipboard, Spinner } from "@chakra-ui/react" 27 28 import { ComponentProps, PropsWithChildren } from 'react'; 28 29 import { IconBaseProps, IconType } from "react-icons/lib"; 29 30 30 - export const makeIconButton = (Icon: IconType) => (props: PropsWithChildren<ComponentProps<typeof IconButton>> & { iconProps?: IconBaseProps }) => { 31 - const { iconProps = {}, children, ...rest } = props; 31 + export const makeIconButton = (Icon: IconType) => (props: PropsWithChildren<ComponentProps<typeof IconButton>> & { iconProps?: IconBaseProps, loading?: boolean }) => { 32 + const { 33 + iconProps = {}, 34 + children, 35 + loading = false, 36 + size = 'xs', 37 + ...rest 38 + } = props; 32 39 return ( 33 - <IconButton variant="surface" size="xs" {...rest}> 34 - <Icon {...iconProps} />{children} 40 + <IconButton variant="surface" disabled={loading} size={size} {...rest}> 41 + {loading ? <Spinner/> : <Icon {...iconProps} />}{children} 35 42 </IconButton> 36 43 ); 37 44 } ··· 142 149 export const EyeClosedButton = makeIconButton(LuEyeClosed); 143 150 144 151 export const CalendarIcon = LuCalendar; 145 - export const CalendarButton = makeIconButton(CalendarIcon); 152 + export const CalendarButton = makeIconButton(CalendarIcon); 153 + 154 + export const RefreshIcon = LuRefreshCw; 155 + export const RefreshButton = makeIconButton(RefreshIcon);
+11 -9
src/client/components/playActivity/ActivityList.tsx
··· 8 8 import { MsSseEvent, MsSseEventPayload, PlayApiCommon, PlayApiCommonDetailed } from '../../../core/Api.js'; 9 9 import { useQuery, useInfiniteQuery, UseInfiniteQueryResult, InfiniteData, useQueryClient } from '@tanstack/react-query'; 10 10 import { ErrorAlert } from '../ErrorAlert.js'; 11 - import { tanQueries } from '../../queries/index.js'; 11 + import { QueryPlaysOptsJsonRefreshable, tanQueries } from '../../queries/index.js'; 12 12 import { VirtualizedListNormal } from './VirtualListNormal.js'; 13 13 import { NoPlayResults, VirtualizedListDynamic } from './VirtualListDynamic.js'; 14 14 import { VirtualizedListExp } from './VirtualListExperimental.js'; ··· 67 67 </Accordion.ItemTrigger> 68 68 <Accordion.ItemContent> 69 69 <Accordion.ItemBody borderTopColor="gray.border" > 70 - {live ? <ActivityDetailFetchable componentId={props.componentId} componentType={props.componentType} query={props.query} uid={activity.uid} /> : <ActivityDetails activity={activity as PlayApiCommonDetailed} {...props} />} 70 + {live ? <ActivityDetailFetchable componentId={props.componentId} componentType={props.componentType} uid={activity.uid} /> : <ActivityDetails activity={activity as PlayApiCommonDetailed} {...props} />} 71 71 </Accordion.ItemBody> 72 72 </Accordion.ItemContent> 73 73 </Accordion.Item> ··· 86 86 return <Container maxWidth="3xl"><ActivityList {...props} /></Container> 87 87 } 88 88 89 - export const ListContainerFetchable = (props: { componentId: number, componentType: ComponentType, filters?: QueryPlaysOptsJson } & Pick<ComponentProps<typeof ActivityList>, 'render'>) => { 89 + export const ListContainerFetchable = (props: { componentId: number, componentType: ComponentType, filters?: QueryPlaysOptsJsonRefreshable } & Pick<ComponentProps<typeof ActivityList>, 'render'>) => { 90 90 const { 91 91 componentId, 92 92 filters = {} 93 93 } = props; 94 - const query: QueryPlaysOptsJson = { ...filters, order: 'desc', sort: 'playedAt' }; 94 + const query: QueryPlaysOptsJsonRefreshable = filters; // { ...filters, order: 'desc', sort: 'playedAt' }; 95 95 const { 96 96 isPending, 97 97 isError, ··· 133 133 }); 134 134 135 135 let rendered; 136 - if (status === 'pending' && data === undefined) { 137 - rendered = <Stack><ActivitySummarySkeleton /><ActivitySummarySkeleton /><ActivitySummarySkeleton /></Stack>; 136 + if (isPending) { 137 + rendered = <Stack width="100%"><ActivitySummarySkeleton /><ActivitySummarySkeleton /><ActivitySummarySkeleton /></Stack>; 138 138 } else if (isError || status === 'error') { 139 139 rendered = <ErrorAlert error={error} /> 140 140 } else if(!isFetching && allPlays.length === 0) { ··· 263 263 } 264 264 265 265 export const ListContainerFilterable = (props: { componentId: number, componentType: ComponentType } & Pick<ComponentProps<typeof ActivityList>, 'render'>) => { 266 - const [filters, setFilter] = useState<QueryPlaysOptsJson>({ 266 + const [filters, setFilter] = useState<QueryPlaysOptsJsonRefreshable>({ 267 267 playedAt: { 268 268 type: 'between', 269 269 range: todayRange, 270 270 inclusive: true 271 - } 271 + }, 272 + order: 'desc', 273 + sort: 'playedAt' 272 274 }); 273 275 return ( 274 276 <Stack width="100%" gap="4"> 275 - <ListFilters componentType={props.componentType} filters={filters} onChange={setFilter}/> 277 + <ListFilters componentType={props.componentType} componentId={props.componentId} filters={filters} onChange={setFilter}/> 276 278 <ListContainerFetchable {...props} filters={filters} /> 277 279 </Stack> 278 280 )
+27 -5
src/client/components/playActivity/ListFilters.tsx
··· 1 - import { Span, Stack, Text, Box, HStack, Wrap, Flex, Container, Select, Portal, createListCollection, useSelectContext, DatePicker, VStack, Button, Spacer, TagsInput, Card } from '@chakra-ui/react'; 1 + import { Span, Stack, Text, Box, Spinner, HStack, Wrap, Flex, Container, Select, Portal, createListCollection, useSelectContext, DatePicker, VStack, Button, Spacer, TagsInput, Card } from '@chakra-ui/react'; 2 2 import { ComponentType, isComponentTypeSource, PLAY_CLIENT_STATE, PLAY_SOURCE_STATE, PlayState } from '../../../core/Atomic.js'; 3 3 import React, { ComponentProps, Fragment, useMemo, useCallback, useState } from "react" 4 4 import dayjs, { Dayjs } from 'dayjs'; ··· 24 24 import { QueryPlaysOptsJson } from '../../../backend/common/database/drizzle/repositories/PlayRepository.js'; 25 25 import { cardHeaderSeparator } from '../../utils/ComponentUtils.js'; 26 26 import { CompareDateBetween } from '../../../backend/common/database/drizzle/repositories/BaseRepository.js'; 27 - import { CalendarButton } from '../icons/ChakraIcons.js'; 27 + import { CalendarButton, RefreshButton } from '../icons/ChakraIcons.js'; 28 + import { nanoid } from 'nanoid'; 29 + import { QueryPlaysOptsJsonRefreshable, tanQueries, useQueryWatcher } from '../../queries/index.js'; 30 + import { useQueryClient } from '@tanstack/react-query'; 28 31 29 32 const noop = (_) => null; 30 33 ··· 256 259 } 257 260 258 261 export const ListFilters = (props: { 259 - onChange: (e: QueryPlaysOptsJson) => void 260 - filters: QueryPlaysOptsJson 262 + onChange: (e: QueryPlaysOptsJsonRefreshable) => void, 263 + loading?: boolean 264 + filters: QueryPlaysOptsJsonRefreshable 261 265 componentType: ComponentType, 266 + componentId: number 262 267 }) => { 263 268 const { 264 269 filters, 265 270 onChange, 271 + componentId, 266 272 } = props; 273 + 274 + const queryClient = useQueryClient(); 267 275 268 276 const setState = useCallback((val: PlayState[]) => { 269 277 const { ··· 299 307 onChange({...rest, text: val}); 300 308 }, [onChange, filters]); 301 309 310 + const onRefresh = useCallback(() => { 311 + queryClient.invalidateQueries({ 312 + queryKey: tanQueries.activities.list(componentId, filters).queryKey 313 + }); 314 + // const nonce = nanoid(); 315 + // const { 316 + // ...rest 317 + // } = filters; 318 + // console.log(`Adding nonce for refresh ${nonce}`); 319 + // onChange({...rest, nonce}); 320 + }, [componentId, filters]); 321 + 322 + const { isFetching } = useQueryWatcher(tanQueries.activities.list(componentId, filters).queryKey) 323 + 302 324 return ( 303 325 <Card.Root size="sm" variant="outline"> 304 326 <Card.Header {...cardHeaderSeparator}> 305 - Filters 327 + <HStack>Filters <RefreshButton variant="ghost" size="sm" loading={isFetching} onClick={(e) => onRefresh()}/></HStack> 306 328 </Card.Header> 307 329 <Card.Body px="3" py="4"> 308 330 <Wrap gap="5">
+51 -6
src/client/queries/index.ts
··· 1 1 import { createQueryKeys, mergeQueryKeys } from "@lukemorales/query-key-factory"; 2 + import { useQueryClient, hashKey, QueryObserver } from '@tanstack/react-query' 3 + import { useEffect, useState, useMemo } from 'react'; 2 4 import ky from 'ky'; 3 5 import { QueryPlaysOpts, QueryPlaysOptsJson } from "../../backend/common/database/drizzle/repositories/PlayRepository"; 4 6 import qs from 'qs'; ··· 6 8 import { PaginatedResponse } from "../../backend/common/database/drizzle/repositories/BaseRepository"; 7 9 import { ComponentsApiJson, PlayApiCommonDetailed } from "../../core/Api"; 8 10 import { SourcePlayerJson } from "../../core/Atomic"; 11 + 12 + export type QueryPlaysOptsJsonRefreshable = QueryPlaysOptsJson & {nonce?: string}; 9 13 10 14 const components = createQueryKeys('components', { 11 15 list: () => ({ ··· 23 27 }) 24 28 25 29 const activities = createQueryKeys('activities', { 26 - list: (componentId: number, filters: QueryPlaysOptsJson) => ({ 30 + list: (componentId: number, filters: QueryPlaysOptsJsonRefreshable) => ({ 27 31 queryKey: ['components', componentId, 'plays', filters], 28 32 queryFn: (ctx) => { 33 + const { 34 + nonce, 35 + ...rest 36 + } = filters; 29 37 return ky.get(`components/${componentId}/plays`, { 30 - baseUrl: baseUrl, 31 - searchParams: qs.stringify({...filters, offset: ctx.pageParam}) 32 - }).json<PaginatedResponse<PlayApiCommonDetailed>>() 33 - } 38 + baseUrl: baseUrl, 39 + searchParams: qs.stringify({...rest, offset: ctx.pageParam}) 40 + }).json<PaginatedResponse<PlayApiCommonDetailed>>() 41 + } 34 42 }), 35 43 single: (componentId: number, activityUid: string) => ({ 36 44 queryKey: ['components', componentId, 'play', activityUid], ··· 53 61 }) 54 62 }) 55 63 56 - export const tanQueries = mergeQueryKeys(components, activities, players); 64 + export const tanQueries = mergeQueryKeys(components, activities, players); 65 + 66 + export const useQueryState = (queryKey: Readonly<unknown[]>) => { 67 + const queryClient = useQueryClient() 68 + const [state, setState] = useState(() => queryClient.getQueryState(queryKey)) 69 + 70 + useEffect(() => { 71 + const targetHash = hashKey(queryKey) 72 + return queryClient.getQueryCache().subscribe((event) => { 73 + if (event.query.queryHash === targetHash) { 74 + setState(event.query.state) 75 + } 76 + }) 77 + }, [queryClient, queryKey]) 78 + 79 + return state // { status, data, error, fetchStatus, ... } 80 + } 81 + 82 + export const useQueryWatcher = <T>(queryKey: Readonly<unknown[]>) => { 83 + const queryClient = useQueryClient() 84 + 85 + const observer = useMemo( 86 + () => 87 + new QueryObserver<T>(queryClient, { 88 + queryKey, 89 + enabled: false, // never triggers its own fetch 90 + }), 91 + [queryClient, queryKey] 92 + ) 93 + 94 + const [result, setResult] = useState(() => observer.getCurrentResult()) 95 + 96 + useEffect(() => { 97 + return observer.subscribe(setResult) 98 + }, [observer]) 99 + 100 + return result // { status, data, error, isPending, isSuccess, ... } 101 + }