[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: Implement user transformer and refactor components to use async

FoxxMD (Nov 28, 2025, 11:17 PM UTC) affb16fc 56409dd1

+645 -429
+27 -23
src/backend/common/AbstractComponent.ts
··· 6 6 import { Simulate } from "react-dom/test-utils"; 7 7 import { PlayObject } from "../../core/Atomic.js"; 8 8 import { buildTrackString } from "../../core/StringUtils.js"; 9 - 10 - import { 11 - configPartsToStrongParts, isUserStage, transformPlayUsingParts 12 - } from "../utils/PlayTransformUtils.js"; 13 9 import { CommonClientConfig } from "./infrastructure/config/client/index.js"; 14 10 import { CommonSourceConfig } from "./infrastructure/config/source/index.js"; 15 11 import { TransformRulesError } from "./errors/MSErrors.js"; 16 12 import { 17 - ConditionalSearchAndReplaceRegExp, ExternalMetadataTerm, PlayTransformPartsArray, 18 13 PlayTransformRules, 14 + StageConfig, 19 15 TRANSFORM_HOOK, 20 16 TransformHook 21 17 } from "./infrastructure/Transform.js"; 22 18 import AbstractInitializable from "./AbstractInitializable.js"; 23 19 import play = Simulate.play; 20 + import TransformerManager from "./transforms/TransformerManager.js"; 21 + import { getRoot } from "../ioc.js"; 24 22 25 23 export default abstract class AbstractComponent extends AbstractInitializable { 26 24 ··· 28 26 29 27 transformRules: PlayTransformRules = {}; 30 28 regexCache!: ReturnType<typeof cacheFunctions>; 29 + protected transformManager: TransformerManager; 31 30 32 31 protected constructor(config: CommonClientConfig | CommonSourceConfig) { 33 32 super(config); 33 + this.transformManager = getRoot().items.transformerManager; 34 34 } 35 35 36 36 protected postCache(): Promise<void> { ··· 83 83 postCompare; 84 84 85 85 try { 86 - preCompare = configPartsToStrongParts(preConfig) 86 + preCompare = this.transformPartToStrong(preConfig); 87 87 } catch (e) { 88 88 throw new Error('preCompare was not valid', {cause: e}); 89 89 } 90 90 91 91 try { 92 - candidate = configPartsToStrongParts(candidateConfig) 92 + candidate = this.transformPartToStrong(candidateConfig); 93 93 } catch (e) { 94 94 throw new Error('candidate was not valid', {cause: e}); 95 95 } 96 96 97 97 try { 98 - existing = configPartsToStrongParts(existingConfig) 98 + existing = this.transformPartToStrong(existingConfig); 99 99 } catch (e) { 100 100 throw new Error('existing was not valid', {cause: e}); 101 101 } 102 102 103 103 try { 104 - postCompare = configPartsToStrongParts(postConfig) 104 + postCompare = this.transformPartToStrong(postConfig); 105 105 } catch (e) { 106 106 throw new Error('postCompare was not valid', {cause: e}); 107 107 } ··· 116 116 } 117 117 } 118 118 119 - public transformPlay = (play: PlayObject, hookType: TransformHook, log?: boolean) => { 119 + protected transformPartToStrong(data: any) { 120 + if(data === undefined) { 121 + return undefined; 122 + } 123 + // default to user transform type for backward compatibility 124 + const partArr = (Array.isArray(data) ? data : [data]).map(x => ({type: 'user', ...x})); 125 + 126 + return partArr.map(x => this.transformManager.parseTransformerConfig(x)); 127 + } 128 + 129 + public transformPlay = async (play: PlayObject, hookType: TransformHook, log?: boolean) => { 120 130 121 131 let logger: Logger; 122 132 const labels = ['Play Transform', hookType]; 123 133 const getLogger = () => logger !== undefined ? logger : childLogger(this.logger, labels); 124 134 125 135 try { 126 - let hook: PlayTransformPartsArray<ConditionalSearchAndReplaceRegExp[] | ExternalMetadataTerm> | undefined; 136 + let hook: StageConfig[]; 127 137 128 138 switch (hookType) { 129 139 case TRANSFORM_HOOK.preCompare: ··· 147 157 let transformedPlay: PlayObject = play; 148 158 const transformDetails: string[] = []; 149 159 for(const hookItem of hook) { 150 - if(isUserStage<ConditionalSearchAndReplaceRegExp[]>(hookItem)) { 151 - const newTransformedPlay = transformPlayUsingParts(transformedPlay, hookItem, { 152 - logger: getLogger, 153 - regex: { 154 - searchAndReplace: this.regexCache.searchAndReplace, 155 - testMaybeRegex: this.regexCache.testMaybeRegex, 156 - } 157 - }); 158 - if(!deepEqual(newTransformedPlay, transformedPlay)) { 159 - transformDetails.push(buildTrackString(transformedPlay, {include: ['artist', 'track', 'album']})); 160 - } 161 - transformedPlay = newTransformedPlay; 160 + 161 + const newTransformedPlay = await this.transformManager.handleStage(hookItem, transformedPlay); 162 + 163 + if(!deepEqual(newTransformedPlay, transformedPlay)) { 164 + transformDetails.push(`${hookItem.type} - ${buildTrackString(transformedPlay, {include: ['artist', 'track', 'album']})}`); 162 165 } 166 + transformedPlay = newTransformedPlay; 163 167 } 164 168 165 169 if(transformDetails.length > 0) {
+1 -14
src/backend/common/AbstractInitializable.ts
··· 1 1 import { childLogger, Logger } from "@foxxmd/logging"; 2 - import { 3 - cacheFunctions, 4 - } from "@foxxmd/regex-buddy-core"; 5 - import deepEqual from 'fast-deep-equal'; 6 2 import { Simulate } from "react-dom/test-utils"; 7 - import { PlayObject } from "../../core/Atomic.js"; 8 - import { buildTrackString, truncateStringToLength } from "../../core/StringUtils.js"; 9 - 10 - import { 11 - configPartsToStrongParts, 12 - isUserStage, 13 - transformPlayUsingParts 14 - } from "../utils/PlayTransformUtils.js"; 3 + import {truncateStringToLength } from "../../core/StringUtils.js"; 15 4 import { hasNodeNetworkException } from "./errors/NodeErrors.js"; 16 5 import { hasUpstreamError } from "./errors/UpstreamError.js"; 17 - import { CommonClientConfig } from "./infrastructure/config/client/index.js"; 18 - import { CommonSourceConfig } from "./infrastructure/config/source/index.js"; 19 6 import play = Simulate.play; 20 7 import { WebhookPayload } from "./infrastructure/config/health/webhooks.js"; 21 8 import { AuthCheckError, BuildDataError, ConnectionCheckError, ParseCacheError, PostInitError, StageError, TransformRulesError } from "./errors/MSErrors.js";
+14 -2
src/backend/common/infrastructure/Transform.ts
··· 24 24 25 25 export type StageTypeMetadata = 'spotify' | 'listenbrainz' | 'native'; 26 26 export type StageTypeUser = 'user'; 27 - export type StageType = StageTypeMetadata | StageTypeUser; 27 + export type StageType = StageTypeMetadata | StageTypeUser | string; 28 28 export const STAGE_TYPES_USER: StageTypeUser[] = ['user']; 29 29 export const STAGE_TYPES_METADATA: StageTypeMetadata[] = ['spotify','listenbrainz','native']; 30 30 export const STAGE_TYPES: StageType[] = [...STAGE_TYPES_METADATA, ...STAGE_TYPES_USER]; ··· 39 39 40 40 export type MaybeStageTyped = StageTyped | NotStageTyped; 41 41 42 + export interface StageTypedConfig { 43 + type: StageType 44 + } 45 + 46 + export interface Whennable { 47 + when?: WhenConditionsConfig 48 + } 49 + 50 + export interface StageConfig extends StageTypedConfig, Whennable {} 51 + 52 + export interface AtomicStageConfig<T> extends StageConfig, PlayTransformPartsAtomic<T> {} 53 + 42 54 export interface PlayTransformStageTyped<T> extends PlayTransformPartsAtomic<T> { 43 55 type: StageType 44 56 } ··· 49 61 type: StageTypeMetadata 50 62 } 51 63 52 - export interface PlayTransformUserStage<T> extends PlayTransformStageTyped<T> { 64 + export interface PlayTransformUserStage<T> extends StageTypedConfig, PlayTransformPartsAtomic<T> { 53 65 type: StageTypeUser 54 66 } 55 67 export type UntypedPlayTransformUserStage<T> = Omit<PlayTransformUserStage<T>, 'type'> & {type?: never};
+3
src/backend/common/infrastructure/config/aioConfig.ts
··· 6 6 import { CommonSourceOptions, SourceRetryOptions } from "./source/index.js"; 7 7 import { SourceAIOConfig } from "./source/sources.js"; 8 8 import { CacheConfigOptions, ClientType, SourceType } from "../Atomic.js"; 9 + import { TransformerCommonConfig } from "../../../../core/Atomic.js"; 9 10 10 11 11 12 export interface SourceDefaults extends CommonSourceOptions { ··· 66 67 debugMode?: boolean 67 68 68 69 cache?: CacheConfigOptions 70 + 71 + transformers?: TransformerCommonConfig[] 69 72 } 70 73 71 74 export interface AIOClientConfig {
+29 -92
src/backend/common/transforms/AbstractTransformer.ts
··· 1 - import { ObjectPlayData, PlayObject, TrackMeta } from "../../../core/Atomic.js"; 1 + import { childLogger, Logger } from "@foxxmd/logging"; 2 + import { PlayObject, TransformerCommonConfig } from "../../../core/Atomic.js"; 2 3 import { getRoot } from "../../ioc.js"; 3 - import { testWhenConditions } from "../../utils/PlayTransformUtils.js"; 4 + import { isStageTyped, testWhenConditions } from "../../utils/PlayTransformUtils.js"; 4 5 import AbstractInitializable from "../AbstractInitializable.js"; 5 - import { ConditionalSearchAndReplaceRegExp, PlayTransformMetaParts, PlayTransformParts, PlayTransformUserParts } from "../infrastructure/Transform.js"; 6 + import { StageConfig } from "../infrastructure/Transform.js"; 6 7 import { cacheFunctions } from "@foxxmd/regex-buddy-core"; 7 8 8 - export interface TransformerCommonConfig { 9 - data?: Record<string, any> 10 - options?: { 11 - failOnFetch?: boolean 12 - throwOnFailure?: boolean | ('artists' | 'title' | 'albumArtists' | 'album')[] 13 - } 14 - } 15 - 16 9 export interface TransformerCommon extends TransformerCommonConfig { 17 - regexCache?: ReturnType<typeof cacheFunctions> 10 + regexCache: ReturnType<typeof cacheFunctions> 11 + name: string 12 + logger: Logger 18 13 } 19 14 20 - export default abstract class AbstractTransformer<Y, T = any> extends AbstractInitializable { 15 + export default abstract class AbstractTransformer<T = any> extends AbstractInitializable { 21 16 22 17 declare config: TransformerCommonConfig; 23 18 19 + transformType: string 20 + 24 21 regexCache: ReturnType<typeof cacheFunctions> 25 22 26 - protected constructor(config: TransformerCommon) { 23 + public constructor(config: TransformerCommon) { 27 24 super(config); 28 - this.regexCache = config.regexCache ?? getRoot().items.cache().regexCache; 25 + this.logger = childLogger(config.logger, ['Transformer', this.config.type, this.config.name]); 26 + this.transformType = config.type; 27 + this.regexCache = config.regexCache; 28 + } 29 + 30 + public parseConfig(data: any) { 31 + if (!isStageTyped(data)) { 32 + throw new Error(`Must be an object with a 'type' property.`); 33 + } 34 + return this.doParseConfig(data); 29 35 } 30 36 31 - public async handle(parts: PlayTransformParts<Y>, play: PlayObject): Promise<PlayObject> { 37 + protected abstract doParseConfig(data: StageConfig): StageConfig; 38 + 39 + public async handle(data: StageConfig, play: PlayObject): Promise<PlayObject> { 32 40 33 - if (parts.when !== undefined) { 34 - if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regexCache.testMaybeRegex })) { 41 + if (data.when !== undefined) { 42 + if (!testWhenConditions(data.when, play, { testMaybeRegex: this.regexCache.testMaybeRegex })) { 35 43 this.logger.debug('When condition not met, returning original Play'); 36 44 return play; 37 45 } ··· 60 68 return play; 61 69 } 62 70 63 - const transformedPlayData: Partial<ObjectPlayData> = {}; 71 + return await this.doHandle(data, play, transformData); 72 + } 64 73 65 - if (parts.title !== undefined) { 66 - try { 67 - const title = await this.handleTitle(play, parts.title, transformData); 68 - transformedPlayData.track = title; 69 - } catch (e) { 70 - const err = new Error(`Failed to transform title: ${play.data.track}`, { cause: e }); 71 - if (throwOnFailure === true || (throwOnFailure !== false && throwOnFailure.includes('title'))) { 72 - throw err; 73 - } else { 74 - this.logger.warn(err); 75 - } 76 - } 77 - } 78 - 79 - if (parts.artists !== undefined) { 80 - try { 81 - const artists = await this.handleArtists(play, parts.artists, transformData); 82 - transformedPlayData.artists = artists; 83 - } catch (e) { 84 - const err = new Error(`Failed to transform artists`, { cause: e }); 85 - if (throwOnFailure === true || (throwOnFailure !== false && throwOnFailure.includes('artists'))) { 86 - throw err; 87 - } else { 88 - this.logger.warn(err); 89 - } 90 - } 91 - 92 - try { 93 - const albumArtists = await this.handleAlbumArtists(play, parts.artists, transformData); 94 - transformedPlayData.albumArtists = albumArtists; 95 - } catch (e) { 96 - const err = new Error(`Failed to transform album artists`, { cause: e }); 97 - if (throwOnFailure === true || (throwOnFailure !== false && throwOnFailure.includes('albumArtists'))) { 98 - throw err; 99 - } else { 100 - this.logger.warn(err); 101 - } 102 - } 103 - } 104 - 105 - if (parts.album !== undefined) { 106 - try { 107 - const album = await this.handleTitle(play, parts.album, transformData); 108 - transformedPlayData.album = album; 109 - } catch (e) { 110 - const err = new Error(`Failed to transform album: ${play.data.album}`, { cause: e }); 111 - if (throwOnFailure === true || (throwOnFailure !== false && throwOnFailure.includes('album'))) { 112 - throw err; 113 - } else { 114 - this.logger.warn(err); 115 - } 116 - } 117 - } 118 - 119 - const transformedPlay = { 120 - ...play, 121 - data: { 122 - ...play.data, 123 - ...transformedPlayData 124 - } 125 - } 126 - 127 - return transformedPlay; 128 - } 74 + protected abstract doHandle(data: StageConfig, play: PlayObject, transformData: T): Promise<PlayObject>; 129 75 130 76 public async getTransformerData(play: PlayObject): Promise<T> { 131 77 return undefined; ··· 133 79 134 80 public async checkShouldTransform(play: PlayObject, transformData: T): Promise<void> { 135 81 return; 136 - } 137 - 138 - protected abstract handleTitle(play: PlayObject, parts: Y, transformData: T): Promise<string | undefined>; 139 - protected abstract handleArtists(play: PlayObject, parts: Y, transformData: T): Promise<string[] | undefined>; 140 - protected abstract handleAlbumArtists(play: PlayObject, parts: Y, transformData: T): Promise<string[] | undefined>; 141 - protected abstract handleAlbum(play: PlayObject, parts: Y, transformData: T): Promise<string | undefined>; 142 - 143 - protected async handleMeta(play: PlayObject, transformData: T): Promise<TrackMeta | undefined> { 144 - return play.data.meta; 145 82 } 146 83 }
+108
src/backend/common/transforms/AtomicPartsTransformer.ts
··· 1 + import { ObjectPlayData, PlayObject, TrackMeta } from "../../../core/Atomic.js"; 2 + import { getRoot } from "../../ioc.js"; 3 + import { testWhenConditions } from "../../utils/PlayTransformUtils.js"; 4 + import AbstractInitializable from "../AbstractInitializable.js"; 5 + import { AtomicStageConfig, ConditionalSearchAndReplaceRegExp, PlayTransformMetaParts, PlayTransformParts, PlayTransformUserParts } from "../infrastructure/Transform.js"; 6 + import { cacheFunctions } from "@foxxmd/regex-buddy-core"; 7 + import AbstractTransformer from "./AbstractTransformer.js"; 8 + 9 + export default abstract class AtomicPartsTransformer<Y, T = any> extends AbstractTransformer<T> { 10 + 11 + protected async doHandle(parts: AtomicStageConfig<Y>, play: PlayObject, transformData: T): Promise<PlayObject> { 12 + 13 + const { 14 + throwOnFailure = false, 15 + } = this.config.options || {}; 16 + 17 + try { 18 + await this.checkShouldTransform(play, transformData); 19 + } catch (e) { 20 + this.logger.debug(new Error('checkShouldTransform did not pass, returning original Play', { cause: e })); 21 + return play; 22 + } 23 + 24 + const transformedPlayData: Partial<ObjectPlayData> = {}; 25 + 26 + if (parts.title !== undefined) { 27 + try { 28 + const title = await this.handleTitle(play, parts.title, transformData); 29 + transformedPlayData.track = title; 30 + } catch (e) { 31 + const err = new Error(`Failed to transform title: ${play.data.track}`, { cause: e }); 32 + if (throwOnFailure === true || (throwOnFailure !== false && throwOnFailure.includes('title'))) { 33 + throw err; 34 + } else { 35 + this.logger.warn(err); 36 + } 37 + } 38 + } 39 + 40 + if (parts.artists !== undefined) { 41 + try { 42 + const artists = await this.handleArtists(play, parts.artists, transformData); 43 + transformedPlayData.artists = artists; 44 + } catch (e) { 45 + const err = new Error(`Failed to transform artists`, { cause: e }); 46 + if (throwOnFailure === true || (throwOnFailure !== false && throwOnFailure.includes('artists'))) { 47 + throw err; 48 + } else { 49 + this.logger.warn(err); 50 + } 51 + } 52 + 53 + try { 54 + const albumArtists = await this.handleAlbumArtists(play, parts.artists, transformData); 55 + transformedPlayData.albumArtists = albumArtists; 56 + } catch (e) { 57 + const err = new Error(`Failed to transform album artists`, { cause: e }); 58 + if (throwOnFailure === true || (throwOnFailure !== false && throwOnFailure.includes('albumArtists'))) { 59 + throw err; 60 + } else { 61 + this.logger.warn(err); 62 + } 63 + } 64 + } 65 + 66 + if (parts.album !== undefined) { 67 + try { 68 + const album = await this.handleAlbum(play, parts.album, transformData); 69 + transformedPlayData.album = album; 70 + } catch (e) { 71 + const err = new Error(`Failed to transform album: ${play.data.album}`, { cause: e }); 72 + if (throwOnFailure === true || (throwOnFailure !== false && throwOnFailure.includes('album'))) { 73 + throw err; 74 + } else { 75 + this.logger.warn(err); 76 + } 77 + } 78 + } 79 + 80 + const transformedPlay = { 81 + ...play, 82 + data: { 83 + ...play.data, 84 + ...transformedPlayData 85 + } 86 + } 87 + 88 + return transformedPlay; 89 + } 90 + 91 + public async getTransformerData(play: PlayObject): Promise<T> { 92 + return undefined; 93 + } 94 + 95 + public async checkShouldTransform(play: PlayObject, transformData: T): Promise<void> { 96 + return; 97 + } 98 + 99 + protected abstract handleTitle(play: PlayObject, parts: Y, transformData: T): Promise<string | undefined>; 100 + protected abstract handleArtists(play: PlayObject, parts: Y, transformData: T): Promise<string[] | undefined>; 101 + protected abstract handleAlbumArtists(play: PlayObject, parts: Y, transformData: T): Promise<string[] | undefined>; 102 + protected abstract handleAlbum(play: PlayObject, parts: Y, transformData: T): Promise<string | undefined>; 103 + 104 + protected async handleMeta(play: PlayObject, transformData: T): Promise<TrackMeta | undefined> { 105 + return play.data.meta; 106 + } 107 + 108 + }
+103
src/backend/common/transforms/TransformerManager.ts
··· 1 + import { childLogger, Logger } from "@foxxmd/logging"; 2 + import AbstractTransformer from "./AbstractTransformer.js"; 3 + import { TransformerCommonConfig } from "../../../core/Atomic.js"; 4 + import UserTransformer from "./UserTransformer.js"; 5 + import { StageConfig } from "../infrastructure/Transform.js"; 6 + import { PlayObject } from "../../../core/Atomic.js"; 7 + import { isStageTyped } from "../../utils/PlayTransformUtils.js"; 8 + import { MSCache } from "../Cache.js"; 9 + 10 + export default class TransformerManager { 11 + 12 + logger: Logger; 13 + parentLogger: Logger; 14 + transformers: Map<string, AbstractTransformer[]> = new Map(); 15 + cache: MSCache; 16 + 17 + public constructor(logger: Logger, cache: MSCache) { 18 + this.logger = childLogger(logger, 'Transformer Manager'); 19 + this.parentLogger = logger; 20 + this.cache = cache; 21 + } 22 + 23 + public register(config: TransformerCommonConfig): void { 24 + let transformers: AbstractTransformer[] = []; 25 + if (!this.transformers.has(config.type)) { 26 + this.transformers.set(config.type, []); 27 + } else { 28 + transformers = this.transformers.get(config.type); 29 + } 30 + 31 + if (config.name !== undefined && transformers.some(x => x.config.name === config.name)) { 32 + throw new Error(`Cannot register ${config.type} with name '${config.name}' because an existing transformer already has that name`); 33 + } 34 + const tName = config.name ?? `unnamed-${transformers.length + 1}`; 35 + 36 + let t: AbstractTransformer; 37 + switch (config.type) { 38 + case 'user': 39 + t = new UserTransformer({ name: tName, logger: this.parentLogger, regexCache: this.cache.regexCache, ...config }); 40 + break; 41 + default: 42 + throw new Error(`No transformer of type '${config.type}' exists.`); 43 + } 44 + 45 + this.transformers.set(config.type, [...transformers, t]); 46 + } 47 + 48 + public async initTransformers() { 49 + for (const list of this.transformers.values()) { 50 + for (const transformer of list) { 51 + if (!transformer.isReady()) { 52 + if (!transformer.canAuthUnattended()) { 53 + transformer.logger.warn({ label: 'Heartbeat' }, 'Transformer is not ready but will not try to initialize because auth state is not good and cannot be correct unattended.'); 54 + } 55 + try { 56 + await transformer.tryInitialize({ force: false, notify: true, notifyTitle: 'Could not initialize automatically' }); 57 + } catch (e) { 58 + transformer.logger.error(new Error('Could not initialize source automatically', { cause: e })); 59 + } 60 + } 61 + } 62 + } 63 + } 64 + 65 + protected getTransformerByStage(data: StageConfig): AbstractTransformer { 66 + const list = this.transformers.get(data.type); 67 + if (list === undefined || list.length === 0) { 68 + throw new Error(`No transformer of type '${data.type}' is registered.`); 69 + } 70 + 71 + if (list.length > 0 && (data as any).name === undefined) { 72 + this.logger.warn(`More than one '${data.type}' transformer but name was not specified, using first registered`); 73 + return list[0]; 74 + } else { 75 + return list[0] 76 + } 77 + } 78 + 79 + public parseTransformerConfig(data: any) { 80 + if (!isStageTyped(data)) { 81 + throw new Error(`Must be an object with a 'type' property.`); 82 + } 83 + const t = this.getTransformerByStage(data); 84 + return t.parseConfig(data); 85 + } 86 + 87 + public async handleStage(data: StageConfig, play: PlayObject): Promise<PlayObject> { 88 + const list = this.transformers.get(data.type); 89 + if (list === undefined || list.length === 0) { 90 + throw new Error(`No transformer of type '${data.type}' is registered.`); 91 + } 92 + 93 + let t: AbstractTransformer; 94 + if (list.length > 0 && (data as any).name === undefined) { 95 + this.logger.warn(`More than one '${data.type}' transformer but name was not specified, using first registered`); 96 + t = list[0]; 97 + } else { 98 + t = list[0]; 99 + } 100 + 101 + return await t.handle(data, play); 102 + } 103 + }
+44 -8
src/backend/common/transforms/UserTransformer.ts
··· 1 1 import { searchAndReplace } from "@foxxmd/regex-buddy-core"; 2 2 import { PlayObject } from "../../../core/Atomic.js"; 3 - import { testWhenConditions } from "../../utils/PlayTransformUtils.js"; 3 + import { configValToSearchReplace, isSearchAndReplaceTerm, isStageTyped, isUserStage, testWhenConditions } from "../../utils/PlayTransformUtils.js"; 4 4 import { WebhookPayload } from "../infrastructure/config/health/webhooks.js"; 5 - import { ConditionalSearchAndReplaceRegExp, PlayTransformUserParts } from "../infrastructure/Transform.js"; 5 + import { ConditionalSearchAndReplaceRegExp, PlayTransformUserParts, PlayTransformUserStage, StageConfig } from "../infrastructure/Transform.js"; 6 6 import AbstractTransformer, { TransformerCommon } from "./AbstractTransformer.js" 7 + import AtomicPartsTransformer from "./AtomicPartsTransformer.js"; 8 + 9 + export default class UserTransformer extends AtomicPartsTransformer<ConditionalSearchAndReplaceRegExp[], undefined> { 7 10 8 - export default class UserTransformer extends AbstractTransformer<ConditionalSearchAndReplaceRegExp[], undefined> { 11 + // protected constructor(config: TransformerCommon) { 12 + // super(name, config); 13 + // } 9 14 10 - protected constructor(config: TransformerCommon) { 11 - super(config); 15 + protected doParseConfig(data: StageConfig) { 16 + if (!isUserStage(data)) { 17 + throw new Error(`UserTransformer is only usable with 'user' type stages`); 18 + } 19 + 20 + const stage: PlayTransformUserStage<ConditionalSearchAndReplaceRegExp[]> = { 21 + ...data, 22 + type: 'user' 23 + } 24 + 25 + for (const k of ['artists', 'title', 'album']) { 26 + if (!(k in data)) { 27 + continue; 28 + } 29 + if (!Array.isArray(data[k])) { 30 + throw new Error(`${k} must be an array`); 31 + } 32 + try { 33 + isSearchAndReplaceTerm(data[k]); 34 + stage[k] = data[k].map(configValToSearchReplace); 35 + } catch (e) { 36 + throw new Error(`Property '${k}' was not a valid type`, { cause: e }); 37 + } 38 + } 39 + return stage; 12 40 } 13 41 14 42 protected generateMapper(play: PlayObject) { ··· 20 48 return undefined; 21 49 } 22 50 const mapper = this.generateMapper(play); 23 - return searchAndReplace(play.data.track, parts.map(mapper)); 51 + const result = searchAndReplace(play.data.track, parts.map(mapper)); 52 + if(result.trim() === '') { 53 + return undefined; 54 + } 55 + return result.trim(); 24 56 } 25 57 protected async handleArtists(play: PlayObject, parts: ConditionalSearchAndReplaceRegExp[], _transformData: undefined): Promise<string[] | undefined> { 26 58 if(play.data.artists === undefined || play.data.artists.length === 0) { ··· 51 83 return transformedArtists; 52 84 } 53 85 protected async handleAlbum(play: PlayObject, parts: ConditionalSearchAndReplaceRegExp[], _transformData: undefined): Promise<string | undefined> { 54 - if (play.data.track === undefined) { 86 + if (play.data.album === undefined) { 55 87 return undefined; 56 88 } 57 89 const mapper = this.generateMapper(play); 58 - return searchAndReplace(play.data.album, parts.map(mapper)); 90 + const result = searchAndReplace(play.data.album, parts.map(mapper)); 91 + if(result.trim() === '') { 92 + return undefined; 93 + } 94 + return result.trim(); 59 95 } 60 96 61 97 public notify(payload: WebhookPayload): Promise<void> {
+2
src/backend/index.ts
··· 119 119 const notifiers = new Notifiers(root.get('notifierEmitter'), root.get('clientEmitter'), root.get('sourceEmitter'), root.get('logger')); //root.get('notifiers'); 120 120 await notifiers.buildWebhooks(webhooks); 121 121 122 + await root.items.transformerManager.initTransformers(); 123 + 122 124 /* 123 125 * setup clients 124 126 * */
+16 -1
src/backend/ioc.ts
··· 10 10 import { PassThrough } from "stream"; 11 11 import { CacheConfigOptions } from "./common/infrastructure/Atomic.js"; 12 12 import { MSCache } from "./common/Cache.js"; 13 + import TransformerManager from "./common/transforms/TransformerManager.js"; 14 + import { TransformerCommonConfig } from "../core/Atomic.js"; 13 15 14 16 export let version: string = 'unknown'; 15 17 ··· 27 29 loggerStream?: PassThrough 28 30 loggingConfig?: LogOptions 29 31 cache?: CacheConfigOptions | MSCache | (() => MSCache) 32 + transformerConfigs?: TransformerCommonConfig[] 30 33 } 31 34 32 35 const createRoot = (options: RootOptions = {logger: loggerDebug}) => { ··· 37 40 loggerStream, 38 41 loggingConfig, 39 42 logger, 40 - cache 43 + cache, 44 + transformerConfigs = [], 41 45 } = options || {}; 42 46 const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); 43 47 let disableWeb = dw; ··· 65 69 const f = e; 66 70 }); 67 71 72 + const transformerManager = new TransformerManager(logger, maybeSingletonCache !== undefined ? maybeSingletonCache : cacheFunc()); 73 + transformerManager.register({type: 'user'}); 74 + for(const c of transformerConfigs) { 75 + try { 76 + transformerManager.register(c); 77 + } catch (e) { 78 + logger.warn(new Error('Could not register a transformer', {cause: e})); 79 + } 80 + } 81 + 68 82 const portVal: number | string = process.env.PORT ?? port; 69 83 70 84 return createContainer().add({ ··· 80 94 loggerStream, 81 95 loggingConfig, 82 96 logger: logger, 97 + transformerManager, 83 98 cache: () => maybeSingletonCache !== undefined ? () => maybeSingletonCache : cacheFunc 84 99 }).add((items) => { 85 100 const localUrl = generateBaseURL(baseUrl, items.port)
+14 -12
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 56 56 import { MSCache } from "../common/Cache.js"; 57 57 import { getRoot } from "../ioc.js"; 58 58 import { rehydratePlay } from "../utils/CacheUtils.js"; 59 + import { findAsyncSequential } from "../utils/AsyncUtils.js"; 59 60 60 61 type PlatformMappedPlays = Map<string, {play: PlayObject, source: SourceIdentifier}>; 61 62 type NowPlayingQueue = Map<string, PlatformMappedPlays>; ··· 469 470 470 471 getScrobbledPlays = () => this.scrobbledPlayObjs.data.map(x => x.scrobble) 471 472 472 - findExistingSubmittedPlayObj = (playObjPre: PlayObject): ([undefined, undefined] | [ScrobbledPlayObject, ScrobbledPlayObject[]]) => { 473 + findExistingSubmittedPlayObj = async (playObjPre: PlayObject): Promise<([undefined, undefined] | [ScrobbledPlayObject, ScrobbledPlayObject[]])> => { 473 474 474 - const playObj = this.transformPlay(playObjPre, TRANSFORM_HOOK.candidate); 475 + const playObj = await this.transformPlay(playObjPre, TRANSFORM_HOOK.candidate); 475 476 476 - const dtInvariantMatches = this.scrobbledPlayObjs.data 477 - .map(x => ({...x, play: this.transformPlay(x.play, TRANSFORM_HOOK.existing)})) 477 + const dtInvariantMatches = (await Promise.all(this.scrobbledPlayObjs.data 478 + .map(async x => ({...x, play: await this.transformPlay(x.play, TRANSFORM_HOOK.existing)})))) 478 479 .filter(x => playObjDataMatch(playObj, x.play)); 479 480 480 481 if (dtInvariantMatches.length === 0) { ··· 514 515 515 516 existingScrobble = async (playObjPre: PlayObject) => { 516 517 517 - const playObj = this.transformPlay(playObjPre, TRANSFORM_HOOK.candidate); 518 + const playObj = await this.transformPlay(playObjPre, TRANSFORM_HOOK.candidate); 518 519 519 520 const tr = truncateStringToLength(27); 520 521 const scoreTrackOpts: TrackStringOptions = {include: ['track', 'artist', 'time'], transformers: {track: (t: any, data, existing) => `${existing ? '- ': ''}${tr(t)}`}}; ··· 531 532 let closestMatch: {score: number, breakdowns: string[], confidence: string, scrobble?: PlayObject} = {score: 0, breakdowns: [], confidence: 'No existing scrobble matched with a score higher than 0'}; 532 533 533 534 // then check if we have already recorded this 534 - const [existingExactSubmitted, existingDataSubmitted = []] = this.findExistingSubmittedPlayObj(playObjPre); 535 + const [existingExactSubmitted, existingDataSubmitted = []] = await this.findExistingSubmittedPlayObj(playObjPre); 535 536 536 537 // if we have an submitted play with matching data and play date then we can just return the response from the original scrobble 537 538 if (existingExactSubmitted !== undefined) { ··· 567 568 // -- this is info we only know if play was generated from MS player so we can be reasonably sure 568 569 const looseTimeAccuracy = playObj.data.repeat ? [TA_DURING] : [TA_FUZZY, TA_DURING]; 569 570 570 - existingScrobble = this.recentScrobbles.find((xPre) => { 571 + 572 + existingScrobble = findAsyncSequential(this.recentScrobbles, async (xPre) => { 571 573 572 - const x = this.transformPlay(xPre, TRANSFORM_HOOK.existing); 574 + const x = await this.transformPlay(xPre, TRANSFORM_HOOK.existing); 573 575 574 576 //const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse); 575 577 ··· 795 797 const currQueuedPlay = this.queuedScrobbles.shift(); 796 798 797 799 const [timeFrameValid, timeFrameValidLog] = this.timeFrameIsValid(currQueuedPlay.play); 798 - if (timeFrameValid && !(await this.alreadyScrobbled(this.transformPlay(currQueuedPlay.play, TRANSFORM_HOOK.preCompare)))) { 799 - const transformedScrobble = this.transformPlay(currQueuedPlay.play, TRANSFORM_HOOK.postCompare); 800 + if (timeFrameValid && !(await this.alreadyScrobbled((await this.transformPlay(currQueuedPlay.play, TRANSFORM_HOOK.preCompare))))) { 801 + const transformedScrobble = await this.transformPlay(currQueuedPlay.play, TRANSFORM_HOOK.postCompare); 800 802 try { 801 803 const scrobbledPlay = await this.scrobble(transformedScrobble); 802 804 this.emitEvent('scrobble', {play: transformedScrobble}); ··· 880 882 await this.refreshScrobbles(); 881 883 } 882 884 const [timeFrameValid, timeFrameValidLog] = this.timeFrameIsValid(deadScrobble.play); 883 - if (timeFrameValid && !(await this.alreadyScrobbled(this.transformPlay(deadScrobble.play, TRANSFORM_HOOK.preCompare)))) { 884 - const transformedScrobble = this.transformPlay(deadScrobble.play, TRANSFORM_HOOK.postCompare); 885 + if (timeFrameValid && !(await this.alreadyScrobbled((await this.transformPlay(deadScrobble.play, TRANSFORM_HOOK.preCompare))))) { 886 + const transformedScrobble = await this.transformPlay(deadScrobble.play, TRANSFORM_HOOK.postCompare); 885 887 try { 886 888 const scrobbledPlay = await this.scrobble(transformedScrobble); 887 889 this.emitEvent('scrobble', {play: transformedScrobble});
+16 -14
src/backend/sources/AbstractSource.ts
··· 40 40 import { WebhookPayload } from '../common/infrastructure/config/health/webhooks.js'; 41 41 import { messageWithCauses, messageWithCausesTruncatedDefault } from '../utils/ErrorUtils.js'; 42 42 import { genericSourcePlayMatch } from '../utils/PlayComparisonUtils.js'; 43 + import { findAsync } from '../utils/AsyncUtils.js'; 43 44 44 45 export interface RecentlyPlayedOptions { 45 46 limit?: number ··· 173 174 return lists; 174 175 } 175 176 176 - existingDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject | undefined => { 177 + existingDiscovered = async (play: PlayObject, opts: {checkAll?: boolean} = {}): Promise<PlayObject | undefined> => { 177 178 const lists: PlayObject[][] = this.getExistingDiscoveredLists(play, opts); 178 - const candidate = this.transformPlay(play, TRANSFORM_HOOK.candidate); 179 + const candidate = await this.transformPlay(play, TRANSFORM_HOOK.candidate); 179 180 for(const list of lists) { 180 - const existing = list.find(x => { 181 - const e = this.transformPlay(x, TRANSFORM_HOOK.existing); 181 + 182 + const existing = await findAsync(list,async x => { 183 + const e = await this.transformPlay(x, TRANSFORM_HOOK.existing); 182 184 return genericSourcePlayMatch(e, candidate); 183 185 }); 184 186 if(existing) { ··· 188 190 return undefined; 189 191 } 190 192 191 - alreadyDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): boolean => { 192 - const existing = this.existingDiscovered(play, opts); 193 + alreadyDiscovered = async (play: PlayObject, opts: {checkAll?: boolean} = {}): Promise<boolean> => { 194 + const existing = await this.existingDiscovered(play, opts); 193 195 return existing !== undefined; 194 196 } 195 197 196 - discover = (plays: PlayObject[], options: { checkAll?: boolean, [key: string]: any } = {}): PlayObject[] => { 198 + discover = async (plays: PlayObject[], options: { checkAll?: boolean, [key: string]: any } = {}): Promise<PlayObject[]> => { 197 199 const newDiscoveredPlays: PlayObject[] = []; 198 200 199 - const transformedPlayed = plays.map(x => this.transformPlay(x, TRANSFORM_HOOK.preCompare)); 201 + const transformedPlayed = await Promise.all(plays.map(x => this.transformPlay(x, TRANSFORM_HOOK.preCompare))); 200 202 201 203 for(const play of transformedPlayed) { 202 - if(!this.alreadyDiscovered(play, options)) { 204 + if(!(await this.alreadyDiscovered(play, options))) { 203 205 this.addPlayToDiscovered(play); 204 206 newDiscoveredPlays.push(play); 205 207 } ··· 222 224 } 223 225 224 226 225 - protected scrobble = (newDiscoveredPlays: PlayObject[], options: { forceRefresh?: boolean, [key: string]: any, discoverLocation?: 'backlog' | [key: string] } = {}) => { 227 + protected scrobble = async (newDiscoveredPlays: PlayObject[], options: { forceRefresh?: boolean, [key: string]: any, discoverLocation?: 'backlog' | [key: string] } = {}) => { 226 228 227 229 if(newDiscoveredPlays.length > 0) { 228 230 if(!this.shouldScrobble(options.discoverLocation)) { ··· 230 232 } 231 233 newDiscoveredPlays.sort(sortByOldestPlayDate); 232 234 this.emitter.emit('discoveredToScrobble', { 233 - data: newDiscoveredPlays.map(x => this.transformPlay(x, TRANSFORM_HOOK.postCompare)), 235 + data: await Promise.all(newDiscoveredPlays.map(x => this.transformPlay(x, TRANSFORM_HOOK.postCompare))), 234 236 options: { 235 237 ...options, 236 238 checkTime: newDiscoveredPlays[newDiscoveredPlays.length-1].data.playDate.add(2, 'second'), ··· 259 261 } catch (e) { 260 262 throw new Error('Error occurred while fetching backlogged plays', {cause: e}); 261 263 } 262 - const discovered = this.discover(backlogPlays, {discoverLocation: 'backlog'}); 264 + const discovered = await this.discover(backlogPlays, {discoverLocation: 'backlog'}); 263 265 264 266 const { 265 267 options: { ··· 270 272 if (scrobbleBacklog) { 271 273 if (discovered.length > 0) { 272 274 this.logger.info('Scrobbling backlogged tracks...'); 273 - this.scrobble(discovered); 275 + await this.scrobble(discovered); 274 276 this.logger.info('Backlog scrobbling complete.'); 275 277 } else { 276 278 this.logger.info('All tracks already discovered!'); ··· 451 453 this.logger.info(`Potential plays were discovered close to polling interval! Delaying scrobble clients refresh by ${maxDelay} seconds so other clients have time to scrobble first`); 452 454 await sleep(maxDelay * 1000); 453 455 } 454 - newDiscovered = this.discover(playObjs); 456 + newDiscovered = await this.discover(playObjs); 455 457 this.scrobble(newDiscovered, 456 458 { 457 459 forceRefresh: closeToInterval
+1 -1
src/backend/sources/AzuracastSource.ts
··· 224 224 position: online && play !== undefined ? play.meta.trackProgressPosition : undefined 225 225 } 226 226 227 - return this.processRecentPlays([playerState]); 227 + return await this.processRecentPlays([playerState]); 228 228 } 229 229 230 230 }
+1 -1
src/backend/sources/ChromecastSource.ts
··· 641 641 } 642 642 } 643 643 644 - const playsToReturn = this.processRecentPlays(plays); 644 + const playsToReturn = await this.processRecentPlays(plays); 645 645 646 646 this.pruneApplications(); 647 647
+7 -6
src/backend/sources/DeezerInternalSource.ts
··· 12 12 import MemorySource from "./MemorySource.js"; 13 13 import { genericSourcePlayMatch } from "../utils/PlayComparisonUtils.js"; 14 14 import { TemporalPlayComparisonOptions } from "../utils/TimeUtils.js"; 15 + import { findAsync, findIndexAsync } from "../utils/AsyncUtils.js"; 15 16 16 17 interface DeezerHistoryResponse { 17 18 errors: [] ··· 202 203 protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options}) 203 204 204 205 205 - existingDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject | undefined => { 206 + existingDiscovered = async (play: PlayObject, opts: {checkAll?: boolean} = {}): Promise<PlayObject | undefined> => { 206 207 const lists: PlayObject[][] = this.getExistingDiscoveredLists(play, opts); 207 - const candidate = this.transformPlay(play, TRANSFORM_HOOK.candidate); 208 + const candidate = await this.transformPlay(play, TRANSFORM_HOOK.candidate); 208 209 for(const list of lists) { 209 - const existing = list.find(x => { 210 - const e = this.transformPlay(x, TRANSFORM_HOOK.existing); 210 + const existing = await findAsync(list, async x => { 211 + const e = await this.transformPlay(x, TRANSFORM_HOOK.existing); 211 212 return genericSourcePlayMatch(e, candidate); 212 213 }); 213 214 if(existing) { 214 215 return existing; 215 216 } 216 217 if(this.config.options?.fuzzyDiscoveryIgnore === true || this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') { 217 - const fuzzyIndex = list.findIndex(x => { 218 - const e = this.transformPlay(x, TRANSFORM_HOOK.existing); 218 + const fuzzyIndex = await findIndexAsync(list, async x => { 219 + const e = await this.transformPlay(x, TRANSFORM_HOOK.existing); 219 220 let temporalOptions: TemporalPlayComparisonOptions = {}; 220 221 const temporalAccuracy: TemporalAccuracy[] = [TA_EXACT, TA_CLOSE, TA_FUZZY]; 221 222 if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
+3 -3
src/backend/sources/EndpointLastfmSource.ts
··· 72 72 73 73 handle = async (stateData: PlayerStateData) => { 74 74 75 - this.processRecentPlays([stateData]); 75 + await this.processRecentPlays([stateData]); 76 76 77 77 if (stateData.play.meta.nowPlaying === false && this.isValidScrobble(stateData.play)) { 78 - const discovered = this.discover([stateData.play]); 78 + const discovered = await this.discover([stateData.play]); 79 79 if (discovered.length > 0) { 80 - this.scrobble(discovered); 80 + await this.scrobble(discovered); 81 81 } 82 82 } 83 83 }
+3 -3
src/backend/sources/EndpointListenbrainzSource.ts
··· 89 89 90 90 handle = async (stateData: PlayerStateData) => { 91 91 92 - this.processRecentPlays([stateData]); 92 + await this.processRecentPlays([stateData]); 93 93 94 94 if (stateData.play.meta.nowPlaying === false && this.isValidScrobble(stateData.play)) { 95 - const discovered = this.discover([stateData.play]); 95 + const discovered = await this.discover([stateData.play]); 96 96 if (discovered.length > 0) { 97 - this.scrobble(discovered); 97 + await this.scrobble(discovered); 98 98 } 99 99 } 100 100 }
+2 -2
src/backend/sources/IcecastSource.ts
··· 122 122 } 123 123 124 124 if (this.currentMetadata === undefined) { 125 - return this.processRecentPlays([]); 125 + return await this.processRecentPlays([]); 126 126 } 127 127 128 128 // if (this.manualListening === false || (this.config.options.scrobbleOnStart === false && this.manualListening === undefined)) { ··· 146 146 play 147 147 } 148 148 149 - return this.processRecentPlays([playerState]); 149 + return await this.processRecentPlays([playerState]); 150 150 } 151 151 } 152 152
+1 -1
src/backend/sources/JRiverSource.ts
··· 142 142 } 143 143 } 144 144 145 - return this.processRecentPlays(play); 145 + return await this.processRecentPlays(play); 146 146 } 147 147 148 148 }
+1 -1
src/backend/sources/JellyfinApiSource.ts
··· 497 497 this.logger[this.logFilterFailure](dropReason); 498 498 } 499 499 } 500 - return this.processRecentPlays(validSessions); 500 + return await this.processRecentPlays(validSessions); 501 501 } 502 502 503 503 sessionToPlayerState = (obj: SessionInfo): PlayerStateDataMaybePlay => {
+3 -3
src/backend/sources/JellyfinSource.ts
··· 354 354 scrobbleOpts.checkAll = true; 355 355 356 356 } else { 357 - newPlays = this.processRecentPlays([playObj]); 357 + newPlays = await this.processRecentPlays([playObj]); 358 358 } 359 359 360 360 if(newPlays.length > 0) { 361 361 try { 362 - const discovered = this.discover(newPlays, scrobbleOpts); 363 - this.scrobble(discovered); 362 + const discovered = await this.discover(newPlays, scrobbleOpts); 363 + await this.scrobble(discovered); 364 364 } catch (e) { 365 365 this.logger.error('Encountered error while scrobbling') 366 366 this.logger.error(e)
+1 -1
src/backend/sources/KodiSource.ts
··· 59 59 60 60 const play = await this.client.getRecentlyPlayed(options); 61 61 62 - return this.processRecentPlays(play); 62 + return await this.processRecentPlays(play); 63 63 } 64 64 65 65 }
+1 -1
src/backend/sources/KoitoSource.ts
··· 61 61 62 62 getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => { 63 63 const {limit = 20} = options; 64 - this.processRecentPlays([]); 64 + await this.processRecentPlays([]); 65 65 return await this.api.getRecentlyPlayed(limit); 66 66 } 67 67
+1 -1
src/backend/sources/LastfmSource.ts
··· 131 131 getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => { 132 132 try { 133 133 const [history, now] = await this.getLastfmRecentTrack(options); 134 - this.processRecentPlays(now); 134 + await this.processRecentPlays(now); 135 135 return history; 136 136 } catch (e) { 137 137 throw e;
+1 -1
src/backend/sources/ListenbrainzSource.ts
··· 79 79 return await this.api.getRecentlyPlayedKoito(limit); 80 80 } 81 81 const now = await this.api.getPlayingNow(); 82 - this.processRecentPlays(now.listens.map(x => ListenbrainzSource.formatPlayObj(x))); 82 + await this.processRecentPlays(now.listens.map(x => ListenbrainzSource.formatPlayObj(x))); 83 83 return await this.api.getRecentlyPlayed(limit); 84 84 } 85 85
+1 -1
src/backend/sources/MPDSource.ts
··· 228 228 position: state.elapsed 229 229 } 230 230 231 - return this.processRecentPlays([playerState]); 231 + return await this.processRecentPlays([playerState]); 232 232 } 233 233 234 234 }
+1 -1
src/backend/sources/MPRISSource.ts
··· 243 243 if(options.display === true) { 244 244 return deduped; 245 245 } 246 - return this.processRecentPlays(deduped); 246 + return await this.processRecentPlays(deduped); 247 247 } 248 248 } 249 249
+1 -1
src/backend/sources/MalojaSource.ts
··· 55 55 56 56 getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { 57 57 const { limit = 20 } = options; 58 - this.processRecentPlays([]); 58 + await this.processRecentPlays([]); 59 59 return await this.api.getRecentScrobbles(limit); 60 60 } 61 61
+19 -11
src/backend/sources/MemorySource.ts
··· 1 1 import { Logger } from "@foxxmd/logging"; 2 2 import dayjs, { Dayjs } from "dayjs"; 3 3 import { EventEmitter } from "events"; 4 - import { SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler"; 4 + import { AsyncTask, SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler"; 5 5 import { PlayObject, SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerObj } from "../../core/Atomic.js"; 6 6 import { buildTrackString } from "../../core/StringUtils.js"; 7 7 import { ··· 24 24 thresholdResultSummary, 25 25 } from "../utils.js"; 26 26 import { timePassesScrobbleThreshold, timeToHumanTimestamp } from "../utils/TimeUtils.js"; 27 + import { PromisePool } from "@supercharge/promise-pool"; 27 28 import AbstractSource from "./AbstractSource.js"; 28 29 import { AbstractPlayerState, createPlayerOptions, PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; 29 30 import { GenericPlayerState } from "./PlayerState/GenericPlayerState.js"; ··· 56 57 57 58 // player cleanup on *schedule* is needed when the Source is non-polling (ingress) 58 59 // because if the source stops sending updates then processRecentPlays() was never called so we never remove old players 59 - this.scheduler.addSimpleIntervalJob(new SimpleIntervalJob({seconds: 15}, new Task('Player Cleanup', () => { 60 - if(!this.canPoll) { 61 - this.cleanupPlayers(); 60 + this.scheduler.addSimpleIntervalJob(new SimpleIntervalJob({ seconds: 15 }, new AsyncTask('Player Cleanup', (): Promise<any> => { 61 + if (!this.canPoll) { 62 + return Promise.resolve(); 62 63 } 64 + return PromisePool 65 + .withConcurrency(1) 66 + .for(this.players.keys()) 67 + .process(async (key) => { 68 + 69 + await this.cleanupPlayer(key); 70 + }); 63 71 }))); 64 72 } 65 73 ··· 69 77 } 70 78 } 71 79 72 - cleanupPlayer = (key: string): PlayObject | undefined => { 80 + cleanupPlayer = async (key: string): Promise<PlayObject | undefined> => { 73 81 const player = this.players.get(key); 74 82 if(player === undefined) { 75 83 this.logger.warn({labels: 'Player Cleanup'},`No Player with ID ${key} exists! Cannot cleanup.`); ··· 113 121 const cleanupPlay = player.getPlayedObject(true); 114 122 let discoverablePlay: boolean; 115 123 if(cleanupPlay !== undefined) { 116 - const [discoverable, discoverableReason] = this.isListenedPlayDiscoverable(cleanupPlay); 124 + const [discoverable, discoverableReason] = await this.isListenedPlayDiscoverable(cleanupPlay); 117 125 discoverablePlay = discoverable; 118 126 if(this.playerSourceOfTruth === SOURCE_SOT.PLAYER) { 119 127 player.logger.verbose({labels: label}, discoverableReason); ··· 188 196 return sessions[0]; 189 197 } 190 198 191 - processRecentPlays = (datas: (PlayObject | PlayerStateDataMaybePlay)[], reportedTS?: Dayjs) => { 199 + processRecentPlays = async (datas: (PlayObject | PlayerStateDataMaybePlay)[], reportedTS?: Dayjs) => { 192 200 193 201 const { 194 202 options: { ··· 250 258 // wait to discover play until it is stale or current play has changed 251 259 // so that our discovered track has an accurate "listenedFor" count 252 260 if (candidate !== undefined && (playChanged || player.isUpdateStale())) { 253 - const [discoverable, discoverableReason] = this.isListenedPlayDiscoverable(candidate); 261 + const [discoverable, discoverableReason] = await this.isListenedPlayDiscoverable(candidate); 254 262 if(discoverable) { 255 263 if(this.playerSourceOfTruth === SOURCE_SOT.PLAYER) { 256 264 player.logger.verbose(discoverableReason); ··· 273 281 } 274 282 }); 275 283 } else { 276 - const playFromCleanup = this.cleanupPlayer(key); 284 + const playFromCleanup = await this.cleanupPlayer(key); 277 285 if(playFromCleanup !== undefined) { 278 286 newStatefulPlays.push(playFromCleanup); 279 287 } ··· 283 291 return newStatefulPlays; 284 292 } 285 293 286 - protected isListenedPlayDiscoverable = (candidate: PlayObject): [boolean, string] => { 294 + protected isListenedPlayDiscoverable = async (candidate: PlayObject): Promise<[boolean, string]> => { 287 295 288 296 const { 289 297 options: { ··· 295 303 const thresholdResults = timePassesScrobbleThreshold(scrobbleThresholds, candidate.data.listenedFor, candidate.data.duration); 296 304 297 305 if (thresholdResults.passes) { 298 - const matchingRecent = this.existingDiscovered(candidate); //sRecentlyPlayed.find(x => playObjDataMatch(x, candidate)); 306 + const matchingRecent = await this.existingDiscovered(candidate); //sRecentlyPlayed.find(x => playObjDataMatch(x, candidate)); 299 307 if (matchingRecent === undefined) { 300 308 return [true,`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and not matching any prior plays`]; 301 309 } else {
+1 -1
src/backend/sources/MopidySource.ts
··· 213 213 play 214 214 } 215 215 216 - return this.processRecentPlays([playerState]); 216 + return await this.processRecentPlays([playerState]); 217 217 } 218 218 219 219 }
+3 -3
src/backend/sources/MusicCastSource.ts
··· 112 112 } 113 113 if ((statusResp.body as DeviceStatusResponse).power !== 'on') { 114 114 this.logger.debug('MusicCast device is offline'); 115 - return this.processRecentPlays([]); 115 + return await this.processRecentPlays([]); 116 116 } 117 117 118 118 const playInfo = await this.getAnyPlayInfo(); 119 119 if(playInfo === undefined) { 120 - return this.processRecentPlays([]); 120 + return await this.processRecentPlays([]); 121 121 } 122 122 123 123 const play = formatPlayObj(playInfo); ··· 130 130 position: play.meta.trackProgressPosition 131 131 } 132 132 133 - return this.processRecentPlays([playerState]); 133 + return await this.processRecentPlays([playerState]); 134 134 } 135 135 } 136 136
+1 -1
src/backend/sources/MusikcubeSource.ts
··· 240 240 position: playbackOverview.options.playing_current_time 241 241 } 242 242 243 - return this.processRecentPlays([playerState]); 243 + return await this.processRecentPlays([playerState]); 244 244 } 245 245 246 246 }
+1 -1
src/backend/sources/PlexApiSource.ts
··· 406 406 } 407 407 } 408 408 } 409 - return this.processRecentPlays(validSessions); 409 + return await this.processRecentPlays(validSessions); 410 410 } 411 411 412 412 getSourceArt = async (data: string): Promise<[Readable, string]> => {
+2 -2
src/backend/sources/PlexSource.ts
··· 223 223 } 224 224 225 225 try { 226 - const discovered = this.discover([playObj]); 227 - this.scrobble(discovered); 226 + const discovered = await this.discover([playObj]); 227 + await this.scrobble(discovered); 228 228 } catch (e) { 229 229 this.logger.error('Encountered error while scrobbling') 230 230 this.logger.error(e)
+1 -1
src/backend/sources/SpotifySource.ts
··· 356 356 plays.push(currPlay); 357 357 } 358 358 } 359 - const newPlays = this.processRecentPlays(plays); 359 + const newPlays = await this.processRecentPlays(plays); 360 360 // hint that scrobble timestamp source of truth should be when the track ended (player changed tracks) 361 361 // rather than when we first saw the track 362 362 //
+1 -1
src/backend/sources/SubsonicSource.ts
··· 292 292 // sometimes subsonic sources will return the same track as being played twice on the same player, need to remove this so we don't duplicate plays 293 293 const deduped = removeDuplicates(entry.map(x => SubsonicSource.formatPlayObj(x, {sourceData: this.sourceData}))); 294 294 const userFiltered = this.usersAllow.length == 0 ? deduped : deduped.filter(x => x.meta.user === undefined || this.usersAllow.map(x => x.toLocaleLowerCase()).includes(x.meta.user.toLocaleLowerCase())); 295 - return this.processRecentPlays(userFiltered); 295 + return await this.processRecentPlays(userFiltered); 296 296 } 297 297 298 298 getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new SubsonicPlayerState(logger, id, opts);
+1 -1
src/backend/sources/TealfmSource.ts
··· 95 95 } catch (e) { 96 96 throw new Error('Error occurred while trying to fetch records', {cause: e}); 97 97 } 98 - this.processRecentPlays([]); 98 + await this.processRecentPlays([]); 99 99 const plays = list.map(x => listRecordToPlay(x)); 100 100 return plays; 101 101 }
+1 -1
src/backend/sources/VLCSource.ts
··· 259 259 position: state.time 260 260 } 261 261 262 - return this.processRecentPlays([playerState]); 262 + return await this.processRecentPlays([playerState]); 263 263 } 264 264 265 265 }
+3 -3
src/backend/sources/WebScrobblerSource.ts
··· 180 180 181 181 handle = async (stateData: PlayerStateData) => { 182 182 183 - this.processRecentPlays([stateData]); 183 + await this.processRecentPlays([stateData]); 184 184 185 185 if (stateData.play.meta.nowPlaying === false && this.isValidScrobble(stateData.play)) { 186 - const discovered = this.discover([stateData.play]); 186 + const discovered = await this.discover([stateData.play]); 187 187 if (discovered.length > 0) { 188 - this.scrobble(discovered); 188 + await this.scrobble(discovered); 189 189 } 190 190 } 191 191 }
+51 -52
src/backend/tests/component/component.test.ts
··· 4 4 import { after, before, describe, it } from 'mocha'; 5 5 import AbstractComponent from "../../common/AbstractComponent.js"; 6 6 7 - import { ConditionalSearchAndReplaceRegExp, STAGE_TYPES, STAGE_TYPES_METADATA, TRANSFORM_HOOK } from "../../common/infrastructure/Transform.js"; 7 + import { ConditionalSearchAndReplaceRegExp, STAGE_TYPES, STAGE_TYPES_METADATA, STAGE_TYPES_USER, TRANSFORM_HOOK } from "../../common/infrastructure/Transform.js"; 8 8 9 9 import { isConditionalSearchAndReplace } from "../../utils/PlayTransformUtils.js"; 10 10 import { asPlays, generatePlay, normalizePlays } from "../utils/PlayTestUtils.js"; ··· 71 71 preCompare: { 72 72 // @ts-expect-error 73 73 type: "test", 74 - // @ts-expect-error 75 74 title: ['something'] 76 75 } 77 76 } ··· 80 79 81 80 // https://github.com/chaijs/chai/issues/655#issuecomment-204386414 82 81 expect(() => component.buildTransformRules()).to.throw(Error).that.satisfies((e) => { 83 - return findCauseByMessage(e, `Stage has invalid 'type'`); 82 + return findCauseByMessage(e, `No transformer of type 'test'`); 84 83 }); 85 84 }); 86 85 ··· 221 220 }); 222 221 }); 223 222 224 - describe('Non-User Stage Parsing', function () { 223 + // describe('Non-User Stage Parsing', function () { 225 224 226 - describe('Non-User Stage Types', function () { 225 + // describe('Non-User Stage Types', function () { 227 226 228 - for(const t of STAGE_TYPES_METADATA) { 227 + // for(const t of STAGE_TYPES_USER) { 229 228 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 - } 239 - } 240 - } 229 + // it(`Allows non-user Stage Type ${t}`, function () { 230 + // component.config = { 231 + // options: { 232 + // playTransform: { 233 + // preCompare: { 234 + // type: t, 235 + // title: true 236 + // } 237 + // } 238 + // } 239 + // } 241 240 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 - }); 241 + // expect(() => component.buildTransformRules()).to.not.throw(); 242 + // expect(component.transformRules.preCompare).to.be.an('array'); 243 + // expect(component.transformRules.preCompare).to.be.length(1); 244 + // expect(component.transformRules.preCompare).to.have.nested.property('0.type'); 245 + // expect(component.transformRules.preCompare[0].type).eq(t); 246 + // }); 248 247 249 - } 248 + // } 250 249 251 - }); 250 + // }); 252 251 253 - }); 252 + // }); 254 253 255 254 describe('Play Transforming', function () { 256 255 257 - it('Returns original play if no hooks are defined', function () { 256 + it('Returns original play if no hooks are defined', async function () { 258 257 component.buildTransformRules(); 259 258 260 259 const play = generatePlay(); 261 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 260 + const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 262 261 expect(JSON.stringify(play)).equal(JSON.stringify(transformed)); 263 262 }); 264 263 265 264 describe('User Play Transforming', function () { 266 - it('Transforms when hook is present', function () { 265 + it('Transforms when hook is present', async function () { 267 266 component.config = { 268 267 options: { 269 268 playTransform: { ··· 276 275 component.buildTransformRules(); 277 276 278 277 const play = generatePlay({ track: 'My coolsomething track' }); 279 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 278 + const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 280 279 expect(transformed.data.track).equal('My cool track'); 281 280 }); 282 281 283 - it('Transforms consecutively when hook is present with multiple values', function () { 282 + it('Transforms consecutively when hook is present with multiple values', async function () { 284 283 component.config = { 285 284 options: { 286 285 playTransform: { ··· 293 292 component.buildTransformRules(); 294 293 295 294 const play = generatePlay({ track: 'My coolsomething track' }); 296 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 295 + const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 297 296 expect(transformed.data.track).equal('My track'); 298 297 }); 299 298 300 - it('Transforms using parsed regex', function () { 299 + it('Transforms using parsed regex', async function () { 301 300 component.config = { 302 301 options: { 303 302 playTransform: { ··· 315 314 component.buildTransformRules(); 316 315 317 316 const play = generatePlay({ track: 'My cool something track' }); 318 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 317 + const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 319 318 expect(transformed.data.track).equal('My cool thing track'); 320 319 }); 321 320 322 321 323 - it('Transforms using parsed regex to get primary artist from delimited artist string', function () { 322 + it('Transforms using parsed regex to get primary artist from delimited artist string', async function () { 324 323 component.config = { 325 324 options: { 326 325 playTransform: { ··· 338 337 component.buildTransformRules(); 339 338 340 339 const play = generatePlay({ artists: ['My Artist One / My Artist Two / Another Guy'] }); 341 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 340 + const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 342 341 expect(transformed.data.artists).length(1) 343 342 expect(transformed.data.artists[0]).equal('My Artist One'); 344 343 }); 345 344 346 - it('Removes title when transform replaces with empty string', function () { 345 + it('Removes title when transform replaces with empty string', async function () { 347 346 component.config = { 348 347 options: { 349 348 playTransform: { ··· 356 355 component.buildTransformRules(); 357 356 358 357 const play = generatePlay({ track: 'something' }); 359 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 358 + const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 360 359 expect(transformed.data.track).is.undefined; 361 360 }); 362 361 363 - it('Removes album when transform replaces with empty string', function () { 362 + it('Removes album when transform replaces with empty string', async function () { 364 363 component.config = { 365 364 options: { 366 365 playTransform: { ··· 373 372 component.buildTransformRules(); 374 373 375 374 const play = generatePlay({ album: 'something' }); 376 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 375 + const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 377 376 expect(transformed.data.album).is.undefined; 378 377 }); 379 378 380 - it('Removes an artist when transform replaces with empty string', function () { 379 + it('Removes an artist when transform replaces with empty string', async function () { 381 380 component.config = { 382 381 options: { 383 382 playTransform: { ··· 390 389 component.buildTransformRules(); 391 390 392 391 const play = generatePlay({ artists: ['something', 'big'] }); 393 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 392 + const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 394 393 expect(transformed.data.artists!.length).is.eq(1) 395 394 expect(transformed.data.artists![0]).is.eq('big') 396 395 }); ··· 403 402 describe('Conditional Transforming', function () { 404 403 405 404 describe('On Hook', function () { 406 - it('Does not run hook if when conditions do not match', function () { 405 + it('Does not run hook if when conditions do not match', async function () { 407 406 component.config = { 408 407 options: { 409 408 playTransform: { ··· 421 420 component.buildTransformRules(); 422 421 423 422 const play = generatePlay({ artists: ['something', 'big'], album: 'It Has No Match' }); 424 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 423 + const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 425 424 expect(transformed.data.artists!.length).is.eq(2) 426 425 expect(transformed.data.artists![0]).is.eq('something') 427 426 }); 428 427 429 - it('Does run hook if when conditions matches', function () { 428 + it('Does run hook if when conditions matches', async function () { 430 429 component.config = { 431 430 options: { 432 431 playTransform: { ··· 444 443 component.buildTransformRules(); 445 444 446 445 const play = generatePlay({ artists: ['something', 'big'], album: 'It Has This Match' }); 447 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 446 + const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 448 447 expect(transformed.data.artists!.length).is.eq(1) 449 448 expect(transformed.data.artists![0]).is.eq('big') 450 449 }); 451 450 }); 452 451 453 452 describe('On Search-And-Replace', function () { 454 - it('Does not run hook if when conditions do not match', function () { 453 + it('Does not run hook if when conditions do not match', async function () { 455 454 component.config = { 456 455 options: { 457 456 playTransform: { ··· 474 473 component.buildTransformRules(); 475 474 476 475 const play = generatePlay({ artists: ['something', 'big'], album: 'It Has No Match' }); 477 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 476 + const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 478 477 expect(transformed.data.artists!.length).is.eq(2) 479 478 expect(transformed.data.artists![0]).is.eq('something') 480 479 }); 481 480 482 - it('Does run hook if when conditions matches', function () { 481 + it('Does run hook if when conditions matches', async function () { 483 482 component.config = { 484 483 options: { 485 484 playTransform: { ··· 502 501 component.buildTransformRules(); 503 502 504 503 const play = generatePlay({ artists: ['something', 'big'], album: 'It Has This Match' }); 505 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 504 + const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 506 505 expect(transformed.data.artists!.length).is.eq(1) 507 506 expect(transformed.data.artists![0]).is.eq('big') 508 507 }); ··· 512 511 513 512 describe('Multiple hook transforms', function () { 514 513 515 - it('Accumulates transforms', function () { 514 + it('Accumulates transforms', async function () { 516 515 component.config = { 517 516 options: { 518 517 playTransform: { ··· 540 539 541 540 component.buildTransformRules(); 542 541 const play = generatePlay({ track: 'My cool something track' }); 543 - const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare); 542 + const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 544 543 expect(transformed.data.track).equal('My cool final thing track'); 545 544 }); 546 545
+32 -1
src/backend/tests/plays/playParsing.test.ts
··· 4 4 import { after, before, describe, it } from 'mocha'; 5 5 6 6 import { asPlays, generateArtistsStr, generatePlay, normalizePlays } from "../utils/PlayTestUtils.js"; 7 - import { parseArtistCredits, parseContextAwareStringList, parseCredits } from "../../utils/StringUtils.js"; 7 + import { parseArtistCredits, parseContextAwareStringList, parseCredits, parseTrackCredits, uniqueNormalizedStrArr } from "../../utils/StringUtils.js"; 8 + import testData from '../utils/playTestData.json' with { type: "json" }; 9 + import { intersect } from "../../utils.js"; 10 + import { ExpectedResults } from "../utils/interfaces.js"; 11 + 12 + interface PlayTestFixture { 13 + caseHints: string[] 14 + data: { 15 + track: string 16 + artists: string[] 17 + album?: string 18 + } 19 + expected: ExpectedResults 20 + } 8 21 9 22 describe('#PlayParse Parsing Artists from String', function() { 10 23 ··· 137 150 }); 138 151 139 152 153 + }); 154 + 155 + describe('Play Track Strings',function () { 156 + 157 + const testFixtures = testData as unknown as PlayTestFixture[]; 158 + const joinerData = testFixtures.filter(x => intersect(['joiner','track'], x.caseHints).length === 2); 159 + 160 + it('should parse joiners from track title', function() { 161 + for(const test of joinerData) { 162 + const res = parseTrackCredits(test.data.track); 163 + let artists: string[] = [...test.data.artists]; 164 + if(res.secondary !== undefined) { 165 + artists = uniqueNormalizedStrArr([...artists, ...res.secondary]); 166 + } 167 + assert.equal(res.primaryComposite, test.expected.track); 168 + assert.sameDeepMembers(artists, test.expected.artists); 169 + } 170 + }); 140 171 });
+3 -3
src/backend/tests/scrobbler/scrobblers.test.ts
··· 453 453 playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds') 454 454 }); 455 455 456 - const [matchedPlay, matchedData] = testScrobbler.findExistingSubmittedPlayObj(newScrobble); 456 + const [matchedPlay, matchedData] = await testScrobbler.findExistingSubmittedPlayObj(newScrobble); 457 457 458 458 assert.isUndefined(matchedPlay); 459 459 assert.isEmpty(matchedData); ··· 465 465 }); 466 466 testScrobbler.addScrobbledTrack(newScrobble, newScrobble); 467 467 468 - const [matchedPlay, matchedData] = testScrobbler.findExistingSubmittedPlayObj(newScrobble); 468 + const [matchedPlay, matchedData] = await testScrobbler.findExistingSubmittedPlayObj(newScrobble); 469 469 470 470 assert.isDefined(matchedPlay); 471 471 assert.isNotEmpty(matchedData); ··· 480 480 const dupScrobble = clone(newScrobble); 481 481 dupScrobble.data.playDate = newScrobble.data.playDate.add(2, 'seconds'); 482 482 483 - const [matchedPlay, matchedData] = testScrobbler.findExistingSubmittedPlayObj(dupScrobble); 483 + const [matchedPlay, matchedData] = await testScrobbler.findExistingSubmittedPlayObj(dupScrobble); 484 484 485 485 assert.isDefined(matchedPlay); 486 486 assert.isNotEmpty(matchedData);
+53 -53
src/backend/tests/source/source.test.ts
··· 51 51 source = generateSource(); 52 52 }); 53 53 54 - it('Transforms play on preCompare', function() { 54 + it('Transforms play on preCompare', async function() { 55 55 source.config.options = { 56 56 playTransform: { 57 57 preCompare: { ··· 68 68 const newScrobble = generatePlay({ 69 69 track: 'my cool track' 70 70 }); 71 - const discovered = source.discover([newScrobble]) 71 + const discovered = await source.discover([newScrobble]) 72 72 expect(discovered.length).eq(1); 73 73 expect(discovered[0].data.track).is.eq('my fun track'); 74 74 }); ··· 90 90 const newScrobble = generatePlay({ 91 91 track: 'my cool track' 92 92 }); 93 - const discovered = source.discover([newScrobble]) 93 + const discovered = await source.discover([newScrobble]) 94 94 expect(discovered.length).eq(1); 95 95 expect(discovered[0].data.track).is.eq('my cool track'); 96 96 ··· 101 101 expect(e.data[0].data.track).is.eq('my fun track'); 102 102 }); 103 103 104 - it('Transforms play existing comparison', function() { 104 + it('Transforms play existing comparison', async function() { 105 105 source.config.options = { 106 106 playTransform: { 107 107 compare: { ··· 120 120 const newScrobble = generatePlay({ 121 121 track: 'my hugely cool and very different track title', 122 122 }); 123 - const discovered = source.discover([newScrobble]) 123 + const discovered = await source.discover([newScrobble]) 124 124 expect(discovered.length).eq(1); 125 125 expect(discovered[0].data.track).is.eq('my hugely cool and very different track title'); 126 126 127 - expect(source.discover([newScrobble]).length).is.eq(1); 127 + expect((await source.discover([newScrobble])).length).is.eq(1); 128 128 }); 129 129 130 - it('Transforms play candidate comparison', function() { 130 + it('Transforms play candidate comparison', async function() { 131 131 source.config.options = { 132 132 playTransform: { 133 133 compare: { ··· 146 146 const newScrobble = generatePlay({ 147 147 track: 'my hugely cool and very different track title', 148 148 }); 149 - const discovered = source.discover([newScrobble]) 149 + const discovered = await source.discover([newScrobble]) 150 150 expect(discovered.length).eq(1); 151 151 expect(discovered[0].data.track).is.eq('my hugely cool and very different track title'); 152 152 153 - expect(source.discover([newScrobble]).length).is.eq(1); 153 + expect((await source.discover([newScrobble])).length).is.eq(1); 154 154 }); 155 155 }) 156 156 ··· 200 200 const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}}); 201 201 const initialDate = dayjs(); 202 202 const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing}); 203 - expect(source.processRecentPlays([initialState]).length).to.be.eq(0); 203 + expect((await source.processRecentPlays([initialState])).length).to.be.eq(0); 204 204 205 205 let position = 0; 206 206 let timeSince = 0; ··· 212 212 MockDate.set(initialDate.add(position, 'seconds').toDate()); 213 213 await sleep(1); 214 214 const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing}); 215 - expect(source.processRecentPlays([advancedState]).length).to.be.eq(0); 215 + expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0); 216 216 } 217 217 218 218 // simulate polling another 20 seconds without any updates from the Source ··· 220 220 timeSince += 10; 221 221 MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); 222 222 await sleep(1); 223 - expect(source.processRecentPlays([]).length).to.be.eq(0); 223 + expect((await source.processRecentPlays([])).length).to.be.eq(0); 224 224 } 225 225 226 226 MockDate.set(initialDate.add(timeSince + 2, 'seconds').toDate()); 227 227 await sleep(1); 228 - const discoveredPlays = source.processRecentPlays([]); 228 + const discoveredPlays = await source.processRecentPlays([]); 229 229 // cleanup should discover stale play 230 230 expect(discoveredPlays.length).to.be.eq(1); 231 231 expect(discoveredPlays[0].data.listenedFor).closeTo(30, 2); ··· 244 244 const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}}); 245 245 const initialDate = dayjs(); 246 246 const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing}); 247 - expect(source.processRecentPlays([initialState]).length).to.be.eq(0); 247 + expect((await source.processRecentPlays([initialState])).length).to.be.eq(0); 248 248 249 249 let position = 0; 250 250 let timeSince = 0; ··· 256 256 MockDate.set(initialDate.add(position, 'seconds').toDate()); 257 257 await sleep(1); 258 258 const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing}); 259 - expect(source.processRecentPlays([advancedState]).length).to.be.eq(0); 259 + expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0); 260 260 } 261 261 262 262 // simulate polling another 20 seconds without any updates from the Source ··· 264 264 timeSince += 10; 265 265 MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); 266 266 await sleep(1); 267 - expect(source.processRecentPlays([]).length).to.be.eq(0); 267 + expect((await source.processRecentPlays([])).length).to.be.eq(0); 268 268 } 269 269 270 270 timeSince += 2; 271 271 272 272 MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); 273 273 await sleep(1); 274 - const discoveredPlays = source.processRecentPlays([]); 274 + const discoveredPlays = await source.processRecentPlays([]); 275 275 // cleanup should discover stale play 276 276 expect(discoveredPlays.length).to.be.eq(1); 277 277 expect(discoveredPlays[0].data.listenedFor).closeTo(30, 2); ··· 285 285 MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); 286 286 await sleep(1); 287 287 const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing}); 288 - expect(source.processRecentPlays([advancedState]).length).to.be.eq(0); 288 + expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0); 289 289 } 290 290 291 291 timeSince += 10; ··· 294 294 // new Play 295 295 const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing}); 296 296 // should not return play because it has only been played for ~20 seconds, less than 50% of duration 297 - const plays = source.processRecentPlays([advancedState]) 297 + const plays = await source.processRecentPlays([advancedState]) 298 298 expect(plays.length).to.be.eq(0); 299 299 } 300 300 ··· 314 314 315 315 // if player incorrectly counted stale time then 30s of actual play + 20s of stale time > scrobble threshold of 50% of 90s 316 316 const initialState = generatePlayerStateData({position: 0, playData: {duration: 90}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing}); 317 - expect(source.processRecentPlays([initialState]).length).to.be.eq(0); 317 + expect((await source.processRecentPlays([initialState])).length).to.be.eq(0); 318 318 319 319 let position = 0; 320 320 let timeSince = 0; ··· 326 326 MockDate.set(initialDate.add(position, 'seconds').toDate()); 327 327 await sleep(1); 328 328 const advancedState = generatePlayerStateData({play: initialState.play, timestamp: initialDate, position, status: REPORTED_PLAYER_STATUSES.playing}); 329 - expect(source.processRecentPlays([advancedState]).length).to.be.eq(0); 329 + expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0); 330 330 } 331 331 332 332 // simulate polling another 20 seconds without any updates from the Source ··· 334 334 timeSince += 10; 335 335 MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); 336 336 await sleep(1); 337 - expect(source.processRecentPlays([]).length).to.be.eq(0); 337 + expect((await source.processRecentPlays([])).length).to.be.eq(0); 338 338 } 339 339 340 340 MockDate.set(initialDate.add(timeSince + 2, 'seconds').toDate()); 341 - const discoveredPlays = source.processRecentPlays([]); 341 + const discoveredPlays = await source.processRecentPlays([]); 342 342 // cleanup should not discover stale play 343 343 expect(discoveredPlays.length).to.be.eq(0); 344 344 ··· 359 359 360 360 // if player incorrectly counted stale time then 30s of actual play + 20s of stale time > scrobble threshold of 50% of 90s 361 361 const initialState = generatePlayerStateData({position: 0, playData: {duration: 90}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing}); 362 - expect(source.processRecentPlays([initialState]).length).to.be.eq(0); 362 + expect((await source.processRecentPlays([initialState])).length).to.be.eq(0); 363 363 364 364 let position = 0; 365 365 let timeSince = 0; ··· 371 371 MockDate.set(initialDate.add(position, 'seconds').toDate()); 372 372 await sleep(1); 373 373 const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing}); 374 - expect(source.processRecentPlays([advancedState]).length).to.be.eq(0); 374 + expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0); 375 375 } 376 376 377 377 // simulate polling another 20 seconds without any updates from the Source ··· 379 379 timeSince += 10; 380 380 MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); 381 381 await sleep(1); 382 - expect(source.processRecentPlays([]).length).to.be.eq(0); 382 + expect((await source.processRecentPlays([])).length).to.be.eq(0); 383 383 } 384 384 385 385 timeSince += 2; 386 386 387 387 MockDate.set(initialDate.add(timeSince, 'seconds').toDate()); 388 388 await sleep(1); 389 - const discoveredPlays = source.processRecentPlays([]); 389 + const discoveredPlays = await source.processRecentPlays([]); 390 390 // cleanup should not discover stale play 391 391 expect(discoveredPlays.length).to.be.eq(0); 392 392 ··· 401 401 MockDate.set(initialDate.add(position, 'seconds').toDate()); 402 402 await sleep(1); 403 403 const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing}); 404 - expect(source.processRecentPlays([advancedState]).length).to.be.eq(0); 404 + expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0); 405 405 } 406 406 407 407 timeSince += 10; ··· 410 410 // new Play 411 411 const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing}); 412 412 // should return discovered play with ~90 seconds of duration 413 - const plays = source.processRecentPlays([advancedState]) 413 + const plays = await source.processRecentPlays([advancedState]) 414 414 expect(plays.length).to.be.eq(1); 415 415 expect(plays[0].data.duration).to.be.closeTo(90, 2); 416 416 ··· 436 436 437 437 describe('When fuzzyDiscoveryIgnore is not defined or false', function () { 438 438 439 - it('discovers fuzzy play', function() { 439 + it('discovers fuzzy play', async function() { 440 440 const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80}); 441 441 const targetPlay = normalizedPlays[normalizedPlays.length - 2] 442 442 const fuzzyPlay = clone(targetPlay); ··· 445 445 const source = generateDeezerSource(); 446 446 source.discover([...normalizedPlays, interimPlay]); 447 447 448 - const discovered = source.discover([fuzzyPlay]); 448 + const discovered = await source.discover([fuzzyPlay]); 449 449 450 450 expect(discovered.length).to.eq(1); 451 451 }); ··· 453 453 454 454 describe('When fuzzyDiscoveryIgnore is true', function () { 455 455 456 - it('does not discover fuzzy play with interim plays', function() { 456 + it('does not discover fuzzy play with interim plays', async function() { 457 457 const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80}); 458 458 const targetPlay = normalizedPlays[normalizedPlays.length - 2] 459 459 const fuzzyPlay = clone(targetPlay); 460 460 fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's'); 461 461 462 462 const source = generateDeezerSource({fuzzyDiscoveryIgnore: true}); 463 - source.discover([...normalizedPlays, interimPlay]); 463 + await source.discover([...normalizedPlays, interimPlay]); 464 464 465 - const discovered = source.discover([fuzzyPlay]); 465 + const discovered = await source.discover([fuzzyPlay]); 466 466 467 467 expect(discovered.length).to.eq(0); 468 468 }); 469 469 470 - it('discovers fuzzy play when it is the last play ', function() { 470 + it('discovers fuzzy play when it is the last play ', async function() { 471 471 const targetPlay = normalizedPlays[normalizedPlays.length - 1] 472 472 const fuzzyPlay = clone(targetPlay); 473 473 fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's'); 474 474 475 475 const source = generateDeezerSource({fuzzyDiscoveryIgnore: true}); 476 - source.discover(normalizedPlays); 476 + await source.discover(normalizedPlays); 477 477 478 - const discovered = source.discover([fuzzyPlay]); 478 + const discovered = await source.discover([fuzzyPlay]); 479 479 480 480 expect(discovered.length).to.eq(1); 481 481 }); 482 482 483 - it('discovers fuzzy play when it is played consecutively', function() { 483 + it('discovers fuzzy play when it is played consecutively', async function() { 484 484 const targetPlay = normalizedPlays[normalizedPlays.length - 1] 485 485 const fuzzyPlay = clone(targetPlay); 486 486 fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's'); 487 487 const morePlays = normalizePlays([...normalizedPlays, fuzzyPlay, ...generatePlays(2)], {initialDate: firstPlayDate}); 488 488 489 489 const source = generateDeezerSource({fuzzyDiscoveryIgnore: true}); 490 - const discovered = source.discover(morePlays); 490 + const discovered = await source.discover(morePlays); 491 491 492 492 expect(discovered.length).to.eq(morePlays.length); 493 493 }); ··· 495 495 496 496 describe('When fuzzyDiscoveryIgnore is aggressive', function () { 497 497 498 - it('does not discover fuzzy play with interim plays', function() { 498 + it('does not discover fuzzy play with interim plays', async function() { 499 499 const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80}); 500 500 const targetPlay = normalizedPlays[normalizedPlays.length - 2] 501 501 const fuzzyPlay = clone(targetPlay); 502 502 fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's'); 503 503 504 504 const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'}); 505 - source.discover([...normalizedPlays, interimPlay]); 505 + await source.discover([...normalizedPlays, interimPlay]); 506 506 507 - const discovered = source.discover([fuzzyPlay]); 507 + const discovered = await source.discover([fuzzyPlay]); 508 508 509 509 expect(discovered.length).to.eq(0); 510 510 }); 511 511 512 - it('does not discover play found during duration of previous', function() { 512 + it('does not discover play found during duration of previous', async function() { 513 513 const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80}); 514 514 const targetPlay = normalizedPlays[normalizedPlays.length - 2] 515 515 const duringPlay = clone(targetPlay); 516 516 duringPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration * 0.5, 's'); 517 517 518 518 const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'}); 519 - source.discover([...normalizedPlays, interimPlay]); 519 + await source.discover([...normalizedPlays, interimPlay]); 520 520 521 - const discovered = source.discover([duringPlay]); 521 + const discovered = await source.discover([duringPlay]); 522 522 523 523 expect(discovered.length).to.eq(0); 524 524 }); 525 525 526 - it('does not discover fuzzy play with delay of up to 40 seconds', function() { 526 + it('does not discover fuzzy play with delay of up to 40 seconds', async function() { 527 527 const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80}); 528 528 const targetPlay = normalizedPlays[normalizedPlays.length - 2] 529 529 const fuzzyPlay = clone(targetPlay); 530 530 fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration + 39, 's'); 531 531 532 532 const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'}); 533 - source.discover([...normalizedPlays, interimPlay]); 533 + await source.discover([...normalizedPlays, interimPlay]); 534 534 535 - const discovered = source.discover([fuzzyPlay]); 535 + const discovered = await source.discover([fuzzyPlay]); 536 536 537 537 expect(discovered.length).to.eq(0); 538 538 }); 539 539 540 - it('it does not discover fuzzy play when it is the last play ', function() { 540 + it('it does not discover fuzzy play when it is the last play ', async function() { 541 541 const targetPlay = normalizedPlays[normalizedPlays.length - 1] 542 542 const fuzzyPlay = clone(targetPlay); 543 543 fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's'); 544 544 545 545 const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'}); 546 - source.discover(normalizedPlays); 546 + await source.discover(normalizedPlays); 547 547 548 - const discovered = source.discover([fuzzyPlay]); 548 + const discovered = await source.discover([fuzzyPlay]); 549 549 550 550 expect(discovered.length).to.eq(0); 551 551 }); 552 552 553 - it('does not discover fuzzy play when it is played consecutively', function() { 553 + it('does not discover fuzzy play when it is played consecutively', async function() { 554 554 const targetPlay = normalizedPlays[normalizedPlays.length - 1] 555 555 const fuzzyPlay = clone(targetPlay); 556 556 fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's'); 557 557 const morePlays = normalizePlays([...normalizedPlays, fuzzyPlay, ...generatePlays(2)], {initialDate: firstPlayDate}); 558 558 559 559 const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'}); 560 - const discovered = source.discover(morePlays); 560 + const discovered = await source.discover(morePlays); 561 561 562 562 expect(discovered.length).to.eq(morePlays.length - 1); 563 563 });
-35
src/backend/tests/utils/strings.test.ts
··· 1 1 import { assert, expect } from 'chai'; 2 2 import { describe, it } from 'mocha'; 3 - import { intersect } from "../../utils.js"; 4 3 import { 5 4 compareNormalizedStrings, 6 5 normalizeStr, 7 - parseTrackCredits, 8 - uniqueNormalizedStrArr 9 6 } from "../../utils/StringUtils.js"; 10 - import { ExpectedResults } from "./interfaces.js"; 11 - import testData from './playTestData.json' with { type: "json" }; 12 7 import { splitByFirstFound } from '../../../core/StringUtils.js'; 13 - 14 - interface PlayTestFixture { 15 - caseHints: string[] 16 - data: { 17 - track: string 18 - artists: string[] 19 - album?: string 20 - } 21 - expected: ExpectedResults 22 - } 23 8 24 9 describe('String Comparisons', function () { 25 10 ··· 126 111 assert.equal( result1.highScore, result2.highScore, `Comparing: '${longerString}' | '${shorterString}'`); 127 112 }); 128 113 }); 129 - 130 - describe('Play Strings',function () { 131 - 132 - const testFixtures = testData as unknown as PlayTestFixture[]; 133 - const joinerData = testFixtures.filter(x => intersect(['joiner','track'], x.caseHints).length === 2); 134 - 135 - it('should parse joiners from track title', function() { 136 - for(const test of joinerData) { 137 - const res = parseTrackCredits(test.data.track); 138 - let artists: string[] = [...test.data.artists]; 139 - if(res.secondary !== undefined) { 140 - artists = uniqueNormalizedStrArr([...artists, ...res.secondary]); 141 - } 142 - assert.equal(res.primaryComposite, test.expected.track); 143 - assert.sameDeepMembers(artists, test.expected.artists); 144 - } 145 - }); 146 - }); 147 - 148 - 149 114 150 115 describe('String Splitting', function() { 151 116
+2 -2
src/backend/tests/ytm/ytm.test.ts
··· 174 174 expect(source.parseRecentAgainstResponse(plays).plays).length(20); 175 175 176 176 source.polling = true; 177 - source.discover(plays); 177 + await source.discover(plays); 178 178 179 179 // first true poll emulating no new tracks played (should not add new tracks from base truth) 180 180 expect(source.parseRecentAgainstResponse(plays).plays).length(0); ··· 201 201 expect(source.parseRecentAgainstResponse(plays).plays).length(20); 202 202 203 203 source.polling = true; 204 - source.discover(plays); 204 + await source.discover(plays); 205 205 206 206 // first true poll emulating no new tracks played (should not add new tracks from base truth) 207 207 expect(source.parseRecentAgainstResponse(plays).plays).length(0);
+45
src/backend/utils/AsyncUtils.ts
··· 1 + /** https://stackoverflow.com/a/63795192/1469797 */ 2 + export async function findAsyncSequential<T>( 3 + array: T[], 4 + predicate: (t: T) => Promise<boolean>, 5 + ): Promise<T | undefined> { 6 + const i = await findIndexAsyncSequential(array, predicate); 7 + if(i === undefined) { 8 + return undefined; 9 + } 10 + return array[i]; 11 + } 12 + 13 + export async function findIndexAsyncSequential<T>( 14 + array: T[], 15 + predicate: (t: T) => Promise<boolean>, 16 + ): Promise<number | undefined> { 17 + let index = 0; 18 + for (const t of array) { 19 + if (await predicate(t)) { 20 + return index; 21 + } 22 + index++; 23 + } 24 + return undefined; 25 + } 26 + 27 + /** https://stackoverflow.com/a/55601090/1469797 */ 28 + export async function findAsync<T>( 29 + array: T[], 30 + predicate: (t: T) => Promise<boolean>): Promise<T | undefined> { 31 + const i = await findIndexAsync(array, predicate); 32 + if(i === undefined) { 33 + return undefined; 34 + } 35 + return array[i]; 36 + } 37 + 38 + export async function findIndexAsync<T>( 39 + array: T[], 40 + predicate: (t: T) => Promise<boolean>): Promise<number | undefined> { 41 + const promises = array.map(predicate); 42 + const results = await Promise.all(promises); 43 + const index = results.findIndex(result => result); 44 + return index; 45 + }
+9 -62
src/backend/utils/PlayTransformUtils.ts
··· 13 13 PlayTransformRules, PlayTransformStage, PlayTransformUserParts, PlayTransformUserStage, SearchAndReplaceTerm, 14 14 STAGE_TYPES, 15 15 StageType, 16 + StageTypedConfig, 16 17 WhenConditionsConfig, 17 18 WhenParts 18 19 } from "../common/infrastructure/Transform.js"; ··· 91 92 throw new Error(`Value is type of ${tf} but must be one of: boolean, undefined, or object with 'when'`); 92 93 } 93 94 95 + export const isStageTyped = (val: unknown): val is StageTypedConfig => { 96 + if(typeof val !== 'object' || val === null) { 97 + return false; 98 + } 99 + return 'type' in val; 100 + } 101 + 94 102 export const isPlayTransformStage = (val: object | Partial<PlayTransformStage<SearchAndReplaceTerm[]>>): val is PlayTransformStage<SearchAndReplaceTerm[]> => { 95 103 if (!('type' in val)) { 96 104 throw new Error(`Stage is missing 'type'. Must be one of: ${STAGE_TYPES.join(', ')}`); ··· 125 133 return true; 126 134 } 127 135 128 - export const isUserStage = <T>(val: PlayTransformStage<T>): val is PlayTransformUserStage<T> => { 136 + export const isUserStage = <T>(val: StageTypedConfig): val is StageTypedConfig => { 129 137 return val.type === 'user'; 130 - } 131 - 132 - export const configPartsToStrongParts = (val: PlayTransformPartsConfig<SearchAndReplaceTerm[] | ExternalMetadataTerm> | undefined): PlayTransformPartsArray<ConditionalSearchAndReplaceRegExp[] | ExternalMetadataTerm> => { 133 - if (val === undefined) { 134 - return [] 135 - } 136 - const arr = Array.isArray(val) ? val : [val]; 137 - 138 - return arr.map((x) => { 139 - const { 140 - title: titleConfig, 141 - artists: artistConfig, 142 - album: albumConfig, 143 - when: whenConfig, 144 - type = 'user', 145 - ...rest 146 - } = x; 147 - 148 - let stage: PlayTransformStage<SearchAndReplaceTerm[]>; 149 - try { 150 - const candidateStage = {...x, type}; 151 - if(isPlayTransformStage(candidateStage)) { 152 - stage = candidateStage; 153 - } 154 - } catch (e) { 155 - throw e; 156 - } 157 - 158 - if (whenConfig !== undefined) { 159 - if (!isWhenConditionConfig(whenConfig)) { 160 - throw new Error(`'when' must be an array of artist/title/album objects and each object's property must be a string`); 161 - } 162 - } 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 - 181 - return { 182 - title, 183 - artists, 184 - album, 185 - when, 186 - type, 187 - ...rest 188 - } 189 - }); 190 - 191 138 } 192 139 193 140 export const testWhen = (parts: WhenParts<string>, play: PlayObject, options?: SuppliedRegex): boolean => {
+10 -1
src/core/Atomic.ts
··· 389 389 export const JOINERS_FINAL: FinalJoiners[] = ['&']; 390 390 391 391 export type Feat = 'ft' | 'feat' | 'vs' | 'ft.' | 'feat.' | 'vs.' | 'featuring' 392 - export const FEAT: Feat[] = ['ft','feat','vs','ft.','feat.','vs.','featuring']; 392 + export const FEAT: Feat[] = ['ft','feat','vs','ft.','feat.','vs.','featuring']; 393 + export interface TransformerCommonConfig { 394 + data?: Record<string, any>; 395 + type: string; 396 + name?: string; 397 + options?: { 398 + failOnFetch?: boolean; 399 + throwOnFailure?: boolean | ('artists' | 'title' | 'albumArtists' | 'album')[]; 400 + }; 401 + }