Something went wrong. Try again.
[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
Something went wrong. Try again.
19 kB · 440 lines
TSX
at strict
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440import { Accordion, Container, Stack, Heading, type MenuSelectionDetails, HStack, Dialog, Portal, CloseButton, Button, Text, RadioGroup } from '@chakra-ui/react';import { useSSEAnyEvent, useSSEContext } from '@flamefrontend/sse-runtime-react';import { type InfiniteData, useInfiniteQuery, type UseInfiniteQueryResult, useMutation, useQueryClient } from '@tanstack/react-query';import dayjs from 'dayjs';import doy from 'dayjs/plugin/dayOfYear.js';import { type ComponentProps, Fragment, useCallback, useMemo, useState } from "react";import type {MsSseEvent, MsSseEventPayload, PaginatedResponse, PlayApiCommonDetailed, QueryPlaysOptsJson} from '../../../core/Api.js';import {actionContextSchema, queueContextSchema, type ComponentType, type QueueContext} from '../../../core/Atomic.js';import { type QueryPlaysOptsJsonRefreshable, tanQueries, useQueryWatcher } from '../../queries/index.js';import { ActivityDetailFetchable, ActivityDetails, ActivitySummary, ActivitySummaryFetchable, ActivitySummarySkeleton } from '../ActivityDetail.js';import { ErrorAlert } from '../ErrorAlert.js';import { ListFilters, ListRefereshButton, todayRange } from './ListFilters.js';import { type ActivityLogProps, generateGroupPlays, GroupHeader } from './ListParts.js';import { NoPlayResults, VirtualizedListDynamic } from './VirtualListDynamic.js';import { VirtualizedListExp } from './VirtualListExperimental.js';import { VirtualizedListNormal } from './VirtualListNormal.js';import { menuItem, type MenuItemRender } from '../buttonMenus/menuItemUtils.js';import { RetryIcon } from '../icons/ChakraIcons.js';import { PrimaryButtonMenu } from '../buttonMenus/PrimaryButtonMenu.js';import ky from 'ky';import { formOptions, useForm } from '@tanstack/react-form';import z from 'zod';import { FormCheckbox, FormRadio, type RadioFormItem } from '../form/formComponents.js';
dayjs.extend(doy);
export const ActivityList = (props: ActivityLogProps & Pick<UseInfiniteQueryResult, 'hasNextPage' | 'isFetchingNextPage' | 'fetchNextPage'>) => {
const { data = [], sortBy = 'played', render = 'accordian' } = props;
if (render === 'accordian') { return <PlainAccordian data={data} sortBy={sortBy} {...props} /> } if (render === 'virtNormal') { return <VirtualizedListNormal data={data} sortBy={sortBy} {...props} /> } if (render === 'virtDynamic') { return <VirtualizedListDynamic data={data} sortBy={sortBy} {...props} /> } if (render === 'virtExp') { return <VirtualizedListExp data={data} sortBy={sortBy} {...props} /> }}
const PlainAccordian = (props: ActivityLogProps) => { const { data = [], sortBy, live = false } = props; const groups = generateGroupPlays(data); return ( <Stack gap="2"> {groups.map((g) => { return ( <Fragment key={g.date.valueOf()}> <GroupHeader data={{ date: g.date, count: g.plays.length }} /> <Accordion.Root variant="enclosed" collapsible multiple lazyMount> {g.plays.map((activity, index) => { const { play } = activity; return ( <Accordion.Item key={index} value={index.toString()}> <Accordion.ItemTrigger truncate cursor="pointer"> <Accordion.ItemIndicator /> {live ? <ActivitySummaryFetchable activityUid={activity.uid} {...props} /> : <ActivitySummary componentType={props.componentType} activity={activity} sortBy={sortBy} />} </Accordion.ItemTrigger> <Accordion.ItemContent> <Accordion.ItemBody borderTopColor="gray.border" > {live ? <ActivityDetailFetchable componentId={props.componentId} componentType={props.componentType} uid={activity.uid} /> : <ActivityDetails activity={activity as PlayApiCommonDetailed} {...props} />} </Accordion.ItemBody> </Accordion.ItemContent> </Accordion.Item> ) })} </Accordion.Root> </Fragment> ) })} </Stack> );}
export const ListContainer = (props?: ComponentProps<typeof ActivityList>) => { return <Container maxWidth="3xl"><ActivityList {...props} /></Container>}
export const ListContainerFetchable = (props: { componentId: number, componentType: ComponentType, filters?: QueryPlaysOptsJsonRefreshable } & Pick<ComponentProps<typeof ActivityList>, 'render'>) => { const { componentId, filters = {} } = props; const query: QueryPlaysOptsJsonRefreshable = filters; // { ...filters, order: 'desc', sort: 'playedAt' }; const { isPending, isError, data, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, status } = useInfiniteQuery({ ...tanQueries.activities.list(componentId, query), initialPageParam: 0, getNextPageParam: (lastPage, allPages, lastPageParam) => { if (lastPage.data.length < lastPage.meta.limit) { return undefined } return lastPage.meta.offset + lastPage.meta.limit }, });
const allPlays = useMemo(() => data === undefined ? [] : data.pages.flatMap(x => x.data).filter(x => x !== null && x !== undefined),[data]);
const queryClient = useQueryClient(); const client = useSSEContext<MsSseEvent>(); useSSEAnyEvent(client, (payload) => { if ('componentId' in (payload.data as object) && (payload.data as Record<string, any>).componentId === componentId) { switch (payload.type) { case 'playInsert': { const componentData = payload.data as MsSseEventPayload<PlayApiCommonDetailed>; console.debug(`[Insert Check ${componentData.data.uid}] Recieved playInsert for Component ${componentId}, checking if Play can be inserted...`); if(playInWindow(componentData.data, query)) { queryClient.setQueryData(tanQueries.activities.list(componentId, query).queryKey, (old: InfiniteData<PaginatedResponse<PlayApiCommonDetailed>, unknown>) => { return insertInfinitePlay(componentData.data, old); }); } } break; case 'playDelete': { queryClient.setQueryData(tanQueries.activities.list(componentId, query).queryKey, (old: InfiniteData<PaginatedResponse<PlayApiCommonDetailed>, unknown>) => { const componentData = payload.data as MsSseEventPayload<Pick<PlayApiCommonDetailed, 'uid'>>; // playDelete events don't happen outside of ui the except for retention cleanup // and its unlikely the user would have fetched data during a retention cleanup event // // so its likey this data will actually be updated so we optimistically build the new data even though we don't know // for sure that any page actually has our uid const newQueryData: InfiniteData<PaginatedResponse<PlayApiCommonDetailed>, unknown> = { pages: [], pageParams: { ...old.pageParams } }; for(const p of old.pages) { const newPageData = p.data.filter(x => x.uid !== componentData.data.uid); if(newPageData.length !== p.data.length) { newQueryData.pages.push({...p, data: newPageData}); } else { newQueryData.pages.push(p); } } return newQueryData; }); } } } });
let rendered; if (isPending) { rendered = <Stack width="100%"><ActivitySummarySkeleton /><ActivitySummarySkeleton /><ActivitySummarySkeleton /></Stack>; } else if (isError || status === 'error') { rendered = <ErrorAlert error={error} /> } else if(!isFetching && allPlays.length === 0) { rendered = <NoPlayResults type="empty"/> } else { rendered = <ActivityList total={data?.pages.length > 0 ? data.pages[0].meta.total : undefined} hasNextPage={hasNextPage} fetchNextPage={fetchNextPage} isFetchingNextPage={isFetchingNextPage} render="virtDynamic" data={allPlays} live {...props} sortBy="played" query={query} />; }
return rendered;}
/** this is based on the assumption data is always playedAt descending */const insertInfinitePlay = (data: PlayApiCommonDetailed, queryData: InfiniteData<PaginatedResponse<PlayApiCommonDetailed>, unknown>): InfiniteData<PaginatedResponse<PlayApiCommonDetailed>, unknown> => { const newQueryData: InfiniteData<PaginatedResponse<PlayApiCommonDetailed>, unknown> = { pages: [], pageParams: { ...queryData.pageParams } }; console.debug(`[Insert Page Index ${data.uid}] Trying to insert Play...`); const playedAt = dayjs(data.play.data.playDate);
let pageIndex = 0; let inserted = false; for (const p of queryData.pages) { if(inserted === true) { newQueryData.pages.push(p); continue; } let beforeIndex: number; try { beforeIndex = p.data.findIndex(x => dayjs(x.play.data.playDate).isBefore(playedAt)); } catch (e) { console.warn(new Error(`[Insert Page Index ${data.uid}] Error while trying to find index for existing data insert`, { cause: e })); } if (beforeIndex === -1 || beforeIndex === undefined) { if (!inserted) { const first = p.data[0].playedAt; const last = p.data[p.data.length - 1].playedAt; console.debug(`[Insert ${data.uid}] Play playedAt ${data.play.data.playDate} not between ranges on page ${pageIndex}: ${first} to ${last}`); } newQueryData.pages.push(p); } else { const first = p.data[0].playedAt; const last = p.data[p.data.length - 1].playedAt; console.debug(`[Insert ${data.uid}] Play playedAt ${data.play.data.playDate} between ranges on page ${pageIndex}: ${first} to ${last}, inserting at index ${beforeIndex}`); inserted = true; const { data: playData, meta } = p; newQueryData.pages.push({ meta, data: [...playData.slice(0, beforeIndex), { ...data, // @ts-expect-error only used for inserts in this context isNew: true } , ...playData.slice(beforeIndex)] }); } pageIndex++; }
return newQueryData;}
const playInWindow = (data: PlayApiCommonDetailed, query: QueryPlaysOptsJson): boolean => { if (query.state !== undefined && !query.state.includes(data.state)) { console.debug(`[Insert Check ${data.uid}] Play is in state ${data.state} not included in current filters of ${query.state.join(',')}`); return false; } if (query.text !== undefined) { let someFound = false; for (const t of query.text) { if (data.play.data.track?.toLocaleLowerCase().includes(t)) { someFound = true; break; } if (data.play.data.track.toLocaleLowerCase().includes(t)) { someFound = true; break; } if ((data.play.data.artists ?? []).some(x => x.name.toLocaleLowerCase().includes(t))) { someFound = true; } } if (!someFound) { console.debug(`[Insert Check ${data.uid}] Play does not match current phrase filters`); return false; } } const played = dayjs(data.play.data.playDate); if (query.playedAt.type === 'between' && ( !played.isAfter(dayjs(query.playedAt.range[0])) || !played.isBefore(dayjs(query.playedAt.range[1])))) { console.debug(`[Insert Check ${data.uid}] Play playedAt ${data.play.data.playDate} is not between filter date range of ${query.playedAt.range[0]} and ${query.playedAt.range[1]}`); return false; } // TODO playedAt with comparisons that aren't between return true;}
const opts = formOptions.strictSchema(actionContextSchema, { defaultValues: { transform: true, dupeCheck: true, useCache: true, action: 'failures' }, validators: [{ run: actionContextSchema, triggers: [], }],});
const playTypeRadioItems: RadioFormItem[] = [ {value: 'all', label: 'All Plays'}, {value: 'failures', label: 'Only Unsuccessful Plays'}];
const RetryWithDialog = (props: { open: boolean, setOpen: (open: boolean) => void, onSubmit: (vals: QueueContext & {action: 'all' | 'failures'}) => void }) => { const form = useForm({ ...opts, onSubmit: ({ schemaOutputs }) => { console.log(schemaOutputs[0]); props.onSubmit(schemaOutputs[0]); props.setOpen(false); } }); return (<Dialog.Root role="alertdialog" closeOnInteractOutside unmountOnExit open={props.open} size="sm" onOpenChange={(e) => props.setOpen(e.open)} > <Portal> <Dialog.Backdrop /> <Dialog.Positioner> <Dialog.Content> <Dialog.CloseTrigger asChild> <CloseButton /> </Dialog.CloseTrigger> <Dialog.Header> <Dialog.Title>Retry With...</Dialog.Title> </Dialog.Header> <form onSubmit={(e) => { e.preventDefault() e.stopPropagation() form.handleSubmit() }}> <Dialog.Body> <Stack gap="3"> <Text mb="3"> Retry <strong>all Plays shown</strong> using the options below: </Text> <form.Field name="action" // @ts-expect-error don't know how to fix this yet children={(field) => (<FormRadio field={field} label="Plays to Retry" items={playTypeRadioItems}/>)} /> <form.Field name="transform" children={(field) => (<FormCheckbox field={field} label="Transform?"/>)} /> <form.Field name="dupeCheck" children={(field) => (<FormCheckbox field={field} label="Check for Duplicate?"/>)} /> <form.Field name="useCache" children={(field) => (<FormCheckbox field={field} label="Use Cache?"/>)} /> </Stack> </Dialog.Body> <Dialog.Footer> <Button variant="outline" onClick={() => props.setOpen(false)}> Cancel </Button> <Button colorPalette="blue" type="submit">Retry</Button> </Dialog.Footer> </form> </Dialog.Content> </Dialog.Positioner> </Portal> </Dialog.Root> )}
const MenuRetryFailures = menuItem(RetryIcon, 'failures', 'Retry All Unsuccessful');const MenuRetryAll = menuItem(RetryIcon, 'all', 'Retry All');const MenuRetryWith = menuItem(RetryIcon, 'allWith', 'Retry All With...');const menuItems: MenuItemRender[] = [ (extra) => <MenuRetryFailures {...extra}/>, (extra) => <MenuRetryAll color="fg.error" _hover={{ bg: "bg.error", color: "fg.error" }} {...extra}/>, (extra) => <MenuRetryWith {...extra}/>];
// instanced so that todayRange is updated on component mountconst defaultFilter = (): QueryPlaysOptsJsonRefreshable => ({ playedAt: { type: 'between', range: todayRange, inclusive: true }, order: 'desc', sort: 'playedAt'});
export const ListContainerFilterable = (props: { componentId: number, componentType: ComponentType } & Pick<ComponentProps<typeof ActivityList>, 'render'>) => { const { componentType } = props; const [filters, setFilter] = useState<QueryPlaysOptsJsonRefreshable>(defaultFilter()); const primaryRefresh = <ListRefereshButton size="md" componentId={props.componentId} filters={filters} variant="subtle" />; const { isFetching: isListFetching } = useQueryWatcher(tanQueries.activities.list(props.componentId, filters).queryKey) const [retryOpen, setRetryOpen] = useState(false);
const { mutate, isPending, variables, isSuccess } = useMutation({ mutationKey: ['retryBulk', props.componentId, filters], // eslint-disable-next-line arrow-body-style mutationFn: (data: { action: string, context?: QueueContext }) => { return ky.post<{ filters: QueryPlaysOptsJsonRefreshable, context?: QueueContext }>(`api/components/${props.componentId}/plays/queue`, { json: { filters: { ...filters, state: data.action === 'failures' ? ['failed'] : filters.state }, context: data.context } }); } });
const menuCb = useCallback((value: MenuSelectionDetails) => { switch(value.value) { case 'failures': mutate({action: 'failures'}); break; case 'all': mutate({action: 'all'}); break; case 'allWith': setRetryOpen(true); break; } }, [mutate]);
return ( <Stack width="100%" gap="4"> <RetryWithDialog open={retryOpen} setOpen={setRetryOpen} onSubmit={(vals) => mutate({action: vals.action, context: vals})}/> <Heading size="3xl" width="100%"> <HStack> {componentType === 'source' ? 'Plays' : 'Scrobbles'} <PrimaryButtonMenu primary={primaryRefresh} menuItems={menuItems} menuCallback={menuCb} menuButtonProps={{ size: 'md' }} disabled={isListFetching || isPending} /> </HStack> </Heading> <ListFilters componentType={componentType} filters={filters} onChange={setFilter} /> <ListContainerFetchable {...props} filters={filters} /> </Stack> )}