diff --git a/src/backend/common/AbstractComponent.ts b/src/backend/common/AbstractComponent.ts index 1a2bef36..acbdd822 100644 --- a/src/backend/common/AbstractComponent.ts +++ b/src/backend/common/AbstractComponent.ts @@ -3,7 +3,7 @@ import { cacheFunctions, } from "@foxxmd/regex-buddy-core"; import type EventEmitter from "events"; -import type {ComponentType, LifecycleInput, LifecycleStep, PlayData, PlayObject} from "../../core/Atomic.ts"; +import {MONITORING_ORIGIN_SYSTEM, MONITORING_ORIGIN_USER, type ComponentType, type LifecycleInput, type LifecycleStep, type PlayData, type PlayObject} from "../../core/Atomic.ts"; import { buildTrackString } from "../../core/StringUtils.ts"; import type {CommonClientConfig} from "./infrastructure/config/client/index.ts"; import type {CommonSourceConfig} from "./infrastructure/config/source/index.ts"; @@ -33,7 +33,7 @@ import { getRetentionCompactAfterFromEnv, getRetentionDeleteAfterFromEnv, isComp import type {DbConcrete} from "./database/drizzle/drizzleUtils.ts"; import type {ComponentSelect} from "./database/drizzle/drizzleTypes.ts"; import { DrizzlePlayRepository } from "./database/drizzle/repositories/PlayRepository.ts"; -import type {ClientType} from "../../core/Atomic.ts"; +import type {ClientType, MonitoringStatus} from "../../core/Atomic.ts"; import type {SourceType} from "../../core/Atomic.ts"; import { DrizzleComponentRepository } from "./database/drizzle/repositories/ComponentRepository.ts"; import dayjs from "dayjs"; @@ -60,6 +60,9 @@ export default abstract class AbstractComponent extends AbstractInitializable { status: string = 'Waiting to initialize...'; emitter: EventEmitter; + monitoringActivity?: boolean | undefined; + monitoringActivityDefault: boolean = true; + protected componentType: ComponentType; type: ClientType | SourceType; name: string; @@ -540,6 +543,7 @@ export default abstract class AbstractComponent extends AbstractInitializable { name: this.dbComponent.name, state, mode: this.dbComponent.mode, + monitoringStatus: this.getMonitoringStatus(), countNonLive: this.dbComponent.countNonLive, createdAt: this.dbComponent.createdAt?.toISOString(), lastReadyAt: this.dbComponent.lastReadyAt?.toISOString(), @@ -585,4 +589,15 @@ export default abstract class AbstractComponent extends AbstractInitializable { this.status = status; this.emitComponentUpdate({status}); } + + public getSystemMonitoring = (): boolean => this.config.options?.autoMonitor ?? this.getSystemDefaultMonitoring(); + + protected getSystemDefaultMonitoring = (): boolean => this.monitoringActivityDefault; + + public isMonitoring = (): boolean => this.monitoringActivity ?? this.getSystemMonitoring(); + + public getMonitoringStatus = (): MonitoringStatus => ({ + monitoring: this.isMonitoring(), + origin: this.monitoringActivity !== undefined ? MONITORING_ORIGIN_USER : MONITORING_ORIGIN_SYSTEM + }) } diff --git a/src/backend/common/infrastructure/config/client/index.ts b/src/backend/common/infrastructure/config/client/index.ts index b85b8ca5..d6df33ba 100644 --- a/src/backend/common/infrastructure/config/client/index.ts +++ b/src/backend/common/infrastructure/config/client/index.ts @@ -1,6 +1,6 @@ import type {DurationValue} from "../../Atomic.ts"; import type {PlayTransformOptions} from "../../../../../core/Transform.ts"; -import type {CommonConfig, RequestRetryOptions} from "../common.ts"; +import type {CommonConfig, MonitorOptions, RequestRetryOptions} from "../common.ts"; import type {RetentionConfig} from "../database.ts"; /** @@ -83,7 +83,7 @@ export interface NowPlayingOptions { nowPlaying?: boolean | string[] } -export interface CommonClientOptions extends RequestRetryOptions, UpstreamRefreshOptions { +export interface CommonClientOptions extends RequestRetryOptions, UpstreamRefreshOptions, MonitorOptions { /** * Check client for an existing scrobble at the same recorded time as the "new" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled. diff --git a/src/backend/common/infrastructure/config/common.ts b/src/backend/common/infrastructure/config/common.ts index 5868e7d9..fe6af15f 100644 --- a/src/backend/common/infrastructure/config/common.ts +++ b/src/backend/common/infrastructure/config/common.ts @@ -88,3 +88,12 @@ export interface PollingOptions { orphanedAfter?: number } +export interface MonitorOptions { + /** + * Set the default behavior for wether this component should automatically monitor any activity, or scrobble, it encounters + * + * @default true + * @examples [true, false] + */ + autoMonitor?: boolean +} diff --git a/src/backend/common/infrastructure/config/source/azuracast.ts b/src/backend/common/infrastructure/config/source/azuracast.ts index 16738997..0abc45c1 100644 --- a/src/backend/common/infrastructure/config/source/azuracast.ts +++ b/src/backend/common/infrastructure/config/source/azuracast.ts @@ -1,4 +1,4 @@ -import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions, ManualListeningOptions} from "./index.ts"; +import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts"; export interface AzuraStationInfoResponse { id: string @@ -93,7 +93,7 @@ export interface AzuracastData extends CommonSourceData { apiKey?: string } -export interface AzuracastSourceoptions extends CommonSourceOptions, ManualListeningOptions { +export interface AzuracastSourceoptions extends CommonSourceOptions { } diff --git a/src/backend/common/infrastructure/config/source/icecast.ts b/src/backend/common/infrastructure/config/source/icecast.ts index daf3dcef..61198f7a 100644 --- a/src/backend/common/infrastructure/config/source/icecast.ts +++ b/src/backend/common/infrastructure/config/source/icecast.ts @@ -1,4 +1,4 @@ -import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions, ManualListeningOptions} from "./index.ts"; +import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts"; export interface IcecastMetadata { @@ -34,7 +34,7 @@ export interface IcecastData extends CommonSourceData, IcecastOptions { url: string } -export interface IcecastSourceOptions extends CommonSourceOptions, ManualListeningOptions { +export interface IcecastSourceOptions extends CommonSourceOptions { } export interface IcecastSourceConfig extends CommonSourceConfig { diff --git a/src/backend/common/infrastructure/config/source/index.ts b/src/backend/common/infrastructure/config/source/index.ts index b58ab78f..f290368f 100644 --- a/src/backend/common/infrastructure/config/source/index.ts +++ b/src/backend/common/infrastructure/config/source/index.ts @@ -1,7 +1,7 @@ import type { FileLogOptions, LogLevel } from "@foxxmd/logging"; import type { PlayTransformOptions } from "../../../../../core/Transform.ts"; -import type { CommonConfig, RequestRetryOptions } from "../common.ts"; +import type { CommonConfig, MonitorOptions, RequestRetryOptions } from "../common.ts"; import type { RetentionConfig } from "../database.ts"; import type { DurationValue } from "../../Atomic.ts"; @@ -44,7 +44,7 @@ export interface ScrobbleThresholds { percent?: number | null } -export interface CommonSourceOptions extends SourceRetryOptions { +export interface CommonSourceOptions extends SourceRetryOptions, MonitorOptions { /** * * If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload) then setting this option to true will make MS log the payload JSON to DEBUG output * * If this source is POLLING then it will log the raw data for each unique track/response the first time it is seen @@ -114,15 +114,6 @@ export interface CommonSourceOptions extends SourceRetryOptions { retention?: RetentionConfig } -export interface ManualListeningOptions { - /** - * For Sources that support manual listening, should MS default to scrobbling when no user interaction has occurred? - * - * If not specified MS will use a Source's specific behavior, see Source's documentation. - */ - systemScrobble?: boolean -} - export interface CommonSourceData { } diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index af5c35a1..f06a41ae 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -1143,6 +1143,14 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i let deadQueueEntity: QueueStateSelect; try { + + // want to fail scrobbles that are being *ingested* by component + // but not those that have already failed but will be dead queue processed + // since dead queued scrobbles are likely from a different time period that had monitoring + const monitoringStatus = this.getMonitoringStatus(); + if(!monitoringStatus.monitoring) { + throw new SimpleError(`Monitoring is disabled by ${capitalize(monitoringStatus.origin)}`); + } if (this.upstreamRefresh.refreshEnabled) { try { diff --git a/src/backend/server/api.ts b/src/backend/server/api.ts index 12f97e16..921a2802 100644 --- a/src/backend/server/api.ts +++ b/src/backend/server/api.ts @@ -380,9 +380,9 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro players: 'players' in x ? (x as MemorySource).playersToObject() as unknown as Record : {}, sot: ('playerSourceOfTruth' in x) ? x.playerSourceOfTruth as SOURCE_SOT_TYPES : SOURCE_SOT.HISTORY, supportsUpstreamRecentlyPlayed: x.supportsUpstreamRecentlyPlayed, - supportsManualListening: x.supportsManualListening, - manualListening: x.manualListening, - systemListeningBehavior: x.getSystemListeningBehavior(), + supportsManualListening: true, + manualListening: x.monitoringActivity, + systemListeningBehavior: x.getSystemMonitoring(), ...x.additionalApiData() }; if(!x.isReady()) { diff --git a/src/backend/sources/AbstractSource.ts b/src/backend/sources/AbstractSource.ts index 966f3bb8..683c3723 100644 --- a/src/backend/sources/AbstractSource.ts +++ b/src/backend/sources/AbstractSource.ts @@ -14,7 +14,7 @@ import { type InternalConfig, type ProgressAwarePlayObject, } from "../common/infrastructure/Atomic.ts"; -import type {PlayUserId} from '../../core/Atomic.ts'; +import type {PlayState, PlayUserId} from '../../core/Atomic.ts'; import type {DeviceId} from '../../core/Atomic.ts'; import type {SourceConfig} from '../common/infrastructure/config/source/sources.ts'; import type {SourceType} from "../../core/Atomic.ts"; @@ -46,6 +46,7 @@ import { asPlay } from '../../core/PlayMarshalUtils.ts'; import { AsyncTask, SimpleIntervalJob, ToadScheduler } from 'toad-scheduler'; import { COMPONENT_STATE, type ComponentSourceApiJson, type ComponentState, type PlayApiCommonDetailed } from '../../core/Api.ts'; import type {PaginatedResponse} from "../../core/Api.ts"; +import type { PlayWith } from '../common/database/drizzle/drizzleTypes.ts'; export interface RecentlyPlayedOptions { limit?: number @@ -293,22 +294,12 @@ export default abstract class AbstractSource extends AbstractComponent implement tracksDiscovered: this.tracksDiscovered, sot: SOURCE_SOT.HISTORY, supportsUpstreamRecentlyPlayed: this.supportsUpstreamRecentlyPlayed, - supportsManualListening: this.supportsManualListening, - manualListening: this.manualListening, - systemListeningBehavior: this.getSystemListeningBehavior(), sleeping: this.getIsSleeping(), wakeAt: this.wakeAt !== undefined ? this.wakeAt.toISOString() : undefined, countLive: this.tracksDiscoveredTotal } } - getSystemListeningBehavior = (): boolean | undefined => { - if(this.supportsManualListening) { - return this.config.options !== undefined && 'systemScrobble' in this.config.options ? this.config.options?.systemScrobble : undefined; - } - return undefined; - } - getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise => [] getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise => { @@ -324,8 +315,14 @@ export default abstract class AbstractSource extends AbstractComponent implement // TODO make this more descriptive? or move it elsewhere recentlyPlayedTrackIsValid = (playObj: PlayObject) => true - protected addPlayToDiscovered = async (play: PlayObject): Promise => { - const playRow = await this.playRepo.createPlays([(playToRepositoryCreatePlayOpts({play, state: 'discovered'}))]); + protected addPlayToDB = async (play: PlayObject): Promise> => { + const monitorStatus = this.getMonitoringStatus(); + let state: PlayState = 'discovered'; + if(!monitorStatus.monitoring) { + this.logger.debug(`Not adding ${buildTrackString(play)} as discovered because monitoring is disabled by ${capitalize(monitorStatus.origin)}`); + state = 'discarded'; + } + const playRow = await this.playRepo.createPlays([(playToRepositoryCreatePlayOpts({play, state}))]); const recentPlays = await this.getRecentlyDiscoveredPlays(false); // only need to update if its already in memory, // and better to update in-memory than clear cache so we aren't refetching from db on every discover @@ -334,15 +331,17 @@ export default abstract class AbstractSource extends AbstractComponent implement recentPlays.sort(sortByOldestPlayDate); this.cache.cacheDb.set(this.recentDiscoveredCacheKey(), recentPlays, '2m'); } - this.tracksDiscovered++; - this.tracksDiscoveredTotal++ - this.logger.info(`Discovered => ${buildTrackString(play)}`); + if(state === 'discovered') { + this.tracksDiscovered++; + this.tracksDiscoveredTotal++ + this.discoveredCounter.labels(this.getPrometheusLabels()).inc(); + } + this.logger.info(`${capitalize(state)} => ${buildTrackString(play)}`); this.emitEvent('discovered', {play}); this.emitPlayInsert({...playRow[0], queueStates: []} as unknown as PlayApiCommonDetailed); - this.discoveredCounter.labels(this.getPrometheusLabels()).inc(); - play.id = playRow[0].id; - play.uid = playRow[0].uid; - return play; + playRow[0].play.id = playRow[0].id; + playRow[0].play.uid = playRow[0].uid; + return playRow[0]; } getFlatRecentlyDiscoveredPlays = async (): Promise => { @@ -398,8 +397,10 @@ export default abstract class AbstractSource extends AbstractComponent implement const existing = await this.existingDiscovered(play); if(existing === undefined) { options.signal?.throwIfAborted() - const hydratedPlay = await this.addPlayToDiscovered(play); - newDiscoveredPlays.push(hydratedPlay); + const hydratedPlay = await this.addPlayToDB(play); + if(hydratedPlay.state === 'discovered') { + newDiscoveredPlays.push(hydratedPlay.play); + } } else { this.playRepo.updateById(existing.id, {updatedAt: dayjs()}); } @@ -419,26 +420,10 @@ export default abstract class AbstractSource extends AbstractComponent implement return newDiscoveredPlays; } - protected shouldScrobble = (discoverLocation?: 'backlog' | [key: string]) => { - if(this.supportsManualListening && discoverLocation !== 'backlog') { - const manualFlag = this.manualListening ?? this.getSystemListeningBehavior() ?? true; - if(manualFlag === false) { - this.logger.debug(`NOT scrobbling because Should Scrobble is FALSE (${this.manualListening === false ? 'user' : 'system'})`); - return false; - } - } - return true; - } - protected scrobble = async (newDiscoveredPlays: PlayObject[], options: { forceRefresh?: boolean, [key: string]: any, discoverLocation?: 'backlog' | [key: string] } = {}) => { if(newDiscoveredPlays.length > 0) { - if(!this.shouldScrobble(options.discoverLocation)) { - await this.playRepo.setStateById('discarded', newDiscoveredPlays.map(x => x.id)); - this.setStatus(`Discarded ${newDiscoveredPlays} new Plays${options.discoverLocation !== undefined ? ` from ${options.discoverLocation} ` : ''}`); - return; - } newDiscoveredPlays.sort(sortByOldestPlayDate); this.emitter.emit('discoveredToScrobble', { data: await pMap(newDiscoveredPlays, this.staggerMappers.postCompare(async (x) => await this.transformPlay(x, TRANSFORM_HOOK.postCompare)), {concurrency: 3}), @@ -470,7 +455,7 @@ export default abstract class AbstractSource extends AbstractComponent implement this.logger.info('Discovering backlogged tracks from recently played API...'); this.setStatus('Discovering backlogged tracks from recently played API...'); - let backlogPlays: PlayObject[] = []; + let backlogPlays: PlayObject[]; const { scrobbleBacklogCount = this.SCROBBLE_BACKLOG_COUNT } = this.config.options || {}; @@ -652,11 +637,8 @@ export default abstract class AbstractSource extends AbstractComponent implement } this.abortController.abort(reason); let elapsed = 0; - let lastlog: Dayjs; while(this.polling && elapsed < (10 * this.stopPollingWaitInterval)) { - if(lastlog === undefined || dayjs().diff(lastlog, 's') >= 2) { - this.logger.verbose(`Waiting for polling stop signal to be acknowledged (waited ${formatNumber(elapsed/1000)}s)`); - } + this.logger.verbose(`Waiting for polling stop signal to be acknowledged (waited ${formatNumber(elapsed/1000)}s)`); await sleep(this.stopPollingWaitInterval); elapsed += this.stopPollingWaitInterval; } diff --git a/src/backend/sources/AppleMusicSource.ts b/src/backend/sources/AppleMusicSource.ts index 36099ce3..a595423e 100644 --- a/src/backend/sources/AppleMusicSource.ts +++ b/src/backend/sources/AppleMusicSource.ts @@ -360,7 +360,7 @@ export default class AppleMusicSource extends AbstractSource { reversedPlays.reverse(); for(const refPlay of reversedPlays) { - await this.addPlayToDiscovered(refPlay); + await this.addPlayToDB(refPlay); } } return true; diff --git a/src/backend/sources/AzuracastSource.ts b/src/backend/sources/AzuracastSource.ts index b569f288..d4d3f393 100644 --- a/src/backend/sources/AzuracastSource.ts +++ b/src/backend/sources/AzuracastSource.ts @@ -28,25 +28,11 @@ export class AzuracastSource extends MemorySource { wsNowPlaying: AzuraStationResponse wsCurrenTime: number = 0; client!: WS; + override monitoringActivityDefault = false; constructor(name: any, config: AzuracastSourceConfig, internal: InternalConfig, emitter: EventEmitter) { - const { - data = {}, - options = {}, - } = config; - const { - ...rest - } = data; - - const { - data: { - monitorWhenListeners, - monitorWhenLive - } = {} - } = config; - - super('azuracast', name, { ...config, options: {systemScrobble: monitorWhenListeners !== undefined || monitorWhenLive === true, ...options}, data: { ...rest } }, internal, emitter); + super('azuracast', name, config, internal, emitter); this.requiresAuth = false; @@ -228,6 +214,14 @@ export class AzuracastSource extends MemorySource { return await this.processRecentPlays([playerState]); } + protected getSystemDefaultMonitoring = (): boolean => { + const { + monitorWhenLive, + monitorWhenListeners + } = this.config.data; + return monitorWhenLive !== undefined || monitorWhenListeners !== undefined; + } + } const formatPlayObj = (obj: AzuraNowPlayingResponse, options: FormatPlayObjectOptions = {}): PlayObject => { diff --git a/src/backend/sources/IcecastSource.ts b/src/backend/sources/IcecastSource.ts index 5027f440..2b8b134d 100644 --- a/src/backend/sources/IcecastSource.ts +++ b/src/backend/sources/IcecastSource.ts @@ -30,6 +30,8 @@ export class IcecastSource extends MemorySource { streamError?: Error; streaming: boolean = false; + override monitoringActivityDefault = false; + constructor(name: any, config: IcecastSourceConfig, internal: InternalConfig, emitter: EventEmitter) { const { data, @@ -38,7 +40,7 @@ export class IcecastSource extends MemorySource { const { ...rest } = data || {}; - super('icecast', name, { ...config, options: {systemScrobble: false, ...options}, data: { ...rest } }, internal, emitter); + super('icecast', name, config, internal, emitter); this.requiresAuth = false; this.canPoll = true; diff --git a/src/backend/sources/YTMusicSource.ts b/src/backend/sources/YTMusicSource.ts index 6468c980..ed8b0ce8 100644 --- a/src/backend/sources/YTMusicSource.ts +++ b/src/backend/sources/YTMusicSource.ts @@ -691,7 +691,7 @@ ${humanDiff}`; // and add to discovered since its empty for(const refPlay of reversedPlays) { //this.transientDiscovered.add(refPlay); - await this.addPlayToDiscovered(refPlay); + await this.addPlayToDB(refPlay); } } } diff --git a/src/core/Api.ts b/src/core/Api.ts index e36666dc..c15976ef 100644 --- a/src/core/Api.ts +++ b/src/core/Api.ts @@ -1,6 +1,6 @@ import type { PickKeys } from "ts-essentials" import type { CompareOpKey, ComponentMinimalSelect } from "../backend/common/database/drizzle/drizzleTypes.ts" -import type { ClientType } from "./Atomic.ts" +import type { ClientType, MonitoringStatus } from "./Atomic.ts" import type { SourceType } from "./Atomic.ts" import type { ComponentType, DateLike, ErrorLike, JsonPlayObject, PlayState, QueueName, Replace, SOURCE_SOT_TYPES, SourcePlayerJson } from "./Atomic.ts" import type { Dayjs } from "dayjs" @@ -82,6 +82,7 @@ export type ComponentCommonApi = { players: Record error?: ErrorIsh warning?: ErrorIsh + monitoringStatus?: MonitoringStatus } & Omit export type ComponentCommonApiJson = Replace, string>; @@ -108,9 +109,6 @@ export type ComponentClientApiJson = Replace { return clientTypes.includes(data as ClientType); }; +export type MonitoringOrigin = 'user' | 'system'; +export const MONITORING_ORIGIN_USER: MonitoringOrigin = 'user'; +export const MONITORING_ORIGIN_SYSTEM: MonitoringOrigin = 'system'; +export interface MonitoringStatus { + monitoring: boolean + origin: MonitoringOrigin +} -- 2.51.2 From 86ebd3d6730a8e3c0237f82d58c2c15fbe2048ae Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 23 Jul 2026 14:34:52 +0000 Subject: [PATCH 2/6] fix(ui): Only render component details when data is present --- src/client/components/msComponent/MSComponentDetailed.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/client/components/msComponent/MSComponentDetailed.tsx b/src/client/components/msComponent/MSComponentDetailed.tsx index ee680eff..798c51dd 100644 --- a/src/client/components/msComponent/MSComponentDetailed.tsx +++ b/src/client/components/msComponent/MSComponentDetailed.tsx @@ -11,7 +11,6 @@ import { useSSEContext, useSSEAnyEvent } from "@flamefrontend/sse-runtime-react"; -import { isComponentTypeSource } from "../../../core/Atomic.js"; import { Link } from "react-router"; import { CountLiveIndicator, DateIndicator, DeadLetterIndicator, QueuedIndicator } from "./Stats.js"; import { ListContainerFilterable } from "../playActivity/ActivityList.js"; @@ -225,7 +224,7 @@ export const ComponentDetailedFetchable = (props: { componentId: number }) => { }); let rendered; - if (isPending && data === undefined) { + if (data === undefined) { rendered = } else if (isError) { rendered = -- 2.51.2 From 956054f387429033872afbc7d03d0b910d3cbe6b Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 23 Jul 2026 14:50:34 +0000 Subject: [PATCH 3/6] refactor(ui): Update legacy ui to be compatible with new monitoring structure --- src/backend/server/api.ts | 35 ++++++++++---- src/backend/sources/AbstractSource.ts | 8 ++-- src/backend/sources/AzuracastSource.ts | 7 ++- src/backend/sources/IcecastSource.ts | 1 - .../statusCard/ClientStatusCard.tsx | 46 +++++++++++++++++-- .../statusCard/SourceStatusCard.tsx | 19 ++++---- .../components/statusCard/clientDucks.ts | 17 ++++++- src/core/Atomic.ts | 3 +- 8 files changed, 103 insertions(+), 33 deletions(-) diff --git a/src/backend/server/api.ts b/src/backend/server/api.ts index 921a2802..e957a628 100644 --- a/src/backend/server/api.ts +++ b/src/backend/server/api.ts @@ -380,7 +380,6 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro players: 'players' in x ? (x as MemorySource).playersToObject() as unknown as Record : {}, sot: ('playerSourceOfTruth' in x) ? x.playerSourceOfTruth as SOURCE_SOT_TYPES : SOURCE_SOT.HISTORY, supportsUpstreamRecentlyPlayed: x.supportsUpstreamRecentlyPlayed, - supportsManualListening: true, manualListening: x.monitoringActivity, systemListeningBehavior: x.getSystemMonitoring(), ...x.additionalApiData() @@ -427,7 +426,9 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro initialized: x.isReady(), deadLetterScrobbles: x.deadLetterQueued, // x.deadLetterScrobbles.length, deadLetterScrobblesTotal: x.deadLetterLength, - queued: x.queuedLength // x.queuedScrobbles.length + queued: x.queuedLength, // x.queuedScrobbles.length + manualListening: x.monitoringActivity, + systemListeningBehavior: x.getSystemMonitoring(), }; if (!base.initialized) { if(x.buildOK === false) { @@ -671,19 +672,35 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro } } = req; - if(!source.supportsManualListening) - { - source.logger.warn('This source does not support manual Should Scrobble state'); - res.status(400).send(); - return; + let listening: boolean | undefined; + if(listeningQ !== undefined) { + listening = parseBool(listeningQ) } + source.logger.verbose(`User requested Monitoring status ${listening === undefined ? 'system' : listening}`); + + source.monitoringActivity = listening; + + res.status(200).json({listening}); + }); + + app.use('/api/client/listen', clientRequiredMiddle); + app.post('/api/client/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 client = req.scrobbleClient as AbstractScrobbleClient; + + const { + query: { + listening: listeningQ + } + } = req; + let listening: boolean | undefined; if(listeningQ !== undefined) { listening = parseBool(listeningQ) } - source.logger.verbose(`User requested Should Scrobble status ${listening === undefined ? 'system' : listening}`); + client.logger.verbose(`User requested Monitoring status ${listening === undefined ? 'system' : listening}`); - source.manualListening = listening; + client.monitoringActivity = listening; res.status(200).json({listening}); }); diff --git a/src/backend/sources/AbstractSource.ts b/src/backend/sources/AbstractSource.ts index 683c3723..669c0ca0 100644 --- a/src/backend/sources/AbstractSource.ts +++ b/src/backend/sources/AbstractSource.ts @@ -87,8 +87,6 @@ export default abstract class AbstractSource extends AbstractComponent implement supportsUpstreamNowPlaying: boolean = false; supportsManualListening: boolean = false; - manualListening?: boolean - scheduler: ToadScheduler = new ToadScheduler(); protected SCROBBLE_BACKLOG_COUNT: number = 30; @@ -268,12 +266,12 @@ export default abstract class AbstractSource extends AbstractComponent implement } public getRunningState(): ComponentState { - const monitoring = (this.canPoll && this.polling) || !this.canPoll; + const running = (this.canPoll && this.polling) || !this.canPoll; - if(monitoring && this.supportsManualListening && this.manualListening === false) { + if(running && !this.isMonitoring()) { return COMPONENT_STATE.MUTED; } - return monitoring ? COMPONENT_STATE.RUNNING : COMPONENT_STATE.IDLE; + return running ? COMPONENT_STATE.RUNNING : COMPONENT_STATE.IDLE; } protected getComponentApiData() { diff --git a/src/backend/sources/AzuracastSource.ts b/src/backend/sources/AzuracastSource.ts index d4d3f393..607ba933 100644 --- a/src/backend/sources/AzuracastSource.ts +++ b/src/backend/sources/AzuracastSource.ts @@ -37,7 +37,6 @@ export class AzuracastSource extends MemorySource { this.requiresAuth = false; this.canPoll = true; - this.supportsManualListening = true; } protected async doBuildInitData(): Promise { @@ -175,9 +174,9 @@ export class AzuracastSource extends MemorySource { this.logger.debug({labels: `Station ${this.config.data.station}`}, `Currently offline`); return false; } - if(this.manualListening === true) { - this.logger.debug({labels: `Station ${this.config.data.station}`}, `Using manual listening status ${this.manualListening}`); - return this.manualListening; + if(this.monitoringActivity !== undefined) { + this.logger.debug({labels: `Station ${this.config.data.station}`}, `Using manual listening status ${this.monitoringActivity}`); + return this.monitoringActivity; } if(this.config.data.monitorWhenListeners !== undefined) { if(this.config.data.monitorWhenListeners === true && this.wsNowPlaying.listeners.current === 0) { diff --git a/src/backend/sources/IcecastSource.ts b/src/backend/sources/IcecastSource.ts index 2b8b134d..7b78e35a 100644 --- a/src/backend/sources/IcecastSource.ts +++ b/src/backend/sources/IcecastSource.ts @@ -44,7 +44,6 @@ export class IcecastSource extends MemorySource { this.requiresAuth = false; this.canPoll = true; - this.supportsManualListening = true; } protected async doBuildInitData(): Promise { diff --git a/src/client/components/statusCard/ClientStatusCard.tsx b/src/client/components/statusCard/ClientStatusCard.tsx index 79605d93..f618fc91 100644 --- a/src/client/components/statusCard/ClientStatusCard.tsx +++ b/src/client/components/statusCard/ClientStatusCard.tsx @@ -5,7 +5,7 @@ import {clientAdapter} from "../../status/ducks"; import type {RootState} from "../../store"; import {connect, type ConnectedProps} from "react-redux"; import {Link} from "react-router-dom"; -import {useStartClientMutation} from "./clientDucks"; +import {useStartClientMutation, useListenClientMutation} from "./clientDucks"; export interface ClientStatusCardData extends StatusCardSkeletonData, PropsFromRedux { loading?: boolean @@ -40,8 +40,25 @@ const ClientStatusCard = (props: ClientStatusCardData) => { } = props; const [startClientPut, startResult] = useStartClientMutation(); + const [listenPut, listenResult] = useListenClientMutation(); const tryStart = useCallback((name: string, force?: boolean) => startClientPut({name, force}), [startClientPut]); + const tryListen = useCallback((name: string, type: string, currentListening?: boolean) => { + // cycle through states + let nextListen: boolean | undefined; + switch(currentListening) { + case true: + nextListen = false; + break; + case false: + nextListen = undefined; + break; + case undefined: + nextListen = true; + break; + } + listenPut({name, type, listening: nextListen}); + }, [listenPut]); let header: string | undefined = display; let body = ; @@ -50,10 +67,13 @@ const ClientStatusCard = (props: ClientStatusCardData) => {
tryStart(name)} className="capitalize underline cursor-pointer inline mr-1">{status === 'Running' ? 'Restart' : 'Start'}
(
tryStart(name, true)} className="capitalize underline cursor-pointer inline">Force
) ) + let subtitleElement = startClientElement; if(data !== undefined) { const { hasAuth, hasAuthInteraction, + manualListening, + systemListeningBehavior, name, type, authed, @@ -61,9 +81,27 @@ const ClientStatusCard = (props: ClientStatusCardData) => { } = data; header = `${display} (Client)`; - const scrobbled = initialized && (!hasAuth || (hasAuth && authed)) ? Tracks Scrobbled : Tracks Scrobbled; + const ml = useMemo(() => { + if(listenResult.status !== 'fulfilled' || listenResult.data === undefined) { + return manualListening; + } + return (listenResult.data as any).listening; + }, [manualListening, listenResult]); + + const manualListenElement = ( + Monitoring: +
tryListen(name, type, ml)} + className="capitalize underline cursor-pointer inline mr-1 ml-1"> + {ml !== undefined ? (ml ? 'Yes' : 'No') : null} + {ml === undefined ? System {systemListeningBehavior ? '(Yes)' : '(No)'} : null} +
+ {/* {ml !== undefined ?
tryListen(name, type, undefined)} + className="capitalize underline cursor-pointer inline">Clear +
: null} */} +
); + subtitleElement = {startClientElement};//{manualListenElement} | {startClientElement}; - // TODO links + const scrobbled = initialized && (!hasAuth || (hasAuth && authed)) ? Tracks Scrobbled : Tracks Scrobbled; body = (
{scrobbled}: {scrobbledCount}
Queued Scrobbles: {queued}
@@ -77,7 +115,7 @@ const ClientStatusCard = (props: ClientStatusCardData) => { title={header} subtitle={name} status={status} - subtitleRight={startClientElement} + subtitleRight={subtitleElement} statusType={statusToStatusType(status)}> {body} diff --git a/src/client/components/statusCard/SourceStatusCard.tsx b/src/client/components/statusCard/SourceStatusCard.tsx index 4b4937be..66a3eb29 100644 --- a/src/client/components/statusCard/SourceStatusCard.tsx +++ b/src/client/components/statusCard/SourceStatusCard.tsx @@ -8,6 +8,9 @@ import {connect, type ConnectedProps} from "react-redux"; import Player from "../player/Player"; import {useStartSourceMutation, useListenSourceMutation} from "./sourceDucks"; import './statusCard.scss'; +import Tooltip from '../Tooltip'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import {faQuestionCircle} from '@fortawesome/free-solid-svg-icons' const ambiguousTypes = ['lastfm','listenbrainz','koito','librefm','maloja','rocksky']; export interface SourceStatusCardData extends StatusCardSkeletonData, PropsFromRedux { @@ -59,8 +62,6 @@ const SourceStatusCard = (props: SourceStatusCardData) => { } listenPut({name, type, listening: nextListen}); }, [listenPut]); - let startSourceElement = null; - let manualListenElement = null; let subtitleElement = null; if(data !== undefined) @@ -78,7 +79,6 @@ const SourceStatusCard = (props: SourceStatusCardData) => { players = {}, sot, supportsUpstreamRecentlyPlayed, - supportsManualListening, manualListening, systemListeningBehavior } = data; @@ -100,9 +100,13 @@ const SourceStatusCard = (props: SourceStatusCardData) => { return (listenResult.data as any).listening; }, [manualListening, listenResult]); - if(supportsManualListening) { - manualListenElement = ( - Should Scrobble: + const manualListenElement = type !== 'azuracast' && type !== 'icecast' ? null : ( + Monitoring + : +
tryListen(name, type, ml)} className="capitalize underline cursor-pointer inline mr-1 ml-1"> {ml !== undefined ? (ml ? 'Yes' : 'No') : null} @@ -112,9 +116,8 @@ const SourceStatusCard = (props: SourceStatusCardData) => { className="capitalize underline cursor-pointer inline">Clear
: null} */}
); - } - startSourceElement = ( + const startSourceElement = (
tryStart(name, type)} className="capitalize underline cursor-pointer inline mr-1">{startText}
diff --git a/src/client/components/statusCard/clientDucks.ts b/src/client/components/statusCard/clientDucks.ts index 1559213c..039641f8 100644 --- a/src/client/components/statusCard/clientDucks.ts +++ b/src/client/components/statusCard/clientDucks.ts @@ -16,8 +16,23 @@ export const scrobblerApi = createApi({ force: params.force } }) + }), + listenClient: builder.mutation({ + query: (params) => ({ + url: '/client/listen', + method: 'POST', + params: { + name: params.name, + type: params.type, + listening: params.listening + } + }) }) }) }); -export const {useStartClientMutation} = scrobblerApi; +export const {useStartClientMutation, useListenClientMutation} = scrobblerApi; diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index f426a003..fcbca122 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -28,7 +28,6 @@ export interface SourceStatusData { players: Record sot: SOURCE_SOT_TYPES supportsUpstreamRecentlyPlayed: boolean; - supportsManualListening: boolean; manualListening?: boolean systemListeningBehavior?: boolean } @@ -46,6 +45,8 @@ export interface ClientStatusData { hasAuthInteraction: boolean; authed: boolean; initialized: boolean; + manualListening?: boolean + systemListeningBehavior?: boolean } export type PlayObjectIncludeTypes = 'album' | 'time' | 'artist' | 'track' | 'timeFromNow' | 'trackId' | 'comment' | 'platform' | 'session'; -- 2.51.2 From c25ca5297cda8836d8d069b3c85f9670018e1e10 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 23 Jul 2026 15:08:04 +0000 Subject: [PATCH 4/6] docs: Replace should scrobble with monitoring behavior --- .../docs/configuration/sources/azuracast.mdx | 6 +- .../docs/configuration/sources/icecast.mdx | 4 +- .../docs/configuration/sources/sources.mdx | 68 +++++++++---------- .../snippets/_should-scrobble-details.mdx | 2 +- .../components/snippets/_should-scrobble.mdx | 19 ++++-- 5 files changed, 53 insertions(+), 46 deletions(-) diff --git a/docsite/docs/configuration/sources/azuracast.mdx b/docsite/docs/configuration/sources/azuracast.mdx index 1a09bb19..7138317c 100644 --- a/docsite/docs/configuration/sources/azuracast.mdx +++ b/docsite/docs/configuration/sources/azuracast.mdx @@ -38,14 +38,14 @@ URL From Config => MS Uses -Multi-scrobbler will set the default **scrobbling behavior** based on if either of these settings is configured: +When `autoMonitor` is not explicitly set, Multi-scrobbler will set the **default monitoring behavior** based on if either of these settings is configured: * live status (`AZURA_LIVE` or `monitorWhenLive`) or * listener count (`AZURA_LISTENERS_NUM` or `monitorWhenListeners`) -If either is configured then MS will automatically scrobble based on when these conditions are met. +If either is configured then MS will automatically monitor based on when these conditions are met. -If neither is configured automatic scrobble defaults to **off** and you should either manually initiate it or explicitly set the default behavior. +If neither is configured automatic monitoring defaults to **off** and you should either manually initiate it or explicitly set the default behavior. ### Station diff --git a/docsite/docs/configuration/sources/icecast.mdx b/docsite/docs/configuration/sources/icecast.mdx index a01a4c79..94f72462 100644 --- a/docsite/docs/configuration/sources/icecast.mdx +++ b/docsite/docs/configuration/sources/icecast.mdx @@ -21,9 +21,9 @@ Multi-scrobbler will do its best to parse Artist/Album/Track information from th ::: -Multi-scrobbler cannot automatically determine if you are listening to a Station. Therefore, **automatic scrobbling for Icecast Sources is off by default.** Use **Should Scrobble Behavior** to initiate scrobbling or configure automatic scrobble. +Multi-scrobbler cannot automatically determine if you are listening to a Station. Therefore, **automatic monitoring for Icecast Sources is off by default.** Use **Monitoring Behavior** to initiate scrobbling or configure automatic scrobble. - + :::tip[Scrobbling Threshold] diff --git a/docsite/docs/configuration/sources/sources.mdx b/docsite/docs/configuration/sources/sources.mdx index 721c2e67..c508fd72 100644 --- a/docsite/docs/configuration/sources/sources.mdx +++ b/docsite/docs/configuration/sources/sources.mdx @@ -13,37 +13,37 @@ A **Source** is a data source that contains information about tracks you are pla ## Sources -| Name | Networking | Scrobble SOT | [Multi Device/User](#multi-deviceuser) | [Scrobble Destination](#limiting-scrobble-destination) | [Thresholds](#scrobble-thresholds) | [Should Scrobble](#should-scrobble-behavior) | -| :---------------------------------------------------------------------- | :------------------------------------------------------- | :-------------------------------------------------- | :------------------------------------- | :----------------------------------------------------- | :--------------------------------- | :------------------------------------------- | -| [Apple Music](/configuration/sources/applemusic) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | -| [Azuracast](/configuration/sources/azuracast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ✅ | -| [Deezer](/configuration/sources/deezer) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ✅ | ✅ | ❌ | ❌ | -| [Google Cast (Chromecast)](/configuration/sources/google-cast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ | -| [Icecast](/configuration/sources/icecast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ✅ | -| [Jellyfin](/configuration/sources/jellyfin) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ | -| [JRiver](/configuration/sources/jriver) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ | -| [Kodi](/configuration/sources/kodi) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ | -| [Koito](/configuration/sources/koito-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | -| [Last.fm](/configuration/sources/lastfm-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | -| [Libre.fm](/configuration/sources/librefm-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | -| [Last.fm (Endpoint)](/configuration/sources/lastfm-endpoint) | [Ingress](./?sourceComm=ingress#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | -| [ListenBrainz](/configuration/sources/listenbrainz-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | -| [ListenBrainz (Endpoint)](/configuration/sources/listenbrainz-endpoint) | [Ingress](./?sourceComm=ingress#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | -| [Maloja](/configuration/sources/maloja) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | -| [Mopidy](/configuration/sources/mopidy) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ | -| [MPD (Music Player Daemon)](/configuration/sources/mpd) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ | -| [Musikcube](/configuration/sources/musikcube) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ | -| [Plex](/configuration/sources/plex) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ | -| [Rocksky](/configuration/sources/rocksky-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | -| [Sonos](/configuration/sources/sonos) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ | -| [Spotify](/configuration/sources/spotify) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ | -| [Subsonic-compatible APIs](/configuration/sources/subsonic) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ | -| [teal.fm](/configuration/sources/tealfm-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | -| [WebScrobbler](/configuration/sources/webscrobbler) | [Ingress](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | -| [VLC](/configuration/sources/vlc) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ | -| [Yamaha MusicCast](/configuration/sources/yamaha-musiccast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ | -| [Yandex Music](/configuration/sources/yandex-music) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | | ✅ | ✅ | ❌ | -| [Youtube Music](/configuration/sources/youtube-music) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | +| Name | Networking | Scrobble SOT | [Multi Device/User](#multi-deviceuser) | [Scrobble Destination](#limiting-scrobble-destination) | [Thresholds](#scrobble-thresholds) | [Auto Monitoring](#monitoring) | +| :---------------------------------------------------------------------- | :------------------------------------------------------- | :-------------------------------------------------- | :------------------------------------- | :----------------------------------------------------- | :--------------------------------- | :----------------------------- | +| [Apple Music](/configuration/sources/applemusic) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ✅ | +| [Azuracast](/configuration/sources/azuracast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ⚠️ | +| [Deezer](/configuration/sources/deezer) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ✅ | ✅ | ❌ | ✅ | +| [Google Cast (Chromecast)](/configuration/sources/google-cast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ✅ | +| [Icecast](/configuration/sources/icecast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ⚠️ | +| [Jellyfin](/configuration/sources/jellyfin) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ✅ | +| [JRiver](/configuration/sources/jriver) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ✅ | +| [Kodi](/configuration/sources/kodi) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ✅ | +| [Koito](/configuration/sources/koito-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ✅ | +| [Last.fm](/configuration/sources/lastfm-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ✅ | +| [Libre.fm](/configuration/sources/librefm-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ✅ | +| [Last.fm (Endpoint)](/configuration/sources/lastfm-endpoint) | [Ingress](./?sourceComm=ingress#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ✅ | +| [ListenBrainz](/configuration/sources/listenbrainz-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ✅ | +| [ListenBrainz (Endpoint)](/configuration/sources/listenbrainz-endpoint) | [Ingress](./?sourceComm=ingress#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ✅ | +| [Maloja](/configuration/sources/maloja) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ✅ | +| [Mopidy](/configuration/sources/mopidy) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ✅ | +| [MPD (Music Player Daemon)](/configuration/sources/mpd) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ✅ | +| [Musikcube](/configuration/sources/musikcube) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ✅ | +| [Plex](/configuration/sources/plex) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ✅ | +| [Rocksky](/configuration/sources/rocksky-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ✅ | +| [Sonos](/configuration/sources/sonos) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ✅ | +| [Spotify](/configuration/sources/spotify) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ✅ | +| [Subsonic-compatible APIs](/configuration/sources/subsonic) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ✅ | +| [teal.fm](/configuration/sources/tealfm-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ✅ | +| [WebScrobbler](/configuration/sources/webscrobbler) | [Ingress](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ✅ | +| [VLC](/configuration/sources/vlc) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ✅ | +| [Yamaha MusicCast](/configuration/sources/yamaha-musiccast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ✅ | +| [Yandex Music](/configuration/sources/yandex-music) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | | ✅ | ✅ | ✅ | +| [Youtube Music](/configuration/sources/youtube-music) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ✅ | ## Features @@ -134,7 +134,7 @@ SOURCE_SCROBBLE_DURATION=40 SOURCE_SCROBBLE_PERCENT=20 ``` -### Should Scrobble Behavior +### Monitoring :::note @@ -142,7 +142,7 @@ This feature is currently available only for some Sources. Check each Source's d ::: -For _most_ Sources multi-scrobbler can determine if a user is listening and scrobble automatically. For Sources which multi-scrobbler cannot do this or require additional configuration, a user can indicate to MS that it should or should not scrobble. This action can be taken on the Web Dashboard by clicking the link next to **Should Scrobble** found in each Source's status card. +For _most_ Sources multi-scrobbler can determine if a user is listening and monitor activity automatically. For Sources which multi-scrobbler cannot do this or require additional configuration, a user can indicate to MS that it should or should not monitor activity. This action can be taken on the Web Dashboard by clicking the link next to **Monitoring** found in each Source's status card. Additionally, for these Sources, the default behavior for Scrobbling can be explicitly set by adding the following value to a Source's `options` in [file or aio config](/configuration?configType=file#configuration-types): @@ -154,7 +154,7 @@ Additionally, for these Sources, the default behavior for Scrobbling can be expl "options": { // set to true to always scrobble automatically // set to false to never scrobble automatically - "systemScrobble": true + "autoMonitor": true } } ``` \ No newline at end of file diff --git a/docsite/src/components/snippets/_should-scrobble-details.mdx b/docsite/src/components/snippets/_should-scrobble-details.mdx index 99b88582..4a39a3ff 100644 --- a/docsite/src/components/snippets/_should-scrobble-details.mdx +++ b/docsite/src/components/snippets/_should-scrobble-details.mdx @@ -1,5 +1,5 @@ import ShouldScrobble from "@site/src/components/snippets/_should-scrobble.mdx" - + \ No newline at end of file diff --git a/docsite/src/components/snippets/_should-scrobble.mdx b/docsite/src/components/snippets/_should-scrobble.mdx index 1974ecdf..e9144928 100644 --- a/docsite/src/components/snippets/_should-scrobble.mdx +++ b/docsite/src/components/snippets/_should-scrobble.mdx @@ -1,8 +1,8 @@ import CodeBlock from '@theme/CodeBlock'; -This **Source** supports toggling scrobbling on or off manually. This action can be taken on the Web Dashboard by clicking the link next to **Should Scrobble** found in each Source's status card. +This **Source** supports toggling [monitoring on or off manually.](/configuration/sources#monitoring) This action can be taken on the Web Dashboard by clicking the link next to **Monitoring** found in each Source's status card. -Additionally, the default behavior for scrobbling can be explicitly set by adding the following value to a Source's `options` in [file or aio config](/configuration?configType=file#configuration-types): +Additionally, the **default monitoring behavior** can be explicitly set by adding the following value to a Source's `options` in [file or aio config](/configuration?configType=file#configuration-types): }, "options": { // highlight-start - // set to true to always scrobble automatically - // set to false to never scrobble automatically - "systemScrobble": true + // set to true to always monitor (unless manually invoked) + // set to false to never monitor (unless manually invoked) + "autoMonitor": true // highlight-end } }}`} - \ No newline at end of file + + +{props.defaultBehavior === true ? ( +This component's default monitoring behavior, when the above is not set, is to always monitor. +) : undefined} +{props.defaultBehavior === false ? ( +This component's default monitoring behavior, when the above is not set, is to never monitor. +) : undefined} \ No newline at end of file -- 2.51.2 From c727445f76a47030ef32e029135f1c55785e035a Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 23 Jul 2026 15:32:57 +0000 Subject: [PATCH 5/6] fix: Fix api test fixtures for new api surface with monitoring --- src/core/tests/utils/apiFixtures.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/core/tests/utils/apiFixtures.ts b/src/core/tests/utils/apiFixtures.ts index 22963e99..73933401 100644 --- a/src/core/tests/utils/apiFixtures.ts +++ b/src/core/tests/utils/apiFixtures.ts @@ -114,6 +114,7 @@ export const generateComponentCommonApiJson = (data: Partial lastActiveAt = dayjs(), lastReadyAt = dayjs(), state = faker.number.int({min: 1, max: 7}) as ComponentState, + monitoringStatus = { monitoring: faker.datatype.boolean({probability: 0.1}), origin: 'system' }, players = {}, ...rest } = data; @@ -142,6 +143,7 @@ export const generateComponentCommonApiJson = (data: Partial state, players, status: faker.helpers.arrayElement(statusSamples), + monitoringStatus, ...rest } } @@ -160,9 +162,6 @@ export const generateSourceApiJson = (data: Partial = {}): C const { sot = faker.helpers.arrayElement(sourceSotTypes), supportsUpstreamRecentlyPlayed = faker.datatype.boolean(), - supportsManualListening = faker.datatype.boolean({probability: 0.1}), - manualListening = faker.datatype.boolean({probability: 0.1}), - systemListeningBehavior = true, tracksDiscovered = faker.number.int({min: 1, max: 2000}), players = (data.players ?? {}), sleeping = false, @@ -170,10 +169,7 @@ export const generateSourceApiJson = (data: Partial = {}): C return { ...common, sot, - supportsManualListening, supportsUpstreamRecentlyPlayed, - manualListening, - systemListeningBehavior, tracksDiscovered, players, sleeping -- 2.51.2 From aa91d2cb92e068d020861e224f3c07a598016872 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 23 Jul 2026 16:24:17 +0000 Subject: [PATCH 6/6] fix: Fix missed autoMonitor variable name change --- src/backend/sources/ScrobbleSources.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index fbe32458..932a75eb 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -600,7 +600,7 @@ export default class ScrobbleSources { } } break; case 'icecast': { - const scrobbleStart = parseBool(process.env.ICECAST_SCROBBLE_START); + const scrobbleStart = parseBool(process.env.ICECAST_AUTO_MONITOR); const data: IcecastData = removeUndefinedKeys({ url: process.env.ICECAST_URL, }, false); @@ -615,7 +615,7 @@ export default class ScrobbleSources { data: data, ...p, options: transformPresetEnv('ICECAST', { - systemScrobble: scrobbleStart + autoMonitor: scrobbleStart }) }); }