diff --git a/src/backend/server/api.ts b/src/backend/server/api.ts index 8596dafb..52cee28c 100644 --- a/src/backend/server/api.ts +++ b/src/backend/server/api.ts @@ -28,7 +28,6 @@ import { makeClientCheckMiddle, makeSourceCheckMiddle } from "./middleware.js"; import { setupPlexRoutes } from "./plexRoutes.js"; import { setupTautulliRoutes } from "./tautulliRoutes.js"; import { setupWebscrobblerRoutes } from "./webscrobblerRoutes.js"; -import { Readable } from 'node:stream'; const maxBufferSize = 300; const output: Record> = {}; @@ -192,6 +191,8 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream: players: 'players' in x ? (x as MemorySource).playersToObject() : {}, sot: ('playerSourceOfTruth' in x) ? x.playerSourceOfTruth : SOURCE_SOT.HISTORY, supportsUpstreamRecentlyPlayed: x.supportsUpstreamRecentlyPlayed, + supportsManualListening: x.supportsManualListening, + manualListening: x.manualListening, ...x.additionalApiData() }; if(!x.isReady()) { @@ -445,6 +446,34 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream: } }); + app.use('/api/source/listen', sourceRequiredMiddle); + app.postAsync('/api/source/listen', async (req, res) => { + // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message + const source = req.scrobbleSource as AbstractSource; + + const { + query: { + listening: listeningQ + } + } = req; + + if(!source.supportsManualListening) + { + source.logger.warn('This source does not support manual listening'); + res.status(400).send(); + return; + } + let listening: boolean | undefined; + if(listeningQ !== undefined) { + listening = parseBool(listeningQ) + } + source.logger.verbose(`User requested listening status ${listening === undefined ? 'system' : listening}`); + + source.manualListening = listening; + + res.status(200).json({listening}); + }); + app.use('/api/client/init', clientRequiredMiddle); app.postAsync('/api/client/init', async (req, res) => { // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message diff --git a/src/backend/sources/AbstractSource.ts b/src/backend/sources/AbstractSource.ts index 4ad38e31..15a1cf48 100644 --- a/src/backend/sources/AbstractSource.ts +++ b/src/backend/sources/AbstractSource.ts @@ -74,6 +74,9 @@ export default abstract class AbstractSource extends AbstractComponent implement supportsUpstreamRecentlyPlayed: boolean = false; supportsUpstreamNowPlaying: boolean = false; + supportsManualListening: boolean = false; + + manualListening?: boolean emitter: EventEmitter; diff --git a/src/backend/sources/AzuracastSource.ts b/src/backend/sources/AzuracastSource.ts index 02efd426..633088dc 100644 --- a/src/backend/sources/AzuracastSource.ts +++ b/src/backend/sources/AzuracastSource.ts @@ -48,6 +48,7 @@ export class AzuracastSource extends MemoryPositionalSource { } = config; this.requiresAuth = false; this.canPoll = true; + this.supportsManualListening = true; } protected async doBuildInitData(): Promise { @@ -185,6 +186,10 @@ export class AzuracastSource extends MemoryPositionalSource { this.logger.debug({leaf: `Station ${this.config.data.station}`}, `Currently offline`); return false; } + if(this.manualListening !== undefined) { + this.logger.debug({leaf: `Station ${this.config.data.station}`}, `Using manual listening status ${this.manualListening}`); + return this.manualListening; + } if(this.config.data.monitorWhenListeners !== undefined) { if(this.config.data.monitorWhenListeners === true && this.wsNowPlaying.listeners.current === 0) { this.logger.debug({leaf: `Station ${this.config.data.station}`}, `No listeners`); diff --git a/src/client/components/statusCard/SourceStatusCard.tsx b/src/client/components/statusCard/SourceStatusCard.tsx index 2e64efa2..e19b9657 100644 --- a/src/client/components/statusCard/SourceStatusCard.tsx +++ b/src/client/components/statusCard/SourceStatusCard.tsx @@ -1,4 +1,4 @@ -import React, {Fragment, useCallback} from 'react'; +import React, {Fragment, useCallback, useMemo} from 'react'; import StatusCardSkeleton, {StatusCardSkeletonData} from "./StatusCardSkeleton"; import SkeletonParagraph from "../skeleton/SkeletonParagraph"; import {Link} from "react-router-dom"; @@ -6,7 +6,7 @@ import {sourceAdapter} from "../../status/ducks"; import {RootState} from "../../store"; import {connect, ConnectedProps} from "react-redux"; import Player from "../player/Player"; -import {useStartSourceMutation} from "./sourceDucks"; +import {useStartSourceMutation, useListenSourceMutation} from "./sourceDucks"; import './statusCard.scss'; export interface SourceStatusCardData extends StatusCardSkeletonData, PropsFromRedux { @@ -39,9 +39,13 @@ const SourceStatusCard = (props: SourceStatusCardData) => { let body = ; const [startPut, startResult] = useStartSourceMutation(); + const [listenPut, listenResult] = useListenSourceMutation(); const tryStart = useCallback((name: string, type: string, force?: boolean) => startPut({name, type, force}), [startPut]); + const tryListen = useCallback((name: string, type: string, listening?: boolean) => listenPut({name, type, listening}), [listenPut]); let startSourceElement = null; + let manualListenElement = null; + let subtitleElement = null; if(data !== undefined) { @@ -57,7 +61,9 @@ const SourceStatusCard = (props: SourceStatusCardData) => { type, players = {}, sot, - supportsUpstreamRecentlyPlayed + supportsUpstreamRecentlyPlayed, + supportsManualListening, + manualListening } = data; if(type === 'listenbrainz' || type === 'lastfm') { header = `${display} (Source)`; @@ -70,6 +76,25 @@ const SourceStatusCard = (props: SourceStatusCardData) => { startText = status === 'Running' ? 'Reinit' : 'Init' } + const ml = useMemo(() => { + if(listenResult.status !== 'fulfilled' || listenResult.data === undefined) { + return manualListening; + } + return (listenResult.data as any).listening; + }, [manualListening, listenResult]); + + if(supportsManualListening) { + manualListenElement = ( + Manual Listening: +
tryListen(name, type, ml === undefined ? true : !ml)} + className="capitalize underline cursor-pointer inline mr-1 ml-1">{ml === undefined ? 'System' : (ml ? 'Yes' : 'No')} +
+ (
tryListen(name, type, undefined)} + className="capitalize underline cursor-pointer inline">Clear +
) +
); + } + startSourceElement = (
tryStart(name, type)} className="capitalize underline cursor-pointer inline mr-1">{startText} @@ -79,6 +104,12 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
)
); + if(manualListenElement !== null) { + subtitleElement = {manualListenElement} | {startSourceElement} + } else { + subtitleElement = startSourceElement; + } + const platformIds = Object.keys(players); const discovered = (!hasAuth || authed) ? Tracks Discovered : Tracks Discovered; @@ -103,7 +134,7 @@ const SourceStatusCard = (props: SourceStatusCardData) => { title={header} subtitle={name} status={status} - subtitleRight={startSourceElement} + subtitleRight={subtitleElement} statusType={statusToStatusType(status)}> {body} diff --git a/src/client/components/statusCard/sourceDucks.ts b/src/client/components/statusCard/sourceDucks.ts index 538bb825..57b277dd 100644 --- a/src/client/components/statusCard/sourceDucks.ts +++ b/src/client/components/statusCard/sourceDucks.ts @@ -18,8 +18,23 @@ export const sourceApi = createApi({ force: params.force } }) + }), + listenSource: builder.mutation({ + query: (params) => ({ + url: '/source/listen', + method: 'POST', + params: { + name: params.name, + type: params.type, + listening: params.listening + } + }) }) }) }); -export const {useStartSourceMutation} = sourceApi; +export const {useStartSourceMutation, useListenSourceMutation} = sourceApi; diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index b1ca1751..44ae219e 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -15,6 +15,8 @@ export interface SourceStatusData { players: Record sot: SOURCE_SOT_TYPES supportsUpstreamRecentlyPlayed: boolean; + supportsManualListening: boolean; + manualListening?: boolean } export interface ClientStatusData {