[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(client)!: Overhaul source scrobble filtering

* Use id OR name for matching client to scrobble to, during transition period
* Deprecate using name-only and warn when this is detected
* Consolidate logging filtered clients to one statement to reduce log noise
* Improve filter matching by making strings case and whitepsace insensitive
* On startup log `clients` options for sources

FoxxMD (Jul 16, 2026, 3:51 PM UTC) 1dba73cc 3e82e035

+214 -18
+6 -7
src/backend/common/AbstractComponent.ts
··· 79 79 }; 80 80 } 81 81 82 + public getUid() { 83 + return this.config?.id ?? this.config?.name ?? this.name; 84 + } 85 + 82 86 protected postCache(): Promise<void> { 83 87 try { 84 88 this.buildTransformRules(); ··· 96 100 protected async doBuildDatabase(): Promise<true | string | undefined> { 97 101 await super.doBuildDatabase(); 98 102 99 - let name: string; 100 - if('name' in this) { 101 - name = this.name as string; 102 - } 103 - 104 103 this.db = await getRoot().items.db(); 105 104 this.componentRepo = new DrizzleComponentRepository(this.db, {logger: this.logger}); 106 105 this.dbComponent = await this.componentRepo.findOrInsert({ 107 106 mode: this.componentType, 108 107 type: this.type, 109 - uid: this.config.id ?? this.config.name ?? name, 110 - name: this.config.name ?? name 108 + uid: this.getUid(), 109 + name: this.config?.name ?? this.name 111 110 }); 112 111 this.componentId = this.dbComponent.id; 113 112 return true;
+1 -1
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 1807 1807 return dayjs().isAfter(this.nowPlayingExpirationDate); 1808 1808 } 1809 1809 1810 - public getQueued = (queueName: string, statuses: string[], offset?: number) => { 1810 + public getQueued = (queueName: string, offset?: number) => { 1811 1811 return this.playRepo.getQueued(queueName, {offset}); 1812 1812 } 1813 1813
+50 -7
src/backend/scrobblers/ScrobbleClients.ts
··· 26 26 import type {LibrefmClientConfig, LibrefmData} from '../common/infrastructure/config/client/librefm.ts'; 27 27 import clone from 'clone'; 28 28 import type {DiscordClientConfig, DiscordData} from '../common/infrastructure/config/client/discord.ts'; 29 + import { stripIndents } from 'common-tags'; 30 + import { normalizeStr, type StringNormalizationOptions } from '../utils/StringUtils.ts'; 29 31 30 32 type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; 31 33 32 34 type ParsedConfig = ClientAIOConfig & ConfigMeta; 33 35 36 + const clientScrobbleToNormalization: StringNormalizationOptions = { 37 + removeWhitespace: true, 38 + removeSymbols: false, 39 + normalizeUnicode: false, 40 + removeDiacritics: false 41 + } 42 + 34 43 export default class ScrobbleClients { 35 44 36 45 clients: AbstractScrobbleClient[] = []; ··· 41 50 emitter: WildcardEmitter; 42 51 43 52 sourceEmitter: WildcardEmitter; 53 + 54 + scrobbleToNamesWarnings: string[] = []; 44 55 45 56 constructor(emitter: WildcardEmitter, sourceEmitter: WildcardEmitter, internal: InternalConfigOptional, parentLogger: Logger) { 46 57 this.emitter = emitter; ··· 509 520 this.logger.trace('Cannot update Now Playing! No clients are configured.'); 510 521 } 511 522 523 + const excluded: string[] = []; 512 524 for (const client of this.clients) { 513 525 if(!client.supportsNowPlaying || !client.nowPlayingEnabled) { 514 526 continue; 515 527 } 516 - if (scrobbleTo.length > 0 && !scrobbleTo.includes(client.name)) { 517 - if(isDebugMode()) { 518 - client.logger.debug(`Client was filtered out by Source '${scrobbleFrom.type} - ${scrobbleFrom.name}'`); 528 + if (scrobbleTo.length > 0) { 529 + // removing whitespace, case-insensitive, and trimming 530 + const cNameNormal = normalizeStr(client.name, clientScrobbleToNormalization); 531 + const cUidNormal = normalizeStr(client.getUid(), clientScrobbleToNormalization); 532 + const name = scrobbleTo.find(x => normalizeStr(x, clientScrobbleToNormalization) === cNameNormal) 533 + const id = scrobbleTo.find(x => normalizeStr(x, clientScrobbleToNormalization) === cUidNormal); 534 + 535 + if(name === undefined && id === undefined) { 536 + excluded.push(client.getUid()); 537 + continue; 538 + } else if(name !== undefined && id === undefined && !this.scrobbleToNamesWarnings.includes(`${name}-${scrobbleFrom.type}-${scrobbleFrom.name}`)) { 539 + client.logger.warn(stripIndents`Using Client *name* '${name}' in the \`clients\` fields for a Source (${scrobbleFrom}) is DEPRECATED and will be removed in a future release. 540 + Replace the *name* with the *id* '${client.getUid()}' of this Client.`); 541 + this.scrobbleToNamesWarnings.push(`${name}-${scrobbleFrom}`); 542 + 519 543 } 520 - continue; 521 544 } 522 545 for (const playObj of playObjs) { 523 546 await client.queuePlayingNow(playObj, scrobbleFrom); 524 547 } 548 + } 549 + if(excluded.length > 0) { 550 + this.logger.trace(`These Now Playing clients were filtered from Source '${scrobbleFrom.type} - ${scrobbleFrom.name}' => ${excluded.join(' | ')}`); 525 551 } 526 552 } 527 553 ··· 554 580 this.logger.warn('Cannot scrobble! No clients are configured.'); 555 581 } 556 582 583 + const excluded: string[] = []; 557 584 for (const client of this.clients) { 558 - if (scrobbleTo.length > 0 && !scrobbleTo.includes(client.name)) { 559 - client.logger.debug(`Client was filtered out by Source '${scrobbleFrom}'`); 560 - continue; 585 + if (scrobbleTo.length > 0) { 586 + // removing whitespace, case-insensitive, and trimming 587 + const cNameNormal = normalizeStr(client.name, clientScrobbleToNormalization); 588 + const cUidNormal = normalizeStr(client.getUid(), clientScrobbleToNormalization); 589 + const name = scrobbleTo.find(x => normalizeStr(x, clientScrobbleToNormalization) === cNameNormal) 590 + const id = scrobbleTo.find(x => normalizeStr(x, clientScrobbleToNormalization) === cUidNormal); 591 + 592 + if(name === undefined && id === undefined) { 593 + excluded.push(client.getUid()); 594 + continue; 595 + } else if(name !== undefined && id === undefined && !this.scrobbleToNamesWarnings.includes(`${name}-${scrobbleFrom}`)) { 596 + client.logger.warn(stripIndents`Using Client *name* '${name}' in the \`clients\` fields for a Source (${scrobbleFrom}) is DEPRECATED and will be removed in a future release. 597 + Replace the *name* with the *id* '${client.getUid()}' of this Client.`); 598 + this.scrobbleToNamesWarnings.push(`${name}-${scrobbleFrom}`); 599 + 600 + } 561 601 } 562 602 for (const playObj of playObjs) { 563 603 await client.queueScrobble(clone(playObj), scrobbleFrom); 564 604 } 605 + } 606 + if(excluded.length > 0) { 607 + this.logger.trace(`These clients were filtered from scrobbling from Source '${scrobbleFrom}' => ${excluded.join(' | ')}`); 565 608 } 566 609 } 567 610 }
+1
src/backend/sources/AbstractSource.ts
··· 119 119 this.loggerLabel = this.getIdentifier(); 120 120 this.config = config; 121 121 this.clients = clients; 122 + this.logger.debug(`Scrobble To: ${this.clients.length === 0 ? 'All' : this.clients.join(' | ')}`); 122 123 this.instantiatedAt = dayjs(); 123 124 this.lastActivityAt = this.instantiatedAt; 124 125 this.localUrl = internal.localUrl;
+156 -3
src/backend/tests/scrobbler/scrobblers.test.ts
··· 28 28 import { fixtureCreatePlay } from '../utils/databaseFixtures.ts'; 29 29 import { isAbortError } from 'abort-controller-x'; 30 30 import { artistNamesToCredits } from '../../../core/StringUtils.ts'; 31 + import ScrobbleClients from '../../scrobblers/ScrobbleClients.ts'; 32 + import { WildcardEmitter } from '../../common/WildcardEmitter.ts'; 33 + import type { CommonClientConfig } from '../../common/infrastructure/config/client/index.ts'; 34 + import { loggerNoop } from '../../common/MaybeLogger.ts'; 31 35 32 36 chai.use(asPromised); 33 37 ··· 42 46 43 47 const normalizedWithMixedDurOlder = normalizePlays(mixedDurPlays, {initialDate: olderFirstPlayDate}); 44 48 45 - const generateTestScrobbler = async () => { 46 - const testScrobbler = new TestScrobbler(); 49 + const generateTestScrobbler = async (config?: CommonClientConfig) => { 50 + const testScrobbler = new TestScrobbler(config); 47 51 testScrobbler.verboseOptions = { 48 52 match: { 49 53 onMatch: true, ··· 1139 1143 const consolidatedRanges = groupPlaysToTimeRanges(plays, [], {consolidateDuration: DEFAULT_CONSOLIDATE_DURATION}); 1140 1144 expect(consolidatedRanges.length).eq(3); 1141 1145 }); 1142 - }) 1146 + }); 1147 + 1148 + describe('Scrobble Clients Behavior', function() { 1149 + 1150 + describe('Source filtering', function() { 1151 + 1152 + let cEmitter: WildcardEmitter, 1153 + sEmitter: WildcardEmitter, 1154 + clients: ScrobbleClients; 1155 + 1156 + beforeEach(function() { 1157 + cEmitter = new WildcardEmitter(); 1158 + sEmitter = new WildcardEmitter(); 1159 + clients = new ScrobbleClients(cEmitter, sEmitter, { 1160 + localUrl: new URL('http://example.com'), 1161 + configDir: process.cwd(), 1162 + version: 'test' 1163 + }, 1164 + loggerNoop); 1165 + }); 1166 + 1167 + it('does not scrobble when both name and id do not match', async function() { 1168 + 1169 + const testClient = await generateTestScrobbler({id: 'test', name: 'test'}); 1170 + clients.clients.push(testClient); 1171 + 1172 + sEmitter.emit('discoveredToScrobble', { 1173 + data: [generatePlay()], 1174 + options: { 1175 + scrobbleFrom: 'testSource', 1176 + scrobbleTo: ['foo'] 1177 + } 1178 + }); 1179 + expect(clients.scrobbleToNamesWarnings).is.empty; 1180 + await Promise.race([ 1181 + pEvent(testClient.emitter, 'scrobbleQueued'), 1182 + sleep(10) 1183 + ]); 1184 + const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE); 1185 + expect(queued.data).is.empty; 1186 + }); 1187 + 1188 + it('warns when scrobbleTo matches name but not id', async function() { 1189 + 1190 + const testClient = await generateTestScrobbler({id: 'testid', name: 'test'}); 1191 + clients.clients.push(testClient); 1192 + 1193 + sEmitter.emit('discoveredToScrobble', { 1194 + data: [generatePlay()], 1195 + options: { 1196 + scrobbleFrom: 'testSource', 1197 + scrobbleTo: ['test'] 1198 + } 1199 + }); 1200 + expect(clients.scrobbleToNamesWarnings).length.greaterThan(0); 1201 + await Promise.race([ 1202 + pEvent(testClient.emitter, 'scrobbleQueued'), 1203 + sleep(100) 1204 + ]) 1205 + const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE); 1206 + expect(queued.data).is.not.empty; 1207 + }); 1208 + 1209 + it('scrobbles when both name and id match', async function() { 1210 + 1211 + const testClient = await generateTestScrobbler({id: 'test', name: 'test'}); 1212 + clients.clients.push(testClient); 1213 + 1214 + sEmitter.emit('discoveredToScrobble', { 1215 + data: [generatePlay()], 1216 + options: { 1217 + scrobbleFrom: 'testSource', 1218 + scrobbleTo: ['test'] 1219 + } 1220 + }); 1221 + expect(clients.scrobbleToNamesWarnings).is.empty; 1222 + await Promise.race([ 1223 + pEvent(testClient.emitter, 'scrobbleQueued'), 1224 + sleep(50) 1225 + ]) 1226 + const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE); 1227 + expect(queued.data).is.not.empty; 1228 + }); 1229 + 1230 + it('scrobbles without warning when only id matches', async function() { 1231 + 1232 + const testClient = await generateTestScrobbler({id: 'test foo', name: 'test'}); 1233 + clients.clients.push(testClient); 1234 + 1235 + sEmitter.emit('discoveredToScrobble', { 1236 + data: [generatePlay()], 1237 + options: { 1238 + scrobbleFrom: 'testSource', 1239 + scrobbleTo: ['test foo'] 1240 + } 1241 + }); 1242 + expect(clients.scrobbleToNamesWarnings).is.empty; 1243 + await Promise.race([ 1244 + pEvent(testClient.emitter, 'scrobbleQueued'), 1245 + sleep(50) 1246 + ]) 1247 + const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE); 1248 + expect(queued.data).is.not.empty; 1249 + }); 1250 + 1251 + it('scrobbles when scrobbleTo is empty', async function() { 1252 + 1253 + const testClient = await generateTestScrobbler({id: 'test foo', name: 'test'}); 1254 + clients.clients.push(testClient); 1255 + 1256 + sEmitter.emit('discoveredToScrobble', { 1257 + data: [generatePlay()], 1258 + options: { 1259 + scrobbleFrom: 'testSource', 1260 + scrobbleTo: [] 1261 + } 1262 + }); 1263 + expect(clients.scrobbleToNamesWarnings).is.empty; 1264 + await Promise.race([ 1265 + pEvent(testClient.emitter, 'scrobbleQueued'), 1266 + sleep(50) 1267 + ]) 1268 + const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE); 1269 + expect(queued.data).is.not.empty; 1270 + }); 1271 + 1272 + it('scrobbleTo is case-insensitive and ignores whitespace', async function() { 1273 + 1274 + const testClient = await generateTestScrobbler({id: 'test foo ', name: 'test'}); 1275 + clients.clients.push(testClient); 1276 + 1277 + sEmitter.emit('discoveredToScrobble', { 1278 + data: [generatePlay()], 1279 + options: { 1280 + scrobbleFrom: 'testSource', 1281 + scrobbleTo: ['TesT foO'] 1282 + } 1283 + }); 1284 + expect(clients.scrobbleToNamesWarnings).is.empty; 1285 + await Promise.race([ 1286 + pEvent(testClient.emitter, 'scrobbleQueued'), 1287 + sleep(50) 1288 + ]) 1289 + const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE); 1290 + expect(queued.data).is.not.empty; 1291 + }); 1292 + 1293 + }); 1294 + 1295 + });