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

Merge pull request #366 from FoxxMD/transformPipeline

feat: transform pipeline

authored by

Matt Foxx and committed by
GitHub
(Nov 20, 2025, 2:35 PM EST) 0c04e7b7 5c418d56

+525 -288
+20 -16
src/backend/common/AbstractComponent.ts
··· 9 9 10 10 import { 11 11 configPartsToStrongParts, countRegexes, 12 + isUserStage, 12 13 transformPlayUsingParts 13 14 } from "../utils/PlayTransformUtils.js"; 14 15 import { hasNodeNetworkException } from "./errors/NodeErrors.js"; 15 16 import { hasUpstreamError } from "./errors/UpstreamError.js"; 16 - import { 17 - ConditionalSearchAndReplaceRegExp, 18 - PlayTransformParts, PlayTransformPartsArray, 19 - PlayTransformRules, 20 - TRANSFORM_HOOK, 21 - TransformHook 22 - } from "./infrastructure/Atomic.js"; 23 17 import { CommonClientConfig } from "./infrastructure/config/client/index.js"; 24 18 import { CommonSourceConfig } from "./infrastructure/config/source/index.js"; 25 19 import play = Simulate.play; 26 20 import { WebhookPayload } from "./infrastructure/config/health/webhooks.js"; 27 21 import { AuthCheckError, BuildDataError, ConnectionCheckError, ParseCacheError, PostInitError, TransformRulesError } from "./errors/MSErrors.js"; 28 22 import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js"; 23 + import { 24 + ConditionalSearchAndReplaceRegExp, 25 + PlayTransformParts, 26 + PlayTransformPartsArray, 27 + PlayTransformRules, 28 + TRANSFORM_HOOK, 29 + TransformHook 30 + } from "./infrastructure/Transform.js"; 29 31 30 32 export default abstract class AbstractComponent { 31 33 requiresAuth: boolean = false; ··· 387 389 let transformedPlay: PlayObject = play; 388 390 const transformDetails: string[] = []; 389 391 for(const hookItem of hook) { 390 - const newTransformedPlay = transformPlayUsingParts(transformedPlay, hookItem, { 391 - logger: getLogger, 392 - regex: { 393 - searchAndReplace: this.regexCache.searchAndReplace, 394 - testMaybeRegex: this.regexCache.testMaybeRegex, 392 + if(hookItem.type === 'user') { 393 + const newTransformedPlay = transformPlayUsingParts(transformedPlay, hookItem, { 394 + logger: getLogger, 395 + regex: { 396 + searchAndReplace: this.regexCache.searchAndReplace, 397 + testMaybeRegex: this.regexCache.testMaybeRegex, 398 + } 399 + }); 400 + if(!deepEqual(newTransformedPlay, transformedPlay)) { 401 + transformDetails.push(buildTrackString(transformedPlay, {include: ['artist', 'track', 'album']})); 395 402 } 396 - }); 397 - if(!deepEqual(newTransformedPlay, transformedPlay)) { 398 - transformDetails.push(buildTrackString(transformedPlay, {include: ['artist', 'track', 'album']})); 403 + transformedPlay = newTransformedPlay; 399 404 } 400 - transformedPlay = newTransformedPlay; 401 405 } 402 406 403 407 if(transformDetails.length > 0) {
-56
src/backend/common/infrastructure/Atomic.ts
··· 1 1 import { Logger } from '@foxxmd/logging'; 2 - import { SearchAndReplaceRegExp } from "@foxxmd/regex-buddy-core"; 3 2 import { Dayjs } from "dayjs"; 4 3 import { Request, Response } from "express"; 5 4 import { NextFunction, ParamsDictionary, Query } from "express-serve-static-core"; ··· 298 297 export type AbstractApiOptions = Record<any, any> & { logger: Logger } 299 298 300 299 export type keyOmit<T, U extends keyof any> = T & { [P in U]?: never } 301 - 302 - export interface ConditionalSearchAndReplaceRegExp extends SearchAndReplaceRegExp{ 303 - when?: WhenConditionsConfig 304 - } 305 - 306 - export type ConditionalSearchAndReplaceTerm = Omit<ConditionalSearchAndReplaceRegExp, 'test'> 307 - 308 - export type SearchAndReplaceTerm = string | ConditionalSearchAndReplaceTerm; 309 - 310 - export type PlayTransformParts<T> = PlayTransformPartsAtomic<T[]> & { when?: WhenConditionsConfig }; 311 - 312 - export type PlayTransformPartsArray<T> = PlayTransformParts<T>[]; 313 - 314 - export type PlayTransformPartsConfig<T> = PlayTransformPartsArray<T> | PlayTransformParts<T>; 315 - 316 - export interface PlayTransformPartsAtomic<T> { 317 - title?: T 318 - artists?: T 319 - album?: T 320 - } 321 - 322 - export interface PlayTransformHooksConfig<T> { 323 - preCompare?: PlayTransformPartsConfig<T> 324 - compare?: { 325 - candidate?: PlayTransformPartsConfig<T> 326 - existing?: PlayTransformPartsConfig<T> 327 - } 328 - postCompare?: PlayTransformPartsConfig<T> 329 - } 330 - 331 - export interface PlayTransformHooks<T> extends PlayTransformHooksConfig<T> { 332 - preCompare?: PlayTransformPartsArray<T> 333 - compare?: { 334 - candidate?: PlayTransformPartsArray<T> 335 - existing?: PlayTransformPartsArray<T> 336 - } 337 - postCompare?: PlayTransformPartsArray<T> 338 - } 339 - 340 - export type PlayTransformRules = PlayTransformHooks<ConditionalSearchAndReplaceRegExp> 341 - 342 - export type TransformHook = 'preCompare' | 'compare' | 'candidate' | 'existing' | 'postCompare'; 343 - export const TRANSFORM_HOOK = { 344 - preCompare: 'preCompare' as TransformHook, 345 - candidate: 'candidate' as TransformHook, 346 - existing: 'existing' as TransformHook, 347 - postCompare: 'postCompare' as TransformHook, 348 - } 349 - export type PlayTransformConfig = PlayTransformHooksConfig<SearchAndReplaceTerm>; 350 - export type PlayTransformOptions = PlayTransformConfig & { log?: boolean | 'all' } 351 - 352 - export type WhenParts<T> = PlayTransformPartsAtomic<T>; 353 - 354 - export type WhenConditions<T> = WhenParts<T>[]; 355 - export type WhenConditionsConfig = WhenConditions<string>; 356 300 357 301 export type WithRequiredProperty<Type, Key extends keyof Type> = Type & { 358 302 [Property in Key]-?: Type[Property];
+90
src/backend/common/infrastructure/Transform.ts
··· 1 + import { SearchAndReplaceRegExp } from "@foxxmd/regex-buddy-core"; 2 + 3 + export interface ConditionalSearchAndReplaceRegExp extends SearchAndReplaceRegExp { 4 + when?: WhenConditionsConfig 5 + } 6 + 7 + export type ConditionalSearchAndReplaceTerm = Omit<ConditionalSearchAndReplaceRegExp, 'test'> 8 + export type SearchAndReplaceTerm = string | ConditionalSearchAndReplaceTerm; 9 + export type ExternalMetadataTerm = boolean | undefined | { when: WhenConditionsConfig }; 10 + 11 + export type PlayTransformParts<T, Y = MaybeStageTyped> = Extract<PlayTransformStage<T[]>, Y> & { when?: WhenConditionsConfig }; 12 + export type PlayTransformUserParts<T> = PlayTransformUserStage<T[]> & { when?: WhenConditionsConfig }; 13 + export type PlayTransformPartsArray<T, Y = MaybeStageTyped> = PlayTransformParts<T, Y>[]; 14 + 15 + /** Represents the weakly-defined user config. May be an array of parts or one parts object */ 16 + export type PlayTransformPartsConfig<T, Y = MaybeStageTyped> = PlayTransformPartsArray<T, Y> | PlayTransformParts<T, Y>; 17 + 18 + export interface PlayTransformPartsAtomic<T> { 19 + title?: T 20 + artists?: T 21 + album?: T 22 + } 23 + 24 + export type StageTypeMetadata = 'spotify' | 'listenbrainz' | 'native'; 25 + export type StageTypeUser = 'user'; 26 + export type StageType = StageTypeMetadata | StageTypeUser; 27 + export const STAGE_TYPES_USER: StageTypeUser[] = ['user']; 28 + export const STAGE_TYPES_METADATA: StageTypeMetadata[] = ['spotify','listenbrainz','native']; 29 + export const STAGE_TYPES: StageType[] = [...STAGE_TYPES_METADATA, ...STAGE_TYPES_USER]; 30 + 31 + export interface StageTyped { 32 + type: StageType 33 + } 34 + 35 + export interface NotStageTyped { 36 + type?: never 37 + } 38 + 39 + export type MaybeStageTyped = StageTyped | NotStageTyped; 40 + 41 + export interface PlayTransformStageTyped<T> extends PlayTransformPartsAtomic<T> { 42 + type: StageType 43 + } 44 + 45 + export interface PlayTransformMetadataStage extends PlayTransformStageTyped<ExternalMetadataTerm> { 46 + score?: number 47 + // all?: ExternalMetadataTerm 48 + type: StageTypeMetadata 49 + } 50 + 51 + export interface PlayTransformUserStage<T> extends PlayTransformStageTyped<T> { 52 + type: StageTypeUser 53 + } 54 + export type UntypedPlayTransformUserStage<T> = Omit<PlayTransformUserStage<T>, 'type'> & {type?: never}; 55 + 56 + export type PlayTransformStage<T> = PlayTransformMetadataStage | PlayTransformUserStage<T> | UntypedPlayTransformUserStage<T>; 57 + 58 + /** Represents the plain json user-configured structure (input) */ 59 + export interface PlayTransformHooksConfig<T> { 60 + preCompare?: PlayTransformPartsConfig<T> 61 + compare?: { 62 + candidate?: PlayTransformPartsConfig<T> 63 + existing?: PlayTransformPartsConfig<T> 64 + } 65 + postCompare?: PlayTransformPartsConfig<T> 66 + } 67 + 68 + /** Represents the final, strongly-typed transform configuration used during runtime */ 69 + export interface PlayTransformHooks<T> extends PlayTransformHooksConfig<T> { 70 + preCompare?: PlayTransformPartsArray<T, StageTyped> 71 + compare?: { 72 + candidate?: PlayTransformPartsArray<T, StageTyped> 73 + existing?: PlayTransformPartsArray<T, StageTyped> 74 + } 75 + postCompare?: PlayTransformPartsArray<T, StageTyped> 76 + } 77 + 78 + export type PlayTransformRules = PlayTransformHooks<ConditionalSearchAndReplaceRegExp> 79 + export type TransformHook = 'preCompare' | 'compare' | 'candidate' | 'existing' | 'postCompare'; 80 + export const TRANSFORM_HOOK = { 81 + preCompare: 'preCompare' as TransformHook, 82 + candidate: 'candidate' as TransformHook, 83 + existing: 'existing' as TransformHook, 84 + postCompare: 'postCompare' as TransformHook, 85 + } 86 + export type PlayTransformConfig = PlayTransformHooksConfig<SearchAndReplaceTerm>; 87 + export type PlayTransformOptions = PlayTransformConfig & { log?: boolean | 'all' } 88 + export type WhenParts<T> = PlayTransformPartsAtomic<T>; 89 + export type WhenConditions<T> = WhenParts<T>[]; 90 + export type WhenConditionsConfig = WhenConditions<string>;
+1 -1
src/backend/common/infrastructure/config/client/index.ts
··· 1 - import { PlayTransformConfig, PlayTransformOptions } from "../../Atomic.js"; 1 + import { PlayTransformConfig, PlayTransformOptions } from "../../Transform.js"; 2 2 import { CommonConfig, CommonData, RequestRetryOptions } from "../common.js"; 3 3 4 4 /**
+2 -1
src/backend/common/infrastructure/config/source/index.ts
··· 1 1 import { FileLogOptions, LogLevel } from "@foxxmd/logging"; 2 - import { PlayTransformConfig, PlayTransformOptions } from "../../Atomic.js"; 2 + 3 + import { PlayTransformConfig, PlayTransformOptions } from "../../Transform.js"; 3 4 import { CommonConfig, CommonData, RequestRetryOptions } from "../common.js"; 4 5 5 6 export interface SourceRetryOptions extends RequestRetryOptions {
+2 -1
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 25 25 ScrobbledPlayObject, 26 26 SourceIdentifier, 27 27 TIME_WEIGHT, 28 - TITLE_WEIGHT, TRANSFORM_HOOK, 28 + TITLE_WEIGHT, 29 29 } from "../common/infrastructure/Atomic.js"; 30 30 import { CommonClientConfig, NowPlayingOptions, UpstreamRefreshOptions } from "../common/infrastructure/config/client/index.js"; 31 + import { TRANSFORM_HOOK } from "../common/infrastructure/Transform.js"; 31 32 import { Notifiers } from "../notifier/Notifiers.js"; 32 33 import { 33 34 comparingMultipleArtists,
+2 -1
src/backend/sources/AbstractSource.ts
··· 18 18 PlayUserId, 19 19 ProgressAwarePlayObject, 20 20 SINGLE_USER_PLATFORM_ID, 21 - SourceType, TRANSFORM_HOOK, 21 + SourceType, 22 22 } from "../common/infrastructure/Atomic.js"; 23 23 import { SourceConfig } from "../common/infrastructure/config/source/sources.js"; 24 + import { TRANSFORM_HOOK } from "../common/infrastructure/Transform.js"; 24 25 import TupleMap from "../common/TupleMap.js"; 25 26 import { 26 27 difference,
+3 -2
src/backend/sources/DeezerInternalSource.ts
··· 2 2 import EventEmitter from "events"; 3 3 import request, { Request, Response, SuperAgent } from 'superagent'; 4 4 import { PlayObject, SOURCE_SOT, TA_CLOSE, TA_DURING, TA_EXACT, TA_FUZZY, TemporalAccuracy } from "../../core/Atomic.js"; 5 - import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig, TRANSFORM_HOOK } from "../common/infrastructure/Atomic.js"; 5 + import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; 6 6 import { DeezerInternalSourceConfig, DeezerInternalTrackData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js"; 7 + import { TRANSFORM_HOOK } from "../common/infrastructure/Transform.js"; 7 8 import { parseRetryAfterSecsFromObj, playObjDataMatch, readJson, sleep, sortByOldestPlayDate, writeFile, } from "../utils.js"; 8 9 import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js"; 9 10 import { CookieJar, Cookie } from 'tough-cookie'; ··· 278 279 const u = new URL(`https://www.deezer.com/ajax/gw-light.php?${params.toString()}`); 279 280 280 281 return u; 281 - } 282 + }
+289 -182
src/backend/tests/component/component.test.ts
··· 3 3 import asPromised from 'chai-as-promised'; 4 4 import { after, before, describe, it } from 'mocha'; 5 5 import AbstractComponent from "../../common/AbstractComponent.js"; 6 - import { TRANSFORM_HOOK } from "../../common/infrastructure/Atomic.js"; 6 + 7 + import { ConditionalSearchAndReplaceRegExp, STAGE_TYPES, STAGE_TYPES_METADATA, TRANSFORM_HOOK } from "../../common/infrastructure/Transform.js"; 7 8 8 9 import { isConditionalSearchAndReplace } from "../../utils/PlayTransformUtils.js"; 9 10 import { asPlays, generatePlay, normalizePlays } from "../utils/PlayTestUtils.js"; 10 11 import { WebhookPayload } from "../../common/infrastructure/config/health/webhooks.js"; 12 + import { findCauseByMessage } from "../../utils/ErrorUtils.js"; 13 + import { ComAtprotoServerDescribeServer } from "@atproto/api"; 11 14 12 15 chai.use(asPromised); 13 16 ··· 28 31 29 32 describe('Play Transforms', function () { 30 33 31 - beforeEach(function() { 32 - component.config = {}; 33 - component.transformRules = {}; 34 + beforeEach(function () { 35 + component.config = {}; 36 + component.transformRules = {}; 34 37 }); 38 + describe('Transform Config Parsing', function () { 35 39 36 - describe('Transform Config Parsing', function() { 37 - 38 - it('Sets transform rules as empty object if config is not present', function() { 40 + it('Sets transform rules as empty object if config is not present', function () { 39 41 component.buildTransformRules(); 40 42 expect(component.transformRules).exist; 41 43 expect(Object.keys(component.transformRules).length).eq(0); 42 44 }); 43 45 44 - it('Converts single object hook into hook array', function() { 46 + it('Converts single object hook into hook array', function () { 45 47 component.config = { 46 48 options: { 47 49 playTransform: { 48 50 preCompare: { 51 + type: "user", 49 52 title: ['something'] 50 53 } 51 54 } ··· 59 62 expect(component.transformRules.preCompare).to.have.nested.property('0.title'); 60 63 }); 61 64 62 - it('Accepts hook array', function() { 63 - component.config = { 64 - options: { 65 - playTransform: { 66 - preCompare: [ 67 - { 65 + describe('Stage Parsing', function () { 66 + 67 + it(`Throws an error if stage is an unexpected value`, function () { 68 + component.config = { 69 + options: { 70 + playTransform: { 71 + preCompare: { 72 + // @ts-expect-error 73 + type: "test", 74 + // @ts-expect-error 68 75 title: ['something'] 69 - }, 70 - { 71 - title: ['something else'] 72 76 } 73 - ] 77 + } 74 78 } 75 79 } 76 - } 77 80 78 - component.buildTransformRules(); 81 + // https://github.com/chaijs/chai/issues/655#issuecomment-204386414 82 + expect(() => component.buildTransformRules()).to.throw(Error).that.satisfies((e) => { 83 + return findCauseByMessage(e, `Stage has invalid 'type'`); 84 + }); 85 + }); 79 86 80 - expect(component.transformRules.preCompare).to.be.an('array'); 81 - expect(component.transformRules.preCompare).to.be.length(2); 82 - expect(component.transformRules.preCompare).to.have.nested.property('0.title'); 83 - expect(component.transformRules.preCompare).to.have.nested.property('1.title') 84 87 }); 85 88 86 - it('Converts transform config into real S&P data', function() { 87 - component.config = { 88 - options: { 89 - playTransform: { 90 - preCompare: { 91 - title: ['something'] 89 + describe('User Stage Parsing', function () { 90 + 91 + it(`Assumes user 'type' if no type is present`, function () { 92 + component.config = { 93 + options: { 94 + playTransform: { 95 + preCompare: { 96 + title: ['something'] 97 + } 92 98 } 93 99 } 94 100 } 95 - } 96 101 97 - component.buildTransformRules(); 102 + component.buildTransformRules(); 98 103 99 - expect(component.transformRules.preCompare![0]).to.exist; 100 - expect(component.transformRules.preCompare![0].title).to.exist; 101 - expect(Array.isArray(component.transformRules.preCompare![0].title)).is.true; 102 - expect( isConditionalSearchAndReplace(component.transformRules.preCompare![0].title![0])).is.true 103 - }); 104 + expect(component.transformRules.preCompare).to.be.an('array'); 105 + expect(component.transformRules.preCompare).to.be.length(1); 106 + expect(component.transformRules.preCompare).to.have.nested.property('0.type'); 107 + expect(component.transformRules.preCompare[0].type).eq('user'); 108 + }); 104 109 105 - it('Converts transform config into real S&P data with default being empty string', function() { 106 - component.config = { 107 - options: { 108 - playTransform: { 109 - preCompare: { 110 - title: ['something'] 110 + it(`Allows user 'type'`, function () { 111 + component.config = { 112 + options: { 113 + playTransform: { 114 + preCompare: { 115 + type: "user", 116 + title: ['something'] 117 + } 111 118 } 112 119 } 113 120 } 114 - } 115 121 116 - component.buildTransformRules(); 122 + component.buildTransformRules(); 117 123 118 - expect(component.transformRules.preCompare![0]).to.exist; 119 - expect(component.transformRules.preCompare![0].title).to.exist; 120 - expect(Array.isArray(component.transformRules.preCompare![0].title)).is.true; 121 - expect( isConditionalSearchAndReplace(component.transformRules.preCompare![0].title![0])).is.true 122 - expect( component.transformRules.preCompare![0].title![0].search).is.eq('something'); 123 - expect( component.transformRules.preCompare![0].title![0].replace).is.eq(''); 124 - }); 124 + expect(component.transformRules.preCompare).to.be.an('array'); 125 + expect(component.transformRules.preCompare).to.be.length(1); 126 + expect(component.transformRules.preCompare).to.have.nested.property('0.type'); 127 + expect(component.transformRules.preCompare[0].type).eq('user'); 128 + }); 125 129 126 - it('Respects transform config when it is already S&P data', function() { 127 - component.config = { 128 - options: { 129 - playTransform: { 130 - preCompare: { 131 - title: [ 130 + it('Accepts hook array', function () { 131 + component.config = { 132 + options: { 133 + playTransform: { 134 + preCompare: [ 132 135 { 133 - search: 'nothing', 134 - replace: 'anything' 136 + title: ['something'] 137 + }, 138 + { 139 + title: ['something else'] 135 140 } 136 141 ] 137 142 } 138 143 } 139 144 } 140 - } 141 145 142 - component.buildTransformRules(); 146 + component.buildTransformRules(); 143 147 144 - expect(component.transformRules.preCompare![0]).to.exist; 145 - expect(component.transformRules.preCompare![0].title).to.exist; 146 - expect(Array.isArray(component.transformRules.preCompare![0].title)).is.true; 147 - expect( isConditionalSearchAndReplace(component.transformRules.preCompare![0].title![0])).is.true 148 - expect( component.transformRules.preCompare![0].title![0].search).is.eq('nothing'); 149 - expect( component.transformRules.preCompare![0].title![0].replace).is.eq('anything'); 150 - }); 151 - }); 148 + expect(component.transformRules.preCompare).to.be.an('array'); 149 + expect(component.transformRules.preCompare).to.be.length(2); 150 + expect(component.transformRules.preCompare).to.have.nested.property('0.title'); 151 + expect(component.transformRules.preCompare).to.have.nested.property('1.title') 152 + }); 152 153 153 - describe('Play Transforming', function() { 154 + it('Converts transform config into real S&P data', function () { 155 + component.config = { 156 + options: { 157 + playTransform: { 158 + preCompare: { 159 + title: ['something'] 160 + } 161 + } 162 + } 163 + } 154 164 155 - it('Returns original play if no hooks are defined', function () { 156 - component.buildTransformRules(); 165 + component.buildTransformRules(); 157 166 158 - const play = generatePlay(); 159 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 160 - expect(JSON.stringify(play)).equal(JSON.stringify(transformed)); 161 - }); 167 + expect(component.transformRules.preCompare![0]).to.exist; 168 + expect(component.transformRules.preCompare![0].title).to.exist; 169 + expect(Array.isArray(component.transformRules.preCompare![0].title)).is.true; 170 + expect(isConditionalSearchAndReplace(component.transformRules.preCompare![0].title![0])).is.true 171 + }); 162 172 163 - it('Transforms when hook is present', function () { 164 - component.config = { 165 - options: { 166 - playTransform: { 167 - preCompare: { 168 - title: ['something'] 173 + it('Converts transform config into real S&P data with default being empty string', function () { 174 + component.config = { 175 + options: { 176 + playTransform: { 177 + preCompare: { 178 + title: ['something'] 179 + } 169 180 } 170 181 } 171 182 } 172 - } 173 - component.buildTransformRules(); 174 183 175 - const play = generatePlay({track: 'My coolsomething track'}); 176 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 177 - expect(transformed.data.track).equal('My cool track'); 178 - }); 184 + component.buildTransformRules(); 179 185 180 - it('Transforms consecutively when hook is present with multiple values', function () { 181 - component.config = { 182 - options: { 183 - playTransform: { 184 - preCompare: { 185 - title: ['something', 'cool'] 186 + expect(component.transformRules.preCompare![0]).to.exist; 187 + expect(component.transformRules.preCompare![0].title).to.exist; 188 + expect(Array.isArray(component.transformRules.preCompare![0].title)).is.true; 189 + expect(isConditionalSearchAndReplace(component.transformRules.preCompare![0].title![0])).is.true 190 + const title = component.transformRules.preCompare![0].title![0] as ConditionalSearchAndReplaceRegExp; 191 + expect(title.search).is.eq('something'); 192 + expect(title.replace).is.eq(''); 193 + }); 194 + 195 + it('Respects transform config when it is already S&P data', function () { 196 + component.config = { 197 + options: { 198 + playTransform: { 199 + preCompare: { 200 + title: [ 201 + { 202 + 203 + search: 'nothing', 204 + replace: 'anything' 205 + } 206 + ] 207 + } 186 208 } 187 209 } 188 210 } 189 - } 190 - component.buildTransformRules(); 211 + 212 + component.buildTransformRules(); 191 213 192 - const play = generatePlay({track: 'My coolsomething track'}); 193 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 194 - expect(transformed.data.track).equal('My track'); 214 + expect(component.transformRules.preCompare![0]).to.exist; 215 + expect(component.transformRules.preCompare![0].title).to.exist; 216 + expect(Array.isArray(component.transformRules.preCompare![0].title)).is.true; 217 + expect(isConditionalSearchAndReplace(component.transformRules.preCompare![0].title![0])).is.true 218 + const title = component.transformRules.preCompare![0].title![0] as ConditionalSearchAndReplaceRegExp; 219 + expect(title.search).is.eq('nothing'); 220 + expect(title.replace).is.eq('anything'); 221 + }); 195 222 }); 196 223 197 - it('Transforms using parsed regex', function () { 198 - component.config = { 199 - options: { 200 - playTransform: { 201 - preCompare: { 202 - title: [ 203 - { 204 - search: '/(cool )(some)(thing)/i', 205 - replace: '$1$3' 224 + describe('Non-User Stage Parsing', function () { 225 + 226 + describe('Non-User Stage Types', function () { 227 + 228 + for(const t of STAGE_TYPES_METADATA) { 229 + 230 + it(`Allows non-user Stage Type ${t}`, function () { 231 + component.config = { 232 + options: { 233 + playTransform: { 234 + preCompare: { 235 + type: t, 236 + title: true 237 + } 238 + } 206 239 } 207 - ] 208 240 } 209 - } 241 + 242 + expect(() => component.buildTransformRules()).to.not.throw(); 243 + expect(component.transformRules.preCompare).to.be.an('array'); 244 + expect(component.transformRules.preCompare).to.be.length(1); 245 + expect(component.transformRules.preCompare).to.have.nested.property('0.type'); 246 + expect(component.transformRules.preCompare[0].type).eq(t); 247 + }); 248 + 210 249 } 211 - } 212 - component.buildTransformRules(); 250 + 251 + }); 213 252 214 - const play = generatePlay({track: 'My cool something track'}); 215 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 216 - expect(transformed.data.track).equal('My cool thing track'); 217 253 }); 218 254 255 + describe('Play Transforming', function () { 219 256 220 - it('Transforms using parsed regex to get primary artist from delimited artist string', function () { 221 - component.config = { 222 - options: { 223 - playTransform: { 224 - preCompare: { 225 - artists: [ 226 - { 227 - search: '/(.*?)(\\s*\\/\\s*)(.*$)/i', 228 - replace: '$1' 257 + it('Returns original play if no hooks are defined', function () { 258 + component.buildTransformRules(); 259 + 260 + const play = generatePlay(); 261 + const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 262 + expect(JSON.stringify(play)).equal(JSON.stringify(transformed)); 263 + }); 264 + 265 + describe('User Play Transforming', function () { 266 + it('Transforms when hook is present', function () { 267 + component.config = { 268 + options: { 269 + playTransform: { 270 + preCompare: { 271 + title: ['something'] 272 + } 273 + } 274 + } 275 + } 276 + component.buildTransformRules(); 277 + 278 + const play = generatePlay({ track: 'My coolsomething track' }); 279 + const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 280 + expect(transformed.data.track).equal('My cool track'); 281 + }); 282 + 283 + it('Transforms consecutively when hook is present with multiple values', function () { 284 + component.config = { 285 + options: { 286 + playTransform: { 287 + preCompare: { 288 + title: ['something', 'cool'] 289 + } 290 + } 291 + } 292 + } 293 + component.buildTransformRules(); 294 + 295 + const play = generatePlay({ track: 'My coolsomething track' }); 296 + const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 297 + expect(transformed.data.track).equal('My track'); 298 + }); 299 + 300 + it('Transforms using parsed regex', function () { 301 + component.config = { 302 + options: { 303 + playTransform: { 304 + preCompare: { 305 + title: [ 306 + { 307 + search: '/(cool )(some)(thing)/i', 308 + replace: '$1$3' 309 + } 310 + ] 311 + } 312 + } 313 + } 314 + } 315 + component.buildTransformRules(); 316 + 317 + const play = generatePlay({ track: 'My cool something track' }); 318 + const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 319 + expect(transformed.data.track).equal('My cool thing track'); 320 + }); 321 + 322 + 323 + it('Transforms using parsed regex to get primary artist from delimited artist string', function () { 324 + component.config = { 325 + options: { 326 + playTransform: { 327 + preCompare: { 328 + artists: [ 329 + { 330 + search: '/(.*?)(\\s*\\/\\s*)(.*$)/i', 331 + replace: '$1' 332 + } 333 + ] 229 334 } 230 - ] 335 + } 231 336 } 232 337 } 233 - } 234 - } 235 - component.buildTransformRules(); 338 + component.buildTransformRules(); 236 339 237 - const play = generatePlay({artists: ['My Artist One / My Artist Two / Another Guy']}); 238 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 239 - expect(transformed.data.artists).length(1) 240 - expect(transformed.data.artists[0]).equal('My Artist One'); 241 - }); 340 + const play = generatePlay({ artists: ['My Artist One / My Artist Two / Another Guy'] }); 341 + const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 342 + expect(transformed.data.artists).length(1) 343 + expect(transformed.data.artists[0]).equal('My Artist One'); 344 + }); 242 345 243 - it('Removes title when transform replaces with empty string', function () { 244 - component.config = { 245 - options: { 246 - playTransform: { 247 - preCompare: { 248 - title: ['something'] 346 + it('Removes title when transform replaces with empty string', function () { 347 + component.config = { 348 + options: { 349 + playTransform: { 350 + preCompare: { 351 + title: ['something'] 352 + } 353 + } 249 354 } 250 355 } 251 - } 252 - } 253 - component.buildTransformRules(); 356 + component.buildTransformRules(); 254 357 255 - const play = generatePlay({track: 'something'}); 256 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 257 - expect(transformed.data.track).is.undefined; 258 - }); 358 + const play = generatePlay({ track: 'something' }); 359 + const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 360 + expect(transformed.data.track).is.undefined; 361 + }); 259 362 260 - it('Removes album when transform replaces with empty string', function () { 261 - component.config = { 262 - options: { 263 - playTransform: { 264 - preCompare: { 265 - album: ['something'] 363 + it('Removes album when transform replaces with empty string', function () { 364 + component.config = { 365 + options: { 366 + playTransform: { 367 + preCompare: { 368 + album: ['something'] 369 + } 370 + } 266 371 } 267 372 } 268 - } 269 - } 270 - component.buildTransformRules(); 373 + component.buildTransformRules(); 271 374 272 - const play = generatePlay({album: 'something'}); 273 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 274 - expect(transformed.data.album).is.undefined; 275 - }); 375 + const play = generatePlay({ album: 'something' }); 376 + const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 377 + expect(transformed.data.album).is.undefined; 378 + }); 276 379 277 - it('Removes an artist when transform replaces with empty string', function () { 278 - component.config = { 279 - options: { 280 - playTransform: { 281 - preCompare: { 282 - artists: ['something'] 380 + it('Removes an artist when transform replaces with empty string', function () { 381 + component.config = { 382 + options: { 383 + playTransform: { 384 + preCompare: { 385 + artists: ['something'] 386 + } 387 + } 283 388 } 284 389 } 285 - } 286 - } 287 - component.buildTransformRules(); 390 + component.buildTransformRules(); 288 391 289 - const play = generatePlay({artists: ['something', 'big']}); 290 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 291 - expect(transformed.data.artists!.length).is.eq(1) 292 - expect(transformed.data.artists![0]).is.eq('big') 392 + const play = generatePlay({ artists: ['something', 'big'] }); 393 + const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 394 + expect(transformed.data.artists!.length).is.eq(1) 395 + expect(transformed.data.artists![0]).is.eq('big') 396 + }); 397 + 398 + }); 399 + 293 400 }); 294 401 }); 295 402 296 - describe('Conditional Transforming', function() { 403 + describe('Conditional Transforming', function () { 297 404 298 405 describe('On Hook', function () { 299 406 it('Does not run hook if when conditions do not match', function () { ··· 313 420 } 314 421 component.buildTransformRules(); 315 422 316 - const play = generatePlay({artists: ['something', 'big'], album: 'It Has No Match'}); 423 + const play = generatePlay({ artists: ['something', 'big'], album: 'It Has No Match' }); 317 424 const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 318 425 expect(transformed.data.artists!.length).is.eq(2) 319 426 expect(transformed.data.artists![0]).is.eq('something') ··· 336 443 } 337 444 component.buildTransformRules(); 338 445 339 - const play = generatePlay({artists: ['something', 'big'], album: 'It Has This Match'}); 446 + const play = generatePlay({ artists: ['something', 'big'], album: 'It Has This Match' }); 340 447 const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 341 448 expect(transformed.data.artists!.length).is.eq(1) 342 449 expect(transformed.data.artists![0]).is.eq('big') 343 450 }); 344 451 }); 345 452 346 - describe('On Search-And-Replace', function() { 453 + describe('On Search-And-Replace', function () { 347 454 it('Does not run hook if when conditions do not match', function () { 348 455 component.config = { 349 456 options: { ··· 366 473 } 367 474 component.buildTransformRules(); 368 475 369 - const play = generatePlay({artists: ['something', 'big'], album: 'It Has No Match'}); 476 + const play = generatePlay({ artists: ['something', 'big'], album: 'It Has No Match' }); 370 477 const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 371 478 expect(transformed.data.artists!.length).is.eq(2) 372 479 expect(transformed.data.artists![0]).is.eq('something') ··· 394 501 } 395 502 component.buildTransformRules(); 396 503 397 - const play = generatePlay({artists: ['something', 'big'], album: 'It Has This Match'}); 504 + const play = generatePlay({ artists: ['something', 'big'], album: 'It Has This Match' }); 398 505 const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 399 506 expect(transformed.data.artists!.length).is.eq(1) 400 507 expect(transformed.data.artists![0]).is.eq('big') ··· 403 510 404 511 }); 405 512 406 - describe('Multiple hook transforms', function() { 513 + describe('Multiple hook transforms', function () { 407 514 408 - it('Accumulates transforms', function() { 515 + it('Accumulates transforms', function () { 409 516 component.config = { 410 517 options: { 411 518 playTransform: { ··· 432 539 } 433 540 434 541 component.buildTransformRules(); 435 - const play = generatePlay({track: 'My cool something track'}); 542 + const play = generatePlay({ track: 'My cool something track' }); 436 543 const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 437 544 expect(transformed.data.track).equal('My cool final thing track'); 438 545 });
+116 -28
src/backend/utils/PlayTransformUtils.ts
··· 2 2 import { searchAndReplace as searchAndReplaceFunc, testMaybeRegex as testMaybeRegexFunc } from "@foxxmd/regex-buddy-core"; 3 3 import { ObjectPlayData, PlayObject } from "../../core/Atomic.js"; 4 4 import { buildTrackString } from "../../core/StringUtils.js"; 5 + 5 6 import { 6 7 ConditionalSearchAndReplaceRegExp, 7 - PlayTransformParts, PlayTransformPartsArray, PlayTransformPartsConfig, PlayTransformRules, 8 - SearchAndReplaceTerm, 8 + ConditionalSearchAndReplaceTerm, 9 + ExternalMetadataTerm, 10 + PlayTransformParts, 11 + PlayTransformPartsArray, 12 + PlayTransformPartsConfig, 13 + PlayTransformRules, PlayTransformStage, PlayTransformUserParts, PlayTransformUserStage, SearchAndReplaceTerm, 14 + STAGE_TYPES, 15 + StageType, 9 16 WhenConditionsConfig, 10 17 WhenParts 11 - } from "../common/infrastructure/Atomic.js"; 18 + } from "../common/infrastructure/Transform.js"; 12 19 13 20 export const isWhenCondition = (val: unknown): val is WhenParts<string> => { 14 21 if (val !== null && typeof val === 'object') { ··· 49 56 && ('replace' in val && typeof val.replace === 'string') 50 57 && (!('when' in val) || isWhenConditionConfig(val.when)); 51 58 } 59 + 60 + export const isSearchAndReplaceTerm = (val: unknown | string | ConditionalSearchAndReplaceTerm): val is SearchAndReplaceTerm => { 61 + const tf = typeof val; 62 + if(tf === 'string') { 63 + return true; 64 + } 65 + if(!(tf == 'object')) { 66 + throw new Error(`Must be a string or an object, but found ${tf}`); 67 + } 68 + if(tf === null) { 69 + throw new Error('Cannot be null'); 70 + } 71 + return isConditionalSearchAndReplace(val); 72 + } 73 + 74 + export const isExternalMetadataTerm = (val: unknown): val is ExternalMetadataTerm => { 75 + if(val === undefined) { 76 + return true; 77 + } 78 + const tf = typeof val; 79 + if(tf === 'boolean') { 80 + return true; 81 + } 82 + if(tf === null) { 83 + throw new Error(`Value is null but must be one of: true, undefined, or object with 'when'`); 84 + } 85 + if(tf === 'object') { 86 + if(isWhenConditionConfig(val)) { 87 + return true; 88 + } 89 + throw new Error(`Value is not a proper 'when' object`); 90 + } 91 + throw new Error(`Value is type of ${tf} but must be one of: boolean, undefined, or object with 'when'`); 92 + } 93 + 94 + export const isPlayTransformStage = (val: object | Partial<PlayTransformStage<SearchAndReplaceTerm[]>>): val is PlayTransformStage<SearchAndReplaceTerm[]> => { 95 + if (!('type' in val)) { 96 + throw new Error(`Stage is missing 'type'. Must be one of: ${STAGE_TYPES.join(', ')}`); 97 + } 98 + if (!STAGE_TYPES.includes(val.type)) { 99 + throw new Error(`Stage has invalid 'type'. Must be one of: ${STAGE_TYPES.join(', ')}`); 100 + } 101 + 102 + for (const k of ['artist', 'title', 'album']) { 103 + if (!(k in val)) { 104 + continue; 105 + } 106 + if (val.type === 'user') { 107 + if (!Array.isArray(val[k])) { 108 + throw new Error(`${k} must be an array`); 109 + } 110 + try { 111 + isSearchAndReplaceTerm(val[k]); 112 + } catch (e) { 113 + throw new Error(`Property '${k}' was not a valid type`, { cause: e }); 114 + } 115 + } else { 116 + try { 117 + isExternalMetadataTerm(val[k]); 118 + 119 + } catch (e) { 120 + throw new Error(`Property '${k}' was not a valid type`, { cause: e }); 121 + } 122 + } 123 + } 124 + 125 + return true; 126 + } 127 + 128 + export const isUserStage = <T>(val: PlayTransformStage<T>): val is PlayTransformUserStage<T> => { 129 + return val.type === 'user'; 130 + } 131 + 52 132 export const configPartsToStrongParts = (val: PlayTransformPartsConfig<SearchAndReplaceTerm> | undefined): PlayTransformPartsArray<ConditionalSearchAndReplaceRegExp> => { 53 133 if (val === undefined) { 54 134 return [] ··· 60 140 title: titleConfig, 61 141 artists: artistConfig, 62 142 album: albumConfig, 63 - when: whenConfig 143 + when: whenConfig, 144 + type = 'user', 145 + ...rest 64 146 } = x; 65 - let title, 66 - artists, 67 - album, 68 - when; 69 147 70 - if (titleConfig !== undefined) { 71 - if (!Array.isArray(titleConfig)) { 72 - throw new Error('title must be an array'); 73 - } 74 - title = titleConfig.map(configValToSearchReplace); 75 - } 76 - if (artistConfig !== undefined) { 77 - if (!Array.isArray(artistConfig)) { 78 - throw new Error('arist must be an array'); 79 - } 80 - artists = artistConfig.map(configValToSearchReplace); 81 - } 82 - if (albumConfig !== undefined) { 83 - if (!Array.isArray(albumConfig)) { 84 - throw new Error('album must be an array'); 148 + let stage: PlayTransformStage<SearchAndReplaceTerm[]>; 149 + try { 150 + const candidateStage = {...x, type}; 151 + if(isPlayTransformStage(candidateStage)) { 152 + stage = candidateStage; 85 153 } 86 - album = albumConfig.map(configValToSearchReplace); 154 + } catch (e) { 155 + throw e; 87 156 } 157 + 88 158 if (whenConfig !== undefined) { 89 159 if (!isWhenConditionConfig(whenConfig)) { 90 - throw new Error('when must be an array of artist/title/album objects and each object\'s property must be a string'); 160 + throw new Error(`'when' must be an array of artist/title/album objects and each object's property must be a string`); 91 161 } 92 - when = whenConfig; 93 162 } 94 163 164 + let title, 165 + artists, 166 + album, 167 + when; 168 + 169 + if(isUserStage(stage)) { 170 + title = stage.title?.map(configValToSearchReplace); 171 + artists = stage.artists?.map(configValToSearchReplace); 172 + album = stage.album?.map(configValToSearchReplace); 173 + } else { 174 + title = stage.title; 175 + artists = stage.artists; 176 + album = stage.album; 177 + } 178 + 179 + when = whenConfig; 180 + 95 181 return { 96 182 title, 97 183 artists, 98 184 album, 99 - when 185 + when, 186 + type, 187 + ...rest 100 188 } 101 189 }); 102 190 ··· 139 227 regex?: SuppliedRegex 140 228 } 141 229 142 - export const transformPlayUsingParts = (play: PlayObject, parts: PlayTransformParts<ConditionalSearchAndReplaceRegExp>, options?: TransformPlayPartsOptions): PlayObject => { 230 + export const transformPlayUsingParts = (play: PlayObject, parts: PlayTransformUserParts<ConditionalSearchAndReplaceRegExp>, options?: TransformPlayPartsOptions): PlayObject => { 143 231 const { 144 232 data: { 145 233 track,