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.
34 kB · 797 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798import { 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, 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, TransformerCommonConfig} from "../../../core/Atomic.ts";import TransformerManager from "../../common/transforms/TransformerManager.ts";import { transientCache } from "../utils/TransientTestUtils.ts";import dayjs from "dayjs";import clone from "clone";import { artistCreditsToNames, artistNamesToCredits } from "../../../core/StringUtils.ts";import { COMPONENT_STATE, type ComponentState } 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);
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: { preCompare: { // @ts-expect-error no type named test type: "test", // @ts-expect-error stage is untyped title: ['something'] } } } }
// https://github.com/chaijs/chai/issues/655#issuecomment-204386414 expect(component.buildTransformRules()).to.eventually.throw(Error).that.satisfies((e) => { 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![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![0])).is.true const title = component.transformRules.preCompare![0].title![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![0])).is.true const title = component.transformRules.preCompare![0].title![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: 'My coolsomething track' }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.track).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: 'My coolsomething track' }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.track).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: 'My cool something track' }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.track).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: artistNamesToCredits(['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: '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: '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: artistNamesToCredits(['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: artistNamesToCredits([str])});
const transformedPlay = await t.handle(t.parseConfig({type: 'native'}), play); expect(artistCreditsToNames(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: artistNamesToCredits([str]), track: 'My Test'});
const transformedPlay = await t.handle(t.parseConfig({type: 'native'}), play); expect(artistCreditsToNames(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: artistNamesToCredits([str]), track: 'My Test'});
const transformedPlay = await t.handle(t.parseConfig({type: 'native'}), play); expect(artistCreditsToNames(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: artistNamesToCredits(['something', 'big']), album: '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: artistNamesToCredits(['something', 'big']), album: '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: artistNamesToCredits(['something', 'big']), album: '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: artistNamesToCredits(['something', 'big']), album: '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: 'My cool something track' }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.track).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: 'My cool something track', artists: artistNamesToCredits([str]) }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.track).equal('My cool bar track'); expect(artistCreditsToNames(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: 'My cool something track', artists: artistNamesToCredits([str]), playDate: dayjs().subtract(10, 'm') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare, {log: 'all'}); expect(transformed.data.track).equal('My cool bar track'); expect(artistCreditsToNames(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).equal('My cool bar track'); expect(artistCreditsToNames(cacheTransformed.data.artists)).eql(primaries.concat(secondaries)); expect(cacheTransformed.data.playDate.isSame(cachablePlay.data.playDate)); });
});
describe('Transform Manager', function() {
it('Uses user transforms in the order supplied within component', async function() { const tConfigs: TransformerCommonConfig[] = [ { 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: '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).eq('My Bar Title'); }); });
})