diff --git a/src/backend/common/AbstractComponent.ts b/src/backend/common/AbstractComponent.ts index dc991023..1a2bef36 100644 --- a/src/backend/common/AbstractComponent.ts +++ b/src/backend/common/AbstractComponent.ts @@ -79,6 +79,10 @@ export default abstract class AbstractComponent extends AbstractInitializable { }; } + public getUid() { + return this.config?.id ?? this.config?.name ?? this.name; + } + protected postCache(): Promise { try { this.buildTransformRules(); @@ -96,18 +100,13 @@ export default abstract class AbstractComponent extends AbstractInitializable { protected async doBuildDatabase(): Promise { await super.doBuildDatabase(); - let name: string; - if('name' in this) { - name = this.name as string; - } - this.db = await getRoot().items.db(); this.componentRepo = new DrizzleComponentRepository(this.db, {logger: this.logger}); this.dbComponent = await this.componentRepo.findOrInsert({ mode: this.componentType, type: this.type, - uid: this.config.id ?? this.config.name ?? name, - name: this.config.name ?? name + uid: this.getUid(), + name: this.config?.name ?? this.name }); this.componentId = this.dbComponent.id; return true; diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index d53e9421..af5c35a1 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -1807,7 +1807,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i return dayjs().isAfter(this.nowPlayingExpirationDate); } - public getQueued = (queueName: string, statuses: string[], offset?: number) => { + public getQueued = (queueName: string, offset?: number) => { return this.playRepo.getQueued(queueName, {offset}); } diff --git a/src/backend/scrobblers/ScrobbleClients.ts b/src/backend/scrobblers/ScrobbleClients.ts index cf8eb779..4b657c84 100644 --- a/src/backend/scrobblers/ScrobbleClients.ts +++ b/src/backend/scrobblers/ScrobbleClients.ts @@ -26,11 +26,20 @@ import type {ExternalMetadataTerm, PlayTransformHooks} from '../../core/Transfor import type {LibrefmClientConfig, LibrefmData} from '../common/infrastructure/config/client/librefm.ts'; import clone from 'clone'; import type {DiscordClientConfig, DiscordData} from '../common/infrastructure/config/client/discord.ts'; +import { stripIndents } from 'common-tags'; +import { normalizeStr, type StringNormalizationOptions } from '../utils/StringUtils.ts'; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; type ParsedConfig = ClientAIOConfig & ConfigMeta; +const clientScrobbleToNormalization: StringNormalizationOptions = { + removeWhitespace: true, + removeSymbols: false, + normalizeUnicode: false, + removeDiacritics: false +} + export default class ScrobbleClients { clients: AbstractScrobbleClient[] = []; @@ -42,6 +51,8 @@ export default class ScrobbleClients { sourceEmitter: WildcardEmitter; + scrobbleToNamesWarnings: string[] = []; + constructor(emitter: WildcardEmitter, sourceEmitter: WildcardEmitter, internal: InternalConfigOptional, parentLogger: Logger) { this.emitter = emitter; this.sourceEmitter = sourceEmitter; @@ -509,20 +520,35 @@ ${sources.join('\n')}`); this.logger.trace('Cannot update Now Playing! No clients are configured.'); } + const excluded: string[] = []; for (const client of this.clients) { if(!client.supportsNowPlaying || !client.nowPlayingEnabled) { continue; } - if (scrobbleTo.length > 0 && !scrobbleTo.includes(client.name)) { - if(isDebugMode()) { - client.logger.debug(`Client was filtered out by Source '${scrobbleFrom.type} - ${scrobbleFrom.name}'`); + if (scrobbleTo.length > 0) { + // removing whitespace, case-insensitive, and trimming + const cNameNormal = normalizeStr(client.name, clientScrobbleToNormalization); + const cUidNormal = normalizeStr(client.getUid(), clientScrobbleToNormalization); + const name = scrobbleTo.find(x => normalizeStr(x, clientScrobbleToNormalization) === cNameNormal) + const id = scrobbleTo.find(x => normalizeStr(x, clientScrobbleToNormalization) === cUidNormal); + + if(name === undefined && id === undefined) { + excluded.push(client.getUid()); + continue; + } else if(name !== undefined && id === undefined && !this.scrobbleToNamesWarnings.includes(`${name}-${scrobbleFrom.type}-${scrobbleFrom.name}`)) { + 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. + Replace the *name* with the *id* '${client.getUid()}' of this Client.`); + this.scrobbleToNamesWarnings.push(`${name}-${scrobbleFrom}`); + } - continue; } for (const playObj of playObjs) { await client.queuePlayingNow(playObj, scrobbleFrom); } } + if(excluded.length > 0) { + this.logger.trace(`These Now Playing clients were filtered from Source '${scrobbleFrom.type} - ${scrobbleFrom.name}' => ${excluded.join(' | ')}`); + } } getPlayingNow = (source: string, scrobbleTo: string[]): PlayObject[] => { @@ -554,15 +580,32 @@ ${sources.join('\n')}`); this.logger.warn('Cannot scrobble! No clients are configured.'); } + const excluded: string[] = []; for (const client of this.clients) { - if (scrobbleTo.length > 0 && !scrobbleTo.includes(client.name)) { - client.logger.debug(`Client was filtered out by Source '${scrobbleFrom}'`); - continue; + if (scrobbleTo.length > 0) { + // removing whitespace, case-insensitive, and trimming + const cNameNormal = normalizeStr(client.name, clientScrobbleToNormalization); + const cUidNormal = normalizeStr(client.getUid(), clientScrobbleToNormalization); + const name = scrobbleTo.find(x => normalizeStr(x, clientScrobbleToNormalization) === cNameNormal) + const id = scrobbleTo.find(x => normalizeStr(x, clientScrobbleToNormalization) === cUidNormal); + + if(name === undefined && id === undefined) { + excluded.push(client.getUid()); + continue; + } else if(name !== undefined && id === undefined && !this.scrobbleToNamesWarnings.includes(`${name}-${scrobbleFrom}`)) { + 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. + Replace the *name* with the *id* '${client.getUid()}' of this Client.`); + this.scrobbleToNamesWarnings.push(`${name}-${scrobbleFrom}`); + + } } for (const playObj of playObjs) { await client.queueScrobble(clone(playObj), scrobbleFrom); } } + if(excluded.length > 0) { + this.logger.trace(`These clients were filtered from scrobbling from Source '${scrobbleFrom}' => ${excluded.join(' | ')}`); + } } } diff --git a/src/backend/sources/AbstractSource.ts b/src/backend/sources/AbstractSource.ts index 485622a5..966f3bb8 100644 --- a/src/backend/sources/AbstractSource.ts +++ b/src/backend/sources/AbstractSource.ts @@ -119,6 +119,7 @@ export default abstract class AbstractSource extends AbstractComponent implement this.loggerLabel = this.getIdentifier(); this.config = config; this.clients = clients; + this.logger.debug(`Scrobble To: ${this.clients.length === 0 ? 'All' : this.clients.join(' | ')}`); this.instantiatedAt = dayjs(); this.lastActivityAt = this.instantiatedAt; this.localUrl = internal.localUrl; diff --git a/src/backend/tests/scrobbler/scrobblers.test.ts b/src/backend/tests/scrobbler/scrobblers.test.ts index 8d952ad5..f5ee956c 100644 --- a/src/backend/tests/scrobbler/scrobblers.test.ts +++ b/src/backend/tests/scrobbler/scrobblers.test.ts @@ -28,6 +28,10 @@ import type {RepositoryCreatePlayOpts} from '../../common/database/drizzle/repos import { fixtureCreatePlay } from '../utils/databaseFixtures.ts'; import { isAbortError } from 'abort-controller-x'; import { artistNamesToCredits } from '../../../core/StringUtils.ts'; +import ScrobbleClients from '../../scrobblers/ScrobbleClients.ts'; +import { WildcardEmitter } from '../../common/WildcardEmitter.ts'; +import type { CommonClientConfig } from '../../common/infrastructure/config/client/index.ts'; +import { loggerNoop } from '../../common/MaybeLogger.ts'; chai.use(asPromised); @@ -42,8 +46,8 @@ const normalizedWithMixedDur = normalizePlays(mixedDurPlays, {initialDate: first const normalizedWithMixedDurOlder = normalizePlays(mixedDurPlays, {initialDate: olderFirstPlayDate}); -const generateTestScrobbler = async () => { - const testScrobbler = new TestScrobbler(); +const generateTestScrobbler = async (config?: CommonClientConfig) => { + const testScrobbler = new TestScrobbler(config); testScrobbler.verboseOptions = { match: { onMatch: true, @@ -1139,4 +1143,153 @@ describe('Scrobble Temporal Grouping', function () { const consolidatedRanges = groupPlaysToTimeRanges(plays, [], {consolidateDuration: DEFAULT_CONSOLIDATE_DURATION}); expect(consolidatedRanges.length).eq(3); }); -}) \ No newline at end of file +}); + +describe('Scrobble Clients Behavior', function() { + + describe('Source filtering', function() { + + let cEmitter: WildcardEmitter, + sEmitter: WildcardEmitter, + clients: ScrobbleClients; + + beforeEach(function() { + cEmitter = new WildcardEmitter(); + sEmitter = new WildcardEmitter(); + clients = new ScrobbleClients(cEmitter, sEmitter, { + localUrl: new URL('http://example.com'), + configDir: process.cwd(), + version: 'test' + }, + loggerNoop); + }); + + it('does not scrobble when both name and id do not match', async function() { + + const testClient = await generateTestScrobbler({id: 'test', name: 'test'}); + clients.clients.push(testClient); + + sEmitter.emit('discoveredToScrobble', { + data: [generatePlay()], + options: { + scrobbleFrom: 'testSource', + scrobbleTo: ['foo'] + } + }); + expect(clients.scrobbleToNamesWarnings).is.empty; + await Promise.race([ + pEvent(testClient.emitter, 'scrobbleQueued'), + sleep(10) + ]); + const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE); + expect(queued.data).is.empty; + }); + + it('warns when scrobbleTo matches name but not id', async function() { + + const testClient = await generateTestScrobbler({id: 'testid', name: 'test'}); + clients.clients.push(testClient); + + sEmitter.emit('discoveredToScrobble', { + data: [generatePlay()], + options: { + scrobbleFrom: 'testSource', + scrobbleTo: ['test'] + } + }); + expect(clients.scrobbleToNamesWarnings).length.greaterThan(0); + await Promise.race([ + pEvent(testClient.emitter, 'scrobbleQueued'), + sleep(100) + ]) + const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE); + expect(queued.data).is.not.empty; + }); + + it('scrobbles when both name and id match', async function() { + + const testClient = await generateTestScrobbler({id: 'test', name: 'test'}); + clients.clients.push(testClient); + + sEmitter.emit('discoveredToScrobble', { + data: [generatePlay()], + options: { + scrobbleFrom: 'testSource', + scrobbleTo: ['test'] + } + }); + expect(clients.scrobbleToNamesWarnings).is.empty; + await Promise.race([ + pEvent(testClient.emitter, 'scrobbleQueued'), + sleep(50) + ]) + const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE); + expect(queued.data).is.not.empty; + }); + + it('scrobbles without warning when only id matches', async function() { + + const testClient = await generateTestScrobbler({id: 'test foo', name: 'test'}); + clients.clients.push(testClient); + + sEmitter.emit('discoveredToScrobble', { + data: [generatePlay()], + options: { + scrobbleFrom: 'testSource', + scrobbleTo: ['test foo'] + } + }); + expect(clients.scrobbleToNamesWarnings).is.empty; + await Promise.race([ + pEvent(testClient.emitter, 'scrobbleQueued'), + sleep(50) + ]) + const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE); + expect(queued.data).is.not.empty; + }); + + it('scrobbles when scrobbleTo is empty', async function() { + + const testClient = await generateTestScrobbler({id: 'test foo', name: 'test'}); + clients.clients.push(testClient); + + sEmitter.emit('discoveredToScrobble', { + data: [generatePlay()], + options: { + scrobbleFrom: 'testSource', + scrobbleTo: [] + } + }); + expect(clients.scrobbleToNamesWarnings).is.empty; + await Promise.race([ + pEvent(testClient.emitter, 'scrobbleQueued'), + sleep(50) + ]) + const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE); + expect(queued.data).is.not.empty; + }); + + it('scrobbleTo is case-insensitive and ignores whitespace', async function() { + + const testClient = await generateTestScrobbler({id: 'test foo ', name: 'test'}); + clients.clients.push(testClient); + + sEmitter.emit('discoveredToScrobble', { + data: [generatePlay()], + options: { + scrobbleFrom: 'testSource', + scrobbleTo: ['TesT foO'] + } + }); + expect(clients.scrobbleToNamesWarnings).is.empty; + await Promise.race([ + pEvent(testClient.emitter, 'scrobbleQueued'), + sleep(50) + ]) + const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE); + expect(queued.data).is.not.empty; + }); + + }); + +}); \ No newline at end of file