import 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 = {}) => { return ( ); } export const MSComponentName = (props: {data?: Pick & {playbackReporting?: boolean}}) => { if(props.data === undefined) { return ; } let subsonicPlaybackReporting: React.JSX.Element; if('playbackReporting' in props.data) { subsonicPlaybackReporting = } return {props.data.name}{subsonicPlaybackReporting}; } export const MSComponentType = (props: {data?: Pick}) => { if(props.data === undefined) { return ; } return ({props.data.mode}) {capitalize(props.data.type)}; } const SyncedStat = (props: {data: Pick}) => { const { data: { synced, syncedReason, lastImport, lastImportSuccess } = {} } = props; let helpText: ReactNode; if(synced) { helpText = ( Last historical play sync was successful. {lastImportSuccess !== undefined && lastImportSuccess !== null ? Synced at {shortTodayAwareFormat(dayjs(lastImportSuccess))} : undefined} ); } else { helpText = ( Last historical play sync was unsuccessful: {syncedReason} {lastImport !== undefined && lastImport !== null ? Attempted at {shortTodayAwareFormat(dayjs(lastImport))} : undefined} {lastImportSuccess !== undefined && lastImportSuccess !== undefined ? Last successful sync was at {shortTodayAwareFormat(dayjs(lastImportSuccess))} : undefined} ); } return {synced ? 'Yes' : 'No'} } export const MSComponentStats = (props: { data?: ComponentsApiJson, live?: boolean }) => { if (props.data === undefined) { return ( ) } const { data: { synced, } = {}, } = props; return ( {synced !== undefined ? : null} ) } type AuthDialogProps = {data: Pick}; const dialog = createOverlay((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 = ( <> Auth failed and the error indicated that this component cannot be recovered from this state. Likely this means that something is wrong with the data in your configuration which requires you to update it and restart Multi-Scrobbler. You can still try to Test Auth but this will probably change nothing. ); } else if(data.authType !== COMPONENT_AUTH_TYPE.interactive && authFailure[0] !== undefined && authFailure[1] === false) { content = ( <> Auth failed and the error indicated that this component can be recovered from this state. 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. Try to Test Auth after you have made upstream changes or the network issue has been resolved. ); } else if(data.authType === COMPONENT_AUTH_TYPE.interactive && authFailure[0] !== undefined && authFailure[1] === false) { content = ( <> Auth failed and the error indicated that this component can be recovered from this state. If you have just setup this component, or the error indicates auth data is now invalid, try to Authenticate. Authenticate 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. If you have previously successfully Authenticated and the error indicates it is networking-related, try to Test Auth after the networking issue is resolved. ); } return ( Authentication {content} {isError && } {COMPONENT_AUTH_TYPE.interactive === data.authType && } ) }); type SyncDialogProps = {data: Pick}; const syncDialog = createOverlay((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 ( Sync Local Mirror This component stores a local mirror of all of your Plays/Scrobbles. 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. The local mirror is normally synced automatically when Multi-Scrobbler is started but can be manually initiated here. Use Full Sync to initiate a full rebuild of the local mirror This can take some time if you have many Plays/Scrobbles! Only use this if there are errors, "Synced" is "No", or a Recent Sync does not fix your issue. Use Recent Sync to fill in the last ~100 Plays/Scrobbles Useful for "catching up" Multi-Scrobbler if an outside service added, or made changes to, your recent history. ) }) const stateIsStarted = (state: ComponentState): boolean => state <= COMPONENT_STATE.IGNORED; const componentStateMenuItem = (Icon: IconType, value: string, name?: string) => (props: Pick = {}) => { return ({name ?? capitalize(value)}); } 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 = { margin: "1px", variant: "subtle", size: 'xs' } export const ComponentStateBadgeActionable = (props: Omit, 'suffix' | 'data'> & { data: ComponentProps['data'] & Pick}) => { 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 = mutate('restart')} disabled={isPending} {...primaryActionProps}/> menuItems = [,]; break; case COMPONENT_STATE.IDLE: primaryAction = mutate('start')} disabled={isPending} {...primaryActionProps}/> menuItems = [,,]; break; case COMPONENT_STATE.IGNORED: primaryAction = mutate('monitor')} disabled={isPending} {...primaryActionProps}/>; menuItems = [,,]; break; case COMPONENT_STATE.STOPPED: primaryAction = mutate('start')} disabled={isPending} {...primaryActionProps}/> menuItems = []; 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 = dialog.open('auth', {data: {id: componentId, errors: props.data.errors, authType: props.data.authType}})} disabled={isPending} {...primaryActionProps}/>; menuItems = []; } else { primaryAction = mutate('restart')} disabled={isPending} {...primaryActionProps}/>; } // no actions while init is occurring break; default: // otherwise generic start action for all non-running states primaryAction = mutate('restart')} disabled={isPending} {...primaryActionProps}/>; } if(authFailure[0] !== undefined && !([COMPONENT_STATE.NOT_READY,COMPONENT_STATE.ERROR] as ComponentState[]).includes(props.data.state)) { menuItems.push() } if(props.data.synced !== undefined) { menuItems.push() } 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 = ( {primaryAction} {menuItems} ); suffix = menuElm; } else if(primaryAction !== undefined) { suffix = primaryAction; } if(suffix !== undefined || primaryAction !== undefined) { badgeProps.paddingRight = 0; } return ; } 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 = ( Will next poll Source for activity at {shortTodayAwareFormat(wakeDay)} (in {durationToHuman(dayjs.duration(wakeDay.diff(now, 'ms')))})}> ) } else { sleepingRender = ; } } } 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 ( {sleepingRender}{data.status} {errors.length > 0 ? <>{errors.map(x => )} : undefined } {warnings.length > 0 ? <>{warnings.map(x => )} : undefined } {props.live ? : } ) } const ComponentDetailedSkeleton = () => { return ( Actions ) } export const ComponentDetailedFetchable = (props: { componentId: number }) => { const { isPending, isError, data, error } = useQuery({ ...tanQueries.components.single(props.componentId), }); let rendered; if (data === undefined) { rendered = } else if (isError) { rendered = } else { rendered = ; } const queryClient = useQueryClient(); const client = useSSEContext(); useSSEAnyEvent(client, (payload) => { if ('componentId' in (payload.data as object) && (payload.data as Record).componentId === props.componentId) { switch (payload.type) { case 'componentUpdate': queryClient.setQueryData(tanQueries.components.single(props.componentId).queryKey, (old: ComponentCommonApiJson) => { const componentData = payload.data as MsSseEventPayload>; return { ...old, ...componentData.data }; }); } } }); return rendered; } export const ComponentDetailedRoutable = () => { const params = useParams(); if(params.componentId === undefined) { return } return }