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.
24 kB · 532 lines
TSX
at strict
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532import React, { useCallback, useMemo, type ComponentProps, useEffect, type ReactNode } from "react"import { Portal, Group, Span, Menu, Box, Heading, Skeleton, Wrap, HStack, Badge, Stack, List, Flex, Text, Card, Button, CloseButton, SkeletonText, type BadgeProps, type MenuItemProps, createOverlay, Dialog, type MenuSelectionDetails } from '@chakra-ui/react';import { COMPONENT_STATE, type ComponentCommonApiJson, type ComponentsApiJson, type ComponentState, type ComponentStateBody, isComponentSourceApiJson, type MsSseEvent, type MsSseEventPayload } from "../../../core/Api.js";import { capitalize, capitalizeWords } from "../../../core/StringUtils.js";import { ChevronLeftButton, EllipsisButton, ExternalLinkIcon, EyeButton, EyeClosedIcon, EyeIcon, IdleIcon, PowerButton, PowerIcon, type PowerOffButton, PowerOffIcon, RetryButton, RetryIcon, SyncPlayIconRaw, UnlockButton, UnlockIconRaw } from "../icons/ChakraIcons.js";import { PlayersContainer, PlayersContainerFetchable } from "../chakraPlayer/Player.js";import { Tooltip } from "../ToggleTip.js";import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';import ky from "ky";import { ErrorAlert } from "../ErrorAlert";import { useSSEContext, useSSEAnyEvent} from "@flamefrontend/sse-runtime-react";import { Link } from "react-router";import { CountIndicatorStreamable, DateIndicatorStreamable, DeadLetterIndicatorStreamable, QueuedIndicatorStreamable, StaticStat } from "./Stats.js";import { ListContainerFilterable } from "../playActivity/ActivityList.js";import { useParams } from "react-router-dom";import { ComponentStateBadge } from "../Badges.js";import { ActivitySummarySkeleton } from "../ActivityDetail.js";import dayjs from "dayjs";import { durationToHuman, shortTodayAwareFormat } from "../../../core/TimeUtils.js";import { tanQueries } from "../../queries/index.js";import { MSErrorBoundary } from "../ErrorBoundary.js";import type {IconType} from "react-icons/lib";import { useIsWrapped } from "../../utils/hooks/useIsWrapped.js";import { PlaybackReportingServer } from "../icons/PlaybackReporting.js";import { findAnyAuthError, findAuthError } from "../../utils/ComponentUtils.js";import { COMPONENT_AUTH_TYPE } from "../../../core/Atomic.js";
export const ComponentBackButton = (props: ComponentProps<typeof ChevronLeftButton> = {}) => { return ( <Link to={`/`}> <ChevronLeftButton variant="ghost" iconProps={{style: {width: 'unset', height: 'unset', fontSize: "2em"}}} {...props} /> </Link> );}
export const MSComponentName = (props: {data?: Pick<ComponentCommonApiJson, 'name'> & {playbackReporting?: boolean}}) => { if(props.data === undefined) { return <HStack><ComponentBackButton/><Skeleton width="5rem" height="5rem" /></HStack>; } let subsonicPlaybackReporting: React.JSX.Element; if('playbackReporting' in props.data) { subsonicPlaybackReporting = <PlaybackReportingServer playbackReporting={props.data.playbackReporting}/> } return <Heading truncate size="2xl"><ComponentBackButton/>{props.data.name}{subsonicPlaybackReporting}</Heading>;}
export const MSComponentType = (props: {data?: Pick<ComponentCommonApiJson, 'mode' | 'type'>}) => { if(props.data === undefined) { return <Skeleton width="3rem" height="1rem" />; } return <Heading color="fg.subtle" size="lg">({props.data.mode}) {capitalize(props.data.type)}</Heading>;}
const SyncedStat = (props: {data: Pick<ComponentsApiJson, 'synced' | 'syncedReason' | 'lastImport' | 'lastImportSuccess'>}) => { const { data: { synced, syncedReason, lastImport, lastImportSuccess } = {} } = props; let helpText: ReactNode; if(synced) { helpText = ( <Stack margin="1"> <Text>Last historical play sync was successful.</Text> {lastImportSuccess !== undefined && lastImportSuccess !== null ? <Text>Synced at {shortTodayAwareFormat(dayjs(lastImportSuccess))}</Text> : undefined} </Stack> ); } else { helpText = ( <Stack margin="1"> <Text>Last historical play sync was unsuccessful: {syncedReason}</Text> {lastImport !== undefined && lastImport !== null ? <Text>Attempted at {shortTodayAwareFormat(dayjs(lastImport))}</Text> : undefined} {lastImportSuccess !== undefined && lastImportSuccess !== undefined ? <Text>Last successful sync was at {shortTodayAwareFormat(dayjs(lastImportSuccess))}</Text> : undefined} </Stack> ); } return <StaticStat label="Synced?" helpText={helpText}>{synced ? 'Yes' : 'No'}</StaticStat>}
export const MSComponentStats = (props: { data?: ComponentsApiJson, live?: boolean }) => { if (props.data === undefined) { return ( <Box> <SkeletonText noOfLines={6} /> </Box> ) } const { data: { synced, } = {}, } = props; return ( <Wrap gap="6" rowGap="5" justify="flex-start" flexGrow="0"> <CountIndicatorStreamable data={props.data} flexGrow="0"/> <QueuedIndicatorStreamable data={props.data} flexGrow="0"/> <DeadLetterIndicatorStreamable data={props.data} flexGrow="0"/> <DateIndicatorStreamable data={props.data} flexGrow="0"/> {synced !== undefined ? <SyncedStat data={props.data}/> : null} </Wrap> )}
type AuthDialogProps = {data: Pick<ComponentsApiJson, 'id' | 'authType' | 'errors'>};
const dialog = createOverlay<AuthDialogProps>((props) => { const { data, ...rest } = props
const { isPending, isError, data: url, error } = useQuery({ enabled: data.authType === COMPONENT_AUTH_TYPE.interactive, staleTime: Infinity, ...tanQueries.components.authUrl(data.id), }); const {mutate, isPending: mutateIsPending, isSuccess} = useMutation({ mutationKey: ['authChange', data.id], mutationFn: () => ky.post(`api/components/${data.id}/auth`) });
useEffect(() => { if(isSuccess) { dialog.close('auth'); } },[isSuccess, dialog])
const authFailure = useMemo(() => { for(const e of data.errors) { const authState = findAnyAuthError(e); if(authState !== undefined) { return authState; } } return [undefined, false]; },[data.errors]);
let content: React.JSX.Element; if (authFailure[0] !== undefined && authFailure[1] === true) { content = ( <> <Text>Auth failed and the error indicated that this component <strong>cannot</strong> be recovered from this state.</Text> <Text>Likely this means that something is wrong with the data in your configuration which requires you to update it and restart Multi-Scrobbler.</Text> <Text>You can still try to <strong>Test Auth</strong> but this will probably change nothing.</Text> </> ); } else if(data.authType !== COMPONENT_AUTH_TYPE.interactive && authFailure[0] !== undefined && authFailure[1] === false) { content = ( <> <Text>Auth failed and the error indicated that this component <strong>can</strong> be recovered from this state.</Text> <Text>This is likely due to a temporary network issue or something you can fix upstream (file permission issues, user permissions, etc...) without needing to restart Multi-Scrobbler.</Text> <Text>Try to <strong>Test Auth</strong> after you have made upstream changes or the network issue has been resolved.</Text> </> ); } else if(data.authType === COMPONENT_AUTH_TYPE.interactive && authFailure[0] !== undefined && authFailure[1] === false) { content = ( <> <Text>Auth failed and the error indicated that this component <strong>can</strong> be recovered from this state.</Text> <Text>If you have just setup this component, or the error indicates auth data is now invalid, try to <strong>Authenticate</strong>.</Text> <Text><strong>Authenticate</strong> will redirect you to the upstream service's site where you must login and/or allow Multi-Scrobbler access to your account. After auth is complete you will be redirected back here.</Text> <Text>If you have previously successfully Authenticated and the error indicates it is networking-related, try to <strong>Test Auth</strong> after the networking issue is resolved.</Text> </> ); }
return ( <Dialog.Root {...rest}> <Portal> <Dialog.Backdrop /> <Dialog.Positioner> <Dialog.Content> <Dialog.Header> <Dialog.Title>Authentication</Dialog.Title> </Dialog.Header> <Dialog.Body spaceY="4"> {content} {isError && <ErrorAlert error={error}/>} </Dialog.Body> <Dialog.Footer> <Dialog.ActionTrigger asChild> <Button variant="outline">Cancel</Button> </Dialog.ActionTrigger> {COMPONENT_AUTH_TYPE.interactive === data.authType && <Button disabled={isPending || isError} asChild><a target="_self" href={url}>Authenticate <ExternalLinkIcon size="sm"/></a></Button>} <Button loading={mutateIsPending} onClick={() => mutate()}>Test Auth</Button> </Dialog.Footer> <Dialog.CloseTrigger asChild> <CloseButton size="sm" /> </Dialog.CloseTrigger> </Dialog.Content> </Dialog.Positioner> </Portal> </Dialog.Root> )});
type SyncDialogProps = {data: Pick<ComponentsApiJson, 'id' | 'type'>};
const syncDialog = createOverlay<SyncDialogProps>((props) => { const { data, ...rest } = props
const {mutate, isPending: mutateIsPending, isSuccess} = useMutation({ mutationKey: ['syncPlays', data.id], mutationFn: (type: string) => ky.post(`api/components/${data.id}/plays/historical`, {json: {type}}) });
useEffect(() => { if(isSuccess) { syncDialog.close('syncPlays'); } },[isSuccess, syncDialog])
return ( <Dialog.Root {...rest}> <Portal> <Dialog.Backdrop /> <Dialog.Positioner> <Dialog.Content> <Dialog.Header> <Dialog.Title>Sync Local Mirror</Dialog.Title> </Dialog.Header> <Dialog.Body spaceY="4"> <Text>This component stores a <strong>local mirror</strong> of all of your Plays/Scrobbles.</Text> <Text>During duplicate detection, it queries this mirror instead of making API calls to {capitalizeWords(data.type)} so it depends on this mirror being in-sync.</Text> <Text>The local mirror is normally synced automatically when Multi-Scrobbler is started but can be manually initiated here.</Text> <List.Root> <List.Item mt="2">Use <Badge colorPalette="yellow" variant="surface">Full Sync</Badge> to initiate a full rebuild of the local mirror <List.Root mt="1" ps="5"> <List.Item>This can take some time if you have many Plays/Scrobbles!</List.Item> <List.Item>Only use this if there are errors, "Synced" is "No", or a Recent Sync does not fix your issue.</List.Item> </List.Root> </List.Item> <List.Item mt="2"> Use <Badge colorPalette="blue" variant="surface">Recent Sync</Badge> to fill in the last ~100 Plays/Scrobbles <List.Root mt="1" ps="5"> <List.Item>Useful for "catching up" Multi-Scrobbler if an outside service added, or made changes to, your recent history.</List.Item> </List.Root> </List.Item> </List.Root> </Dialog.Body> <Dialog.Footer> <Dialog.ActionTrigger asChild> <Button variant="outline">Cancel</Button> </Dialog.ActionTrigger> <Button colorPalette="blue" variant="surface" loading={mutateIsPending} onClick={() => mutate('recent')}>Recent Sync</Button> <Button colorPalette="yellow" variant="surface" loading={mutateIsPending} onClick={() => mutate('full')}>Full Sync</Button> </Dialog.Footer> <Dialog.CloseTrigger asChild> <CloseButton size="sm" /> </Dialog.CloseTrigger> </Dialog.Content> </Dialog.Positioner> </Portal> </Dialog.Root> )})
const stateIsStarted = (state: ComponentState): boolean => state <= COMPONENT_STATE.IGNORED;
const componentStateMenuItem = (Icon: IconType, value: string, name?: string) => (props: Pick<MenuItemProps, 'disabled'> = {}) => { return (<Menu.Item key={value} value={value} {...props}><Icon/><Box flex="1">{name ?? capitalize(value)}</Box></Menu.Item>);}const MenuItemRestart = componentStateMenuItem(RetryIcon, 'restart', 'Restart');const MenuItemStop = componentStateMenuItem(PowerOffIcon, 'stop', 'Stop');const MenuItemStart = componentStateMenuItem(PowerIcon, 'start', 'Start');const MenuItemIgnore = componentStateMenuItem(EyeClosedIcon, 'ignore', 'Ignore')const MenuItemMonitor = componentStateMenuItem(EyeIcon, 'monitor', 'Monitor');const MenuItemAuth = componentStateMenuItem(UnlockIconRaw, 'auth', 'Auth');const MenuItemSync = componentStateMenuItem(SyncPlayIconRaw, 'syncHistorical', 'Sync Plays...');
const primaryActionProps: ComponentProps<typeof PowerOffButton> = { margin: "1px", variant: "subtle", size: 'xs'}
export const ComponentStateBadgeActionable = (props: Omit<ComponentProps<typeof ComponentStateBadge>, 'suffix' | 'data'> & { data: ComponentProps<typeof ComponentStateBadge>['data'] & Pick<ComponentsApiJson, 'synced' | 'type'>}) => { const { componentId, live, ...rest } = props; let suffix: React.JSX.Element | undefined; let primaryAction: React.JSX.Element | undefined; let menuElm: React.JSX.Element | undefined; let menuItems: React.JSX.Element[] = []; const badgeProps: BadgeProps = {};
const {mutate, isPending, variables, isSuccess} = useMutation({ mutationKey: ['stateChange', componentId], mutationFn: (action: ComponentStateBody['state']) => ky.post(`api/components/${componentId}/state`,{ json: {state: action, reason: 'User initiated from UI'} }) });
const authFailure = useMemo(() => { for(const e of props.data.errors) { const authState = findAnyAuthError(e); if(authState !== undefined) { return authState; } } return [undefined, false]; },[props.data.errors]);
switch(props.data.state) { case COMPONENT_STATE.RUNNING: primaryAction = <RetryButton onClick={() => mutate('restart')} disabled={isPending} {...primaryActionProps}/> menuItems = [<MenuItemStop/>,<MenuItemIgnore/>]; break; case COMPONENT_STATE.IDLE: primaryAction = <PowerButton onClick={() => mutate('start')} disabled={isPending} {...primaryActionProps}/> menuItems = [<MenuItemStop/>,<MenuItemRestart/>,<MenuItemIgnore/>]; break; case COMPONENT_STATE.IGNORED: primaryAction = <EyeButton onClick={() => mutate('monitor')} disabled={isPending} {...primaryActionProps}/>; menuItems = [<MenuItemStop/>,<MenuItemRestart/>,<MenuItemMonitor/>]; break; case COMPONENT_STATE.STOPPED: primaryAction = <PowerButton onClick={() => mutate('start')} disabled={isPending} {...primaryActionProps}/> menuItems = [<MenuItemRestart/>]; break; case COMPONENT_STATE.INITIALIZING: // no actions while init is occurring break; case COMPONENT_STATE.NOT_READY: case COMPONENT_STATE.ERROR: if(authFailure[0] !== undefined) { primaryAction = <UnlockButton onClick={() => dialog.open('auth', {data: {id: componentId, errors: props.data.errors, authType: props.data.authType}})} disabled={isPending} {...primaryActionProps}/>; menuItems = [<MenuItemRestart/>]; } else { primaryAction = <RetryButton onClick={() => mutate('restart')} disabled={isPending} {...primaryActionProps}/>; } // no actions while init is occurring break; default: // otherwise generic start action for all non-running states primaryAction = <RetryButton onClick={() => mutate('restart')} disabled={isPending} {...primaryActionProps}/>; }
if(authFailure[0] !== undefined && !([COMPONENT_STATE.NOT_READY,COMPONENT_STATE.ERROR] as ComponentState[]).includes(props.data.state)) { menuItems.push(<MenuItemAuth/>) }
if(props.data.synced !== undefined) { menuItems.push(<MenuItemSync/>) }
const menuCb = useCallback((select: MenuSelectionDetails) => { switch(select.value) { case 'auth': dialog.open('auth', {data: {id: componentId, errors: props.data.errors, authType: props.data.authType}}); break; case 'syncHistorical': syncDialog.open('syncPlays', {data: {id: componentId, type: props.data.type}}); break; default: mutate(select.value as ComponentStateBody['state']); break; } },[mutate, props.data.errors, componentId, props.data.authType]);
if(menuItems.length > 0) { menuElm = ( <Menu.Root positioning={{ placement: "bottom-end" }} onSelect={menuCb}> <Group attached> {primaryAction} <Menu.Trigger asChild> <EllipsisButton disabled={isPending} {...primaryActionProps}/> </Menu.Trigger> </Group> <Portal> <Menu.Positioner> <Menu.Content> {menuItems} </Menu.Content> </Menu.Positioner> </Portal> </Menu.Root> ); suffix = menuElm; } else if(primaryAction !== undefined) { suffix = primaryAction; } if(suffix !== undefined || primaryAction !== undefined) { badgeProps.paddingRight = 0; }
return <ComponentStateBadge size="lg" maxWidth="fit-content" {...badgeProps} loading={isPending} separator suffix={suffix} {...rest}/>;}
export const ComponentDetailedDesktop = (props: {data?: ComponentsApiJson, live?: boolean}) => { let sleepingRender: React.JSX.Element = null; const { data, data: { warnings = [], errors = [], authed, authType, syncError } = {} } = props; if(syncError !== undefined && syncError !== null) { warnings.push(syncError); } const isSource = isComponentSourceApiJson(data) if(isSource) { const { sleeping, wakeAt } = data; if(sleeping) { if(sleeping && wakeAt !== undefined) { const wakeDay = dayjs(wakeAt); const now = dayjs(); sleepingRender = ( <Tooltip content={<Span>Will next poll Source for activity at {shortTodayAwareFormat(wakeDay)} (in {durationToHuman(dayjs.duration(wakeDay.diff(now, 'ms')))})</Span>}> <IdleIcon animated cursor="pointer"/> </Tooltip> ) } else { sleepingRender = <IdleIcon/>; } } } const authFailure = useMemo(() => { if(authed) { return false; } for(const e of errors) { const aError = findAuthError(e); if(aError !== undefined && 'unrecoverable' in aError && aError.unrecoverable === true) { return true; } } },[errors, authed]); const target = React.useRef(null); const isWrapped = useIsWrapped(target); return ( <MSErrorBoundary> <dialog.Viewport /> <syncDialog.Viewport /> <Flex direction="row" wrap="wrap" style={{whiteSpace: 'break-spaces'}} truncate rowGap="4"> <Wrap width="100%" ref={target}> <Box marginEnd="auto" truncate> <MSComponentName data={data}/> <MSComponentType data={data}/> </Box> <Stack alignItems={isWrapped ? 'flex-start' : 'flex-end'}> <ComponentStateBadgeActionable size="lg" maxWidth="fit-content" componentId={data.id} data={data} /> <HStack style={{whiteSpace: 'break-spaces'}}>{sleepingRender}{data.status}</HStack> </Stack> </Wrap> <Flex justifyContent="flex-end" rowGap="6" flexDirection="row-reverse" wrap="wrap"> <Box marginEnd="auto"><MSComponentStats {...props}/></Box> </Flex> {errors.length > 0 ? <>{errors.map(x => <ErrorAlert error={x}/>)}</> : undefined } {warnings.length > 0 ? <>{warnings.map(x => <ErrorAlert error={x} status="warning"/>)}</> : undefined } <MSErrorBoundary>{props.live ? <PlayersContainerFetchable nowPlaying={isSource ? undefined : true} data={data}/> : <PlayersContainer nowPlaying={isSource ? undefined : true} data={data} live={props.live}/>}</MSErrorBoundary> <MSErrorBoundary><ListContainerFilterable render="virtDynamic" componentType={data.mode} componentId={data.id}/></MSErrorBoundary> </Flex> </MSErrorBoundary> )}
const ComponentDetailedSkeleton = () => { return ( <Flex direction="column" gap="6"> <Flex justifyContent="flex-end" rowGap="6" wrap="wrap"> <Box marginEnd="auto"><SkeletonText noOfLines={2}/></Box> <Stack alignItems="flex-end"> <Skeleton height="2"/> </Stack> </Flex> <Flex justifyContent="flex-end" rowGap="6" flexDirection="row-reverse" wrap="wrap"> <Card.Root bgColor="bg.subtle" size="sm"> <Card.Header>Actions</Card.Header> <Card.Body> <Skeleton height="2"/> </Card.Body> </Card.Root> <Box marginEnd="auto"><SkeletonText noOfLines={2}/></Box> </Flex> <ActivitySummarySkeleton/> </Flex> )}
export const ComponentDetailedFetchable = (props: { componentId: number }) => { const { isPending, isError, data, error } = useQuery({ ...tanQueries.components.single(props.componentId), });
let rendered; if (data === undefined) { rendered = <ComponentDetailedSkeleton /> } else if (isError) { rendered = <ErrorAlert error={error} /> } else { rendered = <ComponentDetailedDesktop data={data} live />; }
const queryClient = useQueryClient(); const client = useSSEContext<MsSseEvent>(); useSSEAnyEvent(client, (payload) => { if ('componentId' in (payload.data as object) && (payload.data as Record<string, any>).componentId === props.componentId) { switch (payload.type) { case 'componentUpdate': queryClient.setQueryData(tanQueries.components.single(props.componentId).queryKey, (old: ComponentCommonApiJson) => { const componentData = payload.data as MsSseEventPayload<Partial<ComponentCommonApiJson>>; return { ...old, ...componentData.data }; }); } } });
return rendered;}
export const ComponentDetailedRoutable = () => { const params = useParams(); if(params.componentId === undefined) { return <ErrorAlert error={{message: 'Component is on a route with :componentId, cannot rendering anything!'}} /> }
return <ComponentDetailedFetchable componentId={Number.parseInt(params.componentId)}/>}