[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
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(source): Improve "Should Scrobble" functionality

* Rename from manualListening to Should Scrobble to better describe functionality
* Move check to Source scrobble function to avoid short circuting Source player for no reason
* Add to Azuracast and make functionality more generic

FoxxMD (Mar 20, 2025, 3:12 PM UTC) cbe28f97 336972f5

+86 -33
+1
src/core/Atomic.ts
··· 17 17 supportsUpstreamRecentlyPlayed: boolean; 18 18 supportsManualListening: boolean; 19 19 manualListening?: boolean 20 + systemListeningBehavior?: boolean 20 21 } 21 22 22 23 export interface ClientStatusData {
+3 -2
src/backend/server/api.ts
··· 197 197 supportsUpstreamRecentlyPlayed: x.supportsUpstreamRecentlyPlayed, 198 198 supportsManualListening: x.supportsManualListening, 199 199 manualListening: x.manualListening, 200 + systemListeningBehavior: x.getSystemListeningBehavior(), 200 201 ...x.additionalApiData() 201 202 }; 202 203 if(!x.isReady()) { ··· 463 464 464 465 if(!source.supportsManualListening) 465 466 { 466 - source.logger.warn('This source does not support manual listening'); 467 + source.logger.warn('This source does not support manual Should Scrobble state'); 467 468 res.status(400).send(); 468 469 return; 469 470 } ··· 471 472 if(listeningQ !== undefined) { 472 473 listening = parseBool(listeningQ) 473 474 } 474 - source.logger.verbose(`User requested listening status ${listening === undefined ? 'system' : listening}`); 475 + source.logger.verbose(`User requested Should Scrobble status ${listening === undefined ? 'system' : listening}`); 475 476 476 477 source.manualListening = listening; 477 478
+23 -2
src/backend/sources/AbstractSource.ts
··· 106 106 return `${capitalize(this.type)} - ${this.name}` 107 107 } 108 108 109 + getSystemListeningBehavior = (): boolean | undefined => { 110 + if(this.supportsManualListening) { 111 + return this.config.options !== undefined && 'systemScrobble' in this.config.options ? this.config.options?.systemScrobble : undefined; 112 + } 113 + return undefined; 114 + } 115 + 109 116 getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => [] 110 117 111 118 getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => { ··· 198 205 return newDiscoveredPlays; 199 206 } 200 207 208 + protected shouldScrobble = (discoverLocation?: 'backlog' | [key: string]) => { 209 + if(this.supportsManualListening && discoverLocation !== 'backlog') { 210 + const manualFlag = this.manualListening ?? this.getSystemListeningBehavior() ?? true; 211 + if(manualFlag === false) { 212 + this.logger.debug(`NOT scrobbling because Should Scrobble is FALSE (${this.manualListening === false ? 'user' : 'system'})`); 213 + return false; 214 + } 215 + } 216 + return true; 217 + } 201 218 202 - protected scrobble = (newDiscoveredPlays: PlayObject[], options: { forceRefresh?: boolean, [key: string]: any } = {}) => { 219 + 220 + protected scrobble = (newDiscoveredPlays: PlayObject[], options: { forceRefresh?: boolean, [key: string]: any, discoverLocation?: 'backlog' | [key: string] } = {}) => { 203 221 204 222 if(newDiscoveredPlays.length > 0) { 223 + if(!this.shouldScrobble(options.discoverLocation)) { 224 + return; 225 + } 205 226 newDiscoveredPlays.sort(sortByOldestPlayDate); 206 227 this.emitter.emit('discoveredToScrobble', { 207 228 data: newDiscoveredPlays.map(x => this.transformPlay(x, TRANSFORM_HOOK.postCompare)), ··· 233 254 } catch (e) { 234 255 throw new Error('Error occurred while fetching backlogged plays', {cause: e}); 235 256 } 236 - const discovered = this.discover(backlogPlays); 257 + const discovered = this.discover(backlogPlays, {discoverLocation: 'backlog'}); 237 258 238 259 const { 239 260 options: {
+9 -4
src/backend/sources/AzuracastSource.ts
··· 34 34 35 35 constructor(name: any, config: AzuracastSourceConfig, internal: InternalConfig, emitter: EventEmitter) { 36 36 const { 37 - data = {} 37 + data = {}, 38 + options = {}, 38 39 } = config; 39 40 const { 40 41 ...rest 41 42 } = data; 42 - super('azuracast', name, { ...config, data: { ...rest } }, internal, emitter); 43 43 44 44 const { 45 45 data: { 46 - url, 46 + monitorWhenListeners, 47 + monitorWhenLive 47 48 } = {} 48 49 } = config; 50 + 51 + super('azuracast', name, { ...config, options: {systemScrobble: monitorWhenListeners !== undefined || monitorWhenLive === true, ...options}, data: { ...rest } }, internal, emitter); 52 + 53 + 49 54 this.requiresAuth = false; 50 55 this.canPoll = true; 51 56 this.supportsManualListening = true; ··· 186 191 this.logger.debug({labels: `Station ${this.config.data.station}`}, `Currently offline`); 187 192 return false; 188 193 } 189 - if(this.manualListening !== undefined) { 194 + if(this.manualListening === true) { 190 195 this.logger.debug({labels: `Station ${this.config.data.station}`}, `Using manual listening status ${this.manualListening}`); 191 196 return this.manualListening; 192 197 }
+9 -9
src/backend/sources/IcecastSource.ts
··· 36 36 const { 37 37 ...rest 38 38 } = data || {}; 39 - super('icecast', name, { ...config, options: {scrobbleOnStart: false, ...options}, data: { ...rest } }, internal, emitter); 39 + super('icecast', name, { ...config, options: {systemScrobble: false, ...options}, data: { ...rest } }, internal, emitter); 40 40 41 41 this.requiresAuth = false; 42 42 this.canPoll = true; ··· 125 125 return this.processRecentPlays([]); 126 126 } 127 127 128 - if (this.manualListening === false || (this.config.options.scrobbleOnStart === false && this.manualListening === undefined)) { 129 - const playerState: PlayerStateData = { 130 - platformId: SINGLE_USER_PLATFORM_ID, 131 - status: REPORTED_PLAYER_STATUSES.stopped, 132 - play: undefined, 133 - } 128 + // if (this.manualListening === false || (this.config.options.scrobbleOnStart === false && this.manualListening === undefined)) { 129 + // const playerState: PlayerStateData = { 130 + // platformId: SINGLE_USER_PLATFORM_ID, 131 + // status: REPORTED_PLAYER_STATUSES.stopped, 132 + // play: undefined, 133 + // } 134 134 135 - return this.processRecentPlays([playerState]); 136 - } 135 + // return this.processRecentPlays([playerState]); 136 + // } 137 137 138 138 let play: PlayObject | undefined = formatPlayObj(this.currentMetadata); 139 139 if (play.data.track === undefined) {
+25 -7
src/client/components/statusCard/SourceStatusCard.tsx
··· 42 42 const [listenPut, listenResult] = useListenSourceMutation(); 43 43 44 44 const tryStart = useCallback((name: string, type: string, force?: boolean) => startPut({name, type, force}), [startPut]); 45 - const tryListen = useCallback((name: string, type: string, listening?: boolean) => listenPut({name, type, listening}), [listenPut]); 45 + const tryListen = useCallback((name: string, type: string, currentListening?: boolean) => { 46 + // cycle through states 47 + let nextListen: boolean | undefined; 48 + switch(currentListening) { 49 + case true: 50 + nextListen = false; 51 + break; 52 + case false: 53 + nextListen = undefined; 54 + break; 55 + case undefined: 56 + nextListen = true; 57 + break; 58 + } 59 + listenPut({name, type, listening: nextListen}); 60 + }, [listenPut]); 46 61 let startSourceElement = null; 47 62 let manualListenElement = null; 48 63 let subtitleElement = null; ··· 63 78 sot, 64 79 supportsUpstreamRecentlyPlayed, 65 80 supportsManualListening, 66 - manualListening 81 + manualListening, 82 + systemListeningBehavior 67 83 } = data; 68 84 if(type === 'listenbrainz' || type === 'lastfm') { 69 85 header = `${display} (Source)`; ··· 85 101 86 102 if(supportsManualListening) { 87 103 manualListenElement = (<Fragment> 88 - <span>Manual Listening:</span> 89 - <div onClick={() => tryListen(name, type, ml === undefined ? true : !ml)} 90 - className="capitalize underline cursor-pointer inline mr-1 ml-1">{ml === undefined ? 'System' : (ml ? 'Yes' : 'No')} 104 + <span>Should Scrobble:</span> 105 + <div onClick={() => tryListen(name, type, ml)} 106 + className="capitalize underline cursor-pointer inline mr-1 ml-1"> 107 + {ml !== undefined ? (ml ? 'Yes' : 'No') : null} 108 + {ml === undefined ? <span>System {systemListeningBehavior ? '(Yes)' : '(No)'}</span> : null} 91 109 </div> 92 - (<div onClick={() => tryListen(name, type, undefined)} 110 + {/* {ml !== undefined ? <div onClick={() => tryListen(name, type, undefined)} 93 111 className="capitalize underline cursor-pointer inline">Clear 94 - </div>) 112 + </div> : null} */} 95 113 </Fragment>); 96 114 } 97 115
+5 -1
src/backend/common/infrastructure/config/source/azuracast.ts
··· 1 - import { CommonSourceConfig, CommonSourceData } from "./index.js"; 1 + import { CommonSourceConfig, CommonSourceData, CommonSourceOptions, ManualListeningOptions } from "./index.js"; 2 2 3 3 export interface AzuraStationInfoResponse { 4 4 id: string ··· 91 91 * https://www.azuracast.com/docs/developers/apis/#api-authentication 92 92 * */ 93 93 apiKey?: string 94 + } 95 + 96 + export interface AzuracastSourceoptions extends CommonSourceOptions, ManualListeningOptions { 97 + 94 98 } 95 99 96 100 export interface AzuracastSourceConfig extends CommonSourceConfig {
+2 -8
src/backend/common/infrastructure/config/source/icecast.ts
··· 1 - import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js"; 1 + import { CommonSourceConfig, CommonSourceData, CommonSourceOptions, ManualListeningOptions } from "./index.js"; 2 2 3 3 4 4 export interface IcecastMetadata { ··· 34 34 url: string 35 35 } 36 36 37 - export interface IcecastSourceOptions extends CommonSourceOptions { 38 - /** 39 - * For Sources that support manual listening, should MS default to scrobbling when no manual listening flag is set? 40 - * 41 - * @default false 42 - */ 43 - scrobbleOnStart?: boolean 37 + export interface IcecastSourceOptions extends CommonSourceOptions, ManualListeningOptions { 44 38 } 45 39 46 40 export interface IcecastSourceConfig extends CommonSourceConfig {
+9
src/backend/common/infrastructure/config/source/index.ts
··· 109 109 playTransform?: PlayTransformOptions 110 110 } 111 111 112 + export interface ManualListeningOptions { 113 + /** 114 + * For Sources that support manual listening, should MS default to scrobbling when no user interaction has occurred? 115 + * 116 + * If not specified MS will use a Source's specific behavior, see Source's documentation. 117 + */ 118 + systemScrobble?: boolean 119 + } 120 + 112 121 export interface CommonSourceData extends CommonData { 113 122 114 123 }