Something went wrong. Try again.
[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
Something went wrong. Try again.
48 kB · 1077 lines
TypeScript
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078import { loggerTest, childLogger } from "@foxxmd/logging";import chai, { expect } from 'chai';import asPromised from 'chai-as-promised';import { describe, it } from 'mocha';import AbstractComponent, { type AbstractComponentConfig } from "../../common/AbstractComponent.ts";
import { type ConditionalSearchAndReplaceRegExp, DEFAULT_TRANSFORMER_NAME, type MetadataProviderStageType, type StageTypeMetadata, TRANSFORM_HOOK } from "../../../core/Transform.ts";
import { isConditionalSearchAndReplace } from "../../utils/PlayTransformUtils.ts";import { generateArtistsStr, generatePlay } from "../../../core/tests/utils/PlayTestUtils.ts";import type {WebhookPayload} from "../../common/infrastructure/config/health/webhooks.ts";import { findCauseByMessage } from "../../utils/ErrorUtils.ts";import NativeTransformer from "../../common/transforms/NativeTransformer.ts";import { initMemoryCache } from "../../common/Cache.ts";import { Cacheable } from "cacheable";import type {PlayMatchResult, PlayObject, QueueContext, TransformerCommon, TransformerCommonConfig} from "../../../core/Atomic.ts";import TransformerManager from "../../common/transforms/TransformerManager.ts";import type AbstractTransformer from "../../common/transforms/AbstractTransformer.ts";import { transientCache } from "../utils/TransientTestUtils.ts";import dayjs from "dayjs";import clone from "clone";import { nameToCredit, creditsToNames, namesToCredits, mbMeta, creditMbid } from "../../../core/MusicMetadata.ts";import UserTransformer from "../../common/transforms/UserTransformer.ts";import { SkipTransformStageError } from "../../common/errors/MSErrors.ts";import { COMPONENT_STATE, type ComponentState, type TrackSearchResult } from "../../../core/Api.ts";import type { PlayWith, PlaySelectWithQueueStates } from "../../common/database/drizzle/drizzleTypes.ts";import type { PlayProcessingResult } from "../../common/infrastructure/PlayProcessing.ts";
chai.use(asPromised);
class TestTransformerManager extends TransformerManager { public hanging: string[] = []; public setPreferredProviders(preferred: TransformerManager['transformMetadataPreferredProviders']) { this.transformMetadataPreferredProviders = preferred; } public getProviders() { return this.transformMetadataProviders; } public initProviders() { return this.initMetadataProviders(); } protected async initTransformer(transformer: AbstractTransformer): Promise<AbstractTransformer> { if(this.hanging.includes(transformer.name)) { return new Promise(() => {}); } return super.initTransformer(transformer); }}
const componentId = () => `test-${Date.now()}`; class TestComponent extends AbstractComponent { existingPlay(playObjPre: PlayObject, existingScrobbles: PlayObject[], log?: boolean): Promise<PlayMatchResult> { throw new Error("Method not implemented."); } findPreQueueExistingPlay(queueablePlay: PlayObject, context?: QueueContext & { isRetry?: boolean; }): Promise<PlayWith<"parent" | "queueStates">> { throw new Error("Method not implemented."); } processPlay(playEntity: PlaySelectWithQueueStates, signal?: AbortSignal): Promise<PlayProcessingResult> { throw new Error("Method not implemented."); } public getRunningState(): ComponentState { return COMPONENT_STATE.RUNNING; } public notify(payload: WebhookPayload): Promise<void> { throw new Error("Method not implemented."); } protected getIdentifier(): string { return 'test'; } public async start(opts?: { forceInit?: boolean; }): Promise<boolean> { return true; } public async stop(opts?: { reason?: string | Error; }): Promise<void> { return; } constructor(config?: Omit<AbstractComponentConfig, 'id'>) { super({id: `test-${Date.now()}`, ...(config ?? {})}); }}
const createTestComponent = (config?: Omit<AbstractComponentConfig, 'id'>): TestComponent => { const component = new TestComponent(config); component.logger = childLogger(loggerTest, 'App'); return component;}
const component = new TestComponent();component.logger = childLogger(loggerTest, 'App');
const memorycache = () => new Cacheable({primary: initMemoryCache()})
describe('Play Transforms', function () {
beforeEach(function () { component.config = { id: componentId() }; component.transformRules = {}; }); describe('Transform Config Parsing', function () {
it('Sets transform rules as empty object if config is not present', async function () { await component.buildTransformRules(); expect(component.transformRules).exist; expect(Object.keys(component.transformRules).length).eq(0); });
it('Converts single object hook into hook array', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: { type: "user", title: ['something'] } } } }
await component.buildTransformRules();
expect(component.transformRules.preCompare).to.be.an('array'); expect(component.transformRules.preCompare).to.be.length(1); expect(component.transformRules.preCompare).to.have.nested.property('0.title'); });
describe('Stage Parsing', function () {
it(`Throws an error if stage is an unexpected value`, function () { component.config = { options: { playTransform: { // @ts-expect-error no type named test, stage is untyped preCompare: { type: "test", title: ['something'] } } } }
// https://github.com/chaijs/chai/issues/655#issuecomment-204386414 expect(component.buildTransformRules()).to.eventually.throw(Error).that.satisfies((e: any) => { return findCauseByMessage(e, `No transformer of type 'test'`); }); });
});
describe('User Stage Parsing', function () {
it(`Allows user 'type'`, async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: { type: "user", title: ['something'] } } } }
await component.buildTransformRules();
expect(component.transformRules.preCompare).to.be.an('array'); expect(component.transformRules.preCompare).to.be.length(1); expect(component.transformRules.preCompare).to.have.nested.property('0.type'); expect(component.transformRules.preCompare![0].type).eq('user'); });
it('Accepts hook array', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: [ { type: 'user', title: ['something'] }, { type: 'user', title: ['something else'] } ] } } }
await component.buildTransformRules();
expect(component.transformRules.preCompare).to.be.an('array'); expect(component.transformRules.preCompare).to.be.length(2); expect(component.transformRules.preCompare).to.have.nested.property('0.title'); expect(component.transformRules.preCompare).to.have.nested.property('1.title') });
it('Converts transform config into real S&P data', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: { type: 'user', title: ['something'] } } } }
await component.buildTransformRules();
expect(component.transformRules.preCompare![0]).to.exist; expect(component.transformRules.preCompare![0].title).to.exist; expect(Array.isArray(component.transformRules.preCompare![0].title)).is.true; expect(isConditionalSearchAndReplace((component.transformRules.preCompare![0].title as any[])[0])).is.true });
it('Converts transform config into real S&P data with default being empty string', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: { type: 'user', title: ['something'] } } } }
await component.buildTransformRules();
expect(component.transformRules.preCompare![0]).to.exist; expect(component.transformRules.preCompare![0].title).to.exist; expect(Array.isArray(component.transformRules.preCompare![0].title)).is.true; expect(isConditionalSearchAndReplace((component.transformRules.preCompare![0].title as any[])[0])).is.true const title = (component.transformRules.preCompare![0].title as any[])[0] as ConditionalSearchAndReplaceRegExp; expect(title.search).is.eq('something'); expect(title.replace).is.eq(''); });
it('Respects transform config when it is already S&P data', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: { type: 'user', title: [ {
search: 'nothing', replace: 'anything' } ] } } } }
await component.buildTransformRules();
expect(component.transformRules.preCompare![0]).to.exist; expect(component.transformRules.preCompare![0].title).to.exist; expect(Array.isArray(component.transformRules.preCompare![0].title)).is.true; expect(isConditionalSearchAndReplace((component.transformRules.preCompare![0].title as any[])[0])).is.true const title = (component.transformRules.preCompare![0].title as any[])[0] as ConditionalSearchAndReplaceRegExp; expect(title.search).is.eq('nothing'); expect(title.replace).is.eq('anything'); }); });
describe('Non-User Stage Parsing', function () {
describe('Non-User Stage Types', function () {
for(const t of ['native']) {
it(`Allows non-user Stage Type ${t}`, async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: { type: t as StageTypeMetadata, title: true } } } }
await component.buildTransformRules(); //expect(component.buildTransformRules()).to.eventually.not.throw(); expect(component.transformRules.preCompare).to.be.an('array'); expect(component.transformRules.preCompare).to.be.length(1); expect(component.transformRules.preCompare).to.have.nested.property('0.type'); expect(component.transformRules.preCompare![0].type).eq(t); });
}
});
});
describe('Play Transforming', function () {
it('Returns original play if no hooks are defined', async function () { await component.buildTransformRules();
const play = generatePlay(); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(JSON.stringify(play)).equal(JSON.stringify(transformed)); });
describe('User Play Transforming', function () { it('Transforms when hook is present', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: { type: 'user', title: ['something'] } } } } await component.buildTransformRules();
const play = generatePlay({ track: nameToCredit('My coolsomething track') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.track?.name).equal('My cool track'); });
it('Transforms consecutively when hook is present with multiple values', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: { type: 'user', title: ['something', 'cool'] } } } } await component.buildTransformRules();
const play = generatePlay({ track: nameToCredit('My coolsomething track') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.track?.name).equal('My track'); });
it('Transforms using parsed regex', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: { type: 'user', title: [ { search: '/(cool )(some)(thing)/i', replace: '$1$3' } ] } } } } await component.buildTransformRules();
const play = generatePlay({ track: nameToCredit('My cool something track') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.track?.name).equal('My cool thing track'); });
it('Transforms using parsed regex to get primary artist from delimited artist string', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: { type: 'user', artists: [ { search: '/(.*?)(\\s*\\/\\s*)(.*$)/i', replace: '$1' } ] } } } } await component.buildTransformRules();
const play = generatePlay({ artists: namesToCredits(['My Artist One / My Artist Two / Another Guy']) }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.artists).length(1) expect(transformed.data.artists![0].name).equal('My Artist One'); });
it('Removes title when transform replaces with empty string', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: { type: 'user', title: ['something'] } } } } await component.buildTransformRules();
const play = generatePlay({ track: nameToCredit('something') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.track).is.undefined; });
it('Removes album when transform replaces with empty string', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: { type: 'user', album: ['something'] } } } } await component.buildTransformRules();
const play = generatePlay({ album: nameToCredit('something') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.album).is.undefined; });
it('Removes an artist when transform replaces with empty string', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: { type: 'user', artists: ['something'] } } } } await component.buildTransformRules();
const play = generatePlay({ artists: namesToCredits(['something', 'big']) }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.artists!.length).is.eq(1) expect(transformed.data.artists![0].name).is.eq('big') });
});
}); });
describe('Native Transformer', function () {
it('Uses artist parsing functions', async function() {
const t = new NativeTransformer({name: 'test', type: 'native'}, {logger: loggerTest, cache: memorycache()}); await t.initialize();
const [str, primaries, secondaries] = generateArtistsStr({primary: {max: 3, ambiguousJoinedNames: true, trailingAmpersand: true, finalJoiner: false}}); const play = generatePlay({artists: namesToCredits([str])});
const transformedPlay = await t.handle(t.parseConfig({type: 'native'}), play); expect(creditsToNames(transformedPlay.data.artists!)).eql(primaries.concat(secondaries)); });
it('Ignores artists', async function() {
const [str, primaries, secondaries] = generateArtistsStr({primary: {num: 2, ambiguousJoinedNames: false, trailingAmpersand: false, finalJoiner: false}, secondary: 0});
const t = new NativeTransformer({name: 'test', type: 'native', defaults: {artistsIgnore: [str]}}, {logger: loggerTest, cache: memorycache()});
await t.initialize();
const play = generatePlay({artists: namesToCredits([str]), track: nameToCredit('My Test')});
const transformedPlay = await t.handle(t.parseConfig({type: 'native'}), play); expect(creditsToNames(transformedPlay.data.artists!)).eql([str]); });
it('Uses custom delimiters artists', async function() {
const [str, primaries, secondaries] = generateArtistsStr({primary: { max: 3, joiner: '•', spacedJoiners: true, ambiguousJoinedNames: false, trailingAmpersand: false, finalJoiner: false }});
const t = new NativeTransformer({name: 'test', type: 'native', defaults: {delimitersExtra: ['•']}}, {logger: loggerTest, cache: memorycache()});
await t.initialize();
const play = generatePlay({artists: namesToCredits([str]), track: nameToCredit('My Test')});
const transformedPlay = await t.handle(t.parseConfig({type: 'native'}), play); expect(creditsToNames(transformedPlay.data.artists!)).eql(primaries.concat(secondaries)); });
});
describe('Conditional Transforming', function () {
describe('On Hook', function () { it('Does not run hook if when conditions do not match', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: { type: 'user', when: [ { album: "Has This" } ], artists: ['something'] } } } } await component.buildTransformRules();
const play = generatePlay({ artists: namesToCredits(['something', 'big']), album: nameToCredit('It Has No Match') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.artists!.length).is.eq(2) expect(transformed.data.artists![0].name).is.eq('something') });
it('Does run hook if when conditions matches', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: { type: 'user', when: [ { album: "Has This" } ], artists: ['something'] } } } } await component.buildTransformRules();
const play = generatePlay({ artists: namesToCredits(['something', 'big']), album: nameToCredit('It Has This Match') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.artists!.length).is.eq(1) expect(transformed.data.artists![0].name).is.eq('big') }); });
describe('On Search-And-Replace', function () { it('Does not run hook if when conditions do not match', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: { type: 'user', artists: [ { search: "something", replace: "", when: [ { album: "Has This" } ] } ] } } } } await component.buildTransformRules();
const play = generatePlay({ artists: namesToCredits(['something', 'big']), album: nameToCredit('It Has No Match') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.artists!.length).is.eq(2) expect(transformed.data.artists![0].name).is.eq('something') });
it('Does run hook if when conditions matches', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: { type: 'user', artists: [ { search: "something", replace: "", when: [ { album: "Has This" } ] } ] } } } } await component.buildTransformRules();
const play = generatePlay({ artists: namesToCredits(['something', 'big']), album: nameToCredit('It Has This Match') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.artists!.length).is.eq(1) expect(transformed.data.artists![0].name).is.eq('big') }); });
});
describe('Multiple hook transforms', function () {
it('Accumulates transforms within a single stage', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: [ { type: 'user', title: [ { search: "something", replace: "another else" } ] }, { type: 'user', title: [ { search: "another else", replace: "final thing" } ] } ] } } }
await component.buildTransformRules(); const play = generatePlay({ track: nameToCredit('My cool something track') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.track?.name).equal('My cool final thing track'); });
it('Accumulates transforms across multiple stages', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: [ { type: 'user', title: [ { search: "something", replace: "bar" } ] }, { type: 'native' } ] } } }
const [str, primaries, secondaries] = generateArtistsStr({primary: {max: 3, ambiguousJoinedNames: true, trailingAmpersand: true, finalJoiner: false}});
await component.buildTransformRules(); const play = generatePlay({ track: nameToCredit('My cool something track'), artists: namesToCredits([str]) }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.track?.name).equal('My cool bar track'); expect(creditsToNames(transformed.data.artists!)).eql(primaries.concat(secondaries)); });
});
describe("Stage Caching", function () {
it('Re-uses steps without modifying other Play properties', async function () { component.config = { id: componentId(), options: { playTransform: { preCompare: [ { type: 'user', //name: "barChange", title: [ { search: "something", replace: "bar" } ] }, { type: 'native' } ] } } }
const [str, primaries, secondaries] = generateArtistsStr({primary: {max: 3, ambiguousJoinedNames: true, trailingAmpersand: true, finalJoiner: false}});
await component.buildTransformRules(); const play = generatePlay({ track: nameToCredit('My cool something track'), artists: namesToCredits([str]), playDate: dayjs().subtract(10, 'm') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare, {log: 'all'}); expect(transformed.data.track?.name).equal('My cool bar track'); expect(creditsToNames(transformed.data.artists!)).eql(primaries.concat(secondaries));
const cachablePlay = clone(play); const laterDate = dayjs().subtract(5, 'm'); cachablePlay.data.playDate = laterDate; const cacheTransformed = await component.transformPlay(cachablePlay, TRANSFORM_HOOK.preCompare, {log: 'all'}); expect(cacheTransformed.data.track?.name).equal('My cool bar track'); expect(creditsToNames(cacheTransformed.data.artists!)).eql(primaries.concat(secondaries)); expect(cacheTransformed.data.playDate!.isSame(cachablePlay.data.playDate)); });
it('Skips again when a skipped stage is cached', async function () { const t = new NativeTransformer({name: 'test', type: 'native'}, {logger: loggerTest, cache: memorycache()}); await t.initialize(); const stage = t.parseConfig({type: 'native', when: [{title: 'will not match'}]}); const play = generatePlay({track: nameToCredit('My Test')});
await expect(t.handle(stage, play)).to.be.rejectedWith(SkipTransformStageError); await expect(t.handle(stage, play)).to.be.rejectedWith(SkipTransformStageError); });
});
describe('Credit Metadata', function () {
it('User stage keeps ids for renamed credits', async function () { const t = new UserTransformer({name: 'test', type: 'user'}, {logger: loggerTest, cache: memorycache()}); await t.initialize(); const play = generatePlay({ track: nameToCredit('My cool something track', mbMeta('mb-rec', 'recording')), artists: [nameToCredit('Some Artist', mbMeta('mb-a', 'artist'))] });
const transformed = await t.handle(t.parseConfig({type: 'user', title: [{search: 'something', replace: 'bar'}], artists: [{search: 'Some', replace: 'Any'}]}), play); expect(transformed.data.track?.name).eq('My cool bar track'); expect(creditMbid(transformed.data.track, 'recording')).eq('mb-rec'); expect(transformed.data.artists?.[0].name).eq('Any Artist'); expect(creditMbid(transformed.data.artists?.[0], 'artist')).eq('mb-a'); });
it('Native stage keeps ids for artists that are not changed by parsing', async function () { const t = new NativeTransformer({name: 'test', type: 'native'}, {logger: loggerTest, cache: memorycache()}); await t.initialize(); const play = generatePlay({ track: nameToCredit('My Test (feat. Guest Artist)'), artists: [nameToCredit('Main Artist', mbMeta('mb-a', 'artist'))] });
const transformed = await t.handle(t.parseConfig({type: 'native'}), play); expect(creditsToNames(transformed.data.artists!)).eql(['Main Artist', 'Guest Artist']); expect(creditMbid(transformed.data.artists?.[0], 'artist')).eq('mb-a'); });
});
describe('Transform Manager', function() {
it('Uses user transforms in the order supplied within component', async function() { const tConfigs: TransformerCommon[] = [ { type: 'user', name: 't1', defaults: { title: [ { search: "Cool", replace: "Fun" }, { search: "Track", replace: "Title" } ] } }, { type: 'user', name: 't2', defaults: { title: [ { search: "Cool", replace: "Bar" } ] } } ]; const tmanager = new TransformerManager(loggerTest, transientCache()); for(const t of tConfigs) { await tmanager.register(t); }
const play = generatePlay({track: nameToCredit('My Cool Track')});
const multiTransformComponent = createTestComponent({transformManager: tmanager}); multiTransformComponent.config.options = { playTransform: { preCompare: [ { type: "user", name: "t2" }, { type: "user", name: "t1" } ] } }; await multiTransformComponent.buildTransformRules(); const transformed = await multiTransformComponent.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.track?.name).eq('My Bar Title'); });
it('Handles transform config case-insensitive', async function() { const tConfigs: TransformerCommonConfig[] = [ { type: 'UsEr', name: 't1', defaults: { title: [ { search: "Cool", replace: "Fun" } ] } }, { type: 'UsEr', name: 't2', defaults: { title: [ { search: "Cool", replace: "Fun" } ] } } ]; const tmanager = new TransformerManager(loggerTest, transientCache()); for(const t of tConfigs) { tmanager.addTransformerConfig(t); }
const multiTransformComponent = createTestComponent({transformManager: tmanager}); multiTransformComponent.config.options = { playTransform: { preCompare: [ { // @ts-expect-error bad user input but should handle it type: "usEr", name: "t2" }, { // @ts-expect-error bad user input but should handle it type: "User", name: "t1" } ] } }; await multiTransformComponent.buildTransformRules(); expect(multiTransformComponent.transformRules.preCompare).to.exist; expect(multiTransformComponent.transformRules.preCompare![0].name).eq('t2') expect(multiTransformComponent.transformRules.preCompare![1].name).eq('t1') });
it('Handles transform names case-insensitive', async function() { const tConfigs: TransformerCommonConfig[] = [ { type: 'user', name: 'MyBarConfig', defaults: { title: [ { search: "Cool", replace: "Fun" } ] } }, { type: 'user', name: 'MyFooConfig', defaults: { title: [ { search: "Cool", replace: "Fun" } ] } } ]; const tmanager = new TransformerManager(loggerTest, transientCache()); for(const t of tConfigs) { tmanager.addTransformerConfig(t); }
const multiTransformComponent = createTestComponent({transformManager: tmanager}); multiTransformComponent.config.options = { playTransform: { preCompare: [ { // @ts-expect-error bad user input but should handle it type: "usEr", name: "MYFOOCONFIG" }, { // @ts-expect-error bad user input but should handle it type: "User", name: "mybarconfig" } ] } }; await multiTransformComponent.buildTransformRules(); expect(multiTransformComponent.transformRules.preCompare).to.exist; expect(multiTransformComponent.transformRules.preCompare![0].name).eq('MYFOOCONFIG') expect(multiTransformComponent.transformRules.preCompare![1].name).eq('mybarconfig') });
it('Registers metadata providers without any stage usage', async function() { const tmanager = new TestTransformerManager(loggerTest, transientCache()); expect(tmanager.hasTransformerType('musicbrainz')).is.false;
await tmanager.initProviders();
expect(tmanager.getProviders()).to.have.all.keys('musicbrainz', 'rocksky'); expect(tmanager.getTransformerType('musicbrainz')?.get('msdefault')?.isReady()).is.true; expect(tmanager.getTransformerType('rocksky')?.get('msdefault')?.isReady()).is.true; });
it('Registers only preferred metadata providers', async function() { const tmanager = new TestTransformerManager(loggerTest, transientCache()); tmanager.setPreferredProviders({musicbrainz: true});
await tmanager.initProviders();
expect(tmanager.getProviders()).to.have.all.keys('musicbrainz'); expect(tmanager.hasTransformerType('rocksky')).is.false; });
it('Registers a transformer once when requested concurrently', async function() { const tmanager = new TransformerManager(loggerTest, transientCache()); const [a, b] = await Promise.all([ tmanager.getTransformerByStage({type: 'user'}), tmanager.getTransformerByStage({type: 'user'}) ]); expect(a).eq(b); expect(a.isReady()).is.true; });
it('Does not wait on other transformers initializing', async function() { const tmanager = new TestTransformerManager(loggerTest, transientCache()); tmanager.addTransformerConfig({type: 'user', name: 'slow'}); tmanager.addTransformerConfig({type: 'user', name: 'fast'}); tmanager.hanging.push('slow');
void tmanager.getTransformerByStage({type: 'user', name: 'slow'}); const fast = await tmanager.getTransformerByStage({type: 'user', name: 'fast'});
expect(fast.name).eq('fast'); expect(fast.isReady()).is.true; });
it('Uses highest priority config for unnamed stage regardless of what is registered', async function() { const tmanager = new TransformerManager(loggerTest, transientCache()); tmanager.addTransformerConfig({type: 'user', name: 'mine'});
const def = await tmanager.getTransformerByStage({type: 'user', name: DEFAULT_TRANSFORMER_NAME}); expect(def.name).eq(DEFAULT_TRANSFORMER_NAME);
const unnamed = await tmanager.getTransformerByStage({type: 'user'}); expect(unnamed.name).eq('mine'); }); });
describe('Metadata Provider Search', function() {
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/** Skips real provider registration so searches run against the given fakes */ class FakeProviderManager extends TransformerManager { constructor(providers: Record<string, () => Promise<TrackSearchResult[] | false>>) { super(loggerTest, transientCache()); this.metadataInit = Promise.resolve(); for(const [type, getTrackResults] of Object.entries(providers)) { this.transformMetadataProviders[type as MetadataProviderStageType] = type; this.transformers.set(type, new Map([[type, {isReady: () => true, getTrackResults} as unknown as AbstractTransformer]])); } } }
const result = (service: string, score: number): TrackSearchResult => ({id: `${service}-${score}`, service, score, track: {name: 'Track'}});
const buildManager = () => new FakeProviderManager({ musicbrainz: async () => { await sleep(40); return [result('musicbrainz', 90), result('musicbrainz', 10)]; }, spotify: async () => { await sleep(5); return [result('spotify', 50)]; }, rocksky: async () => { await sleep(20); throw new Error('rocksky is down'); }, });
it('Streams each provider as soon as it settles', async function() { const lines = []; for await (const line of buildManager().streamTrackResults({track: {name: 'Track'}})) { lines.push(line); } expect(lines.map(x => x.service)).eql(['spotify', 'rocksky', 'musicbrainz']); expect(lines.map(x => x.type)).eql(['result', 'error', 'result']); const errorLine = lines[1]; expect(errorLine.type === 'error' && errorLine.error.message).eq('rocksky is down'); // survives being written to a response, a raw Error would become {} expect(JSON.parse(JSON.stringify(errorLine)).error.message).eq('rocksky is down'); });
it('Reports a provider that is not ready as an error', async function() { const tmanager = new FakeProviderManager({spotify: async () => false}); const res = await tmanager.getTrackResults({track: {name: 'Track'}}); expect(res.data).eql([]); expect(res.errors).eql([{service: 'spotify', error: {message: 'Not Ready'}}]); });
it('Aggregates all providers sorted by score with errors', async function() { const res = await buildManager().getTrackResults({track: {name: 'Track'}}); expect(res.data.map(x => x.score)).eql([90, 50, 10]); expect(res.errors.map(x => x.service)).eql(['rocksky']); expect(res.errors[0].error.message).eq('rocksky is down'); }); });
})