[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(transform): Implement abstract transformer

#365 #363 #364

FoxxMD (Nov 28, 2025, 11:17 PM UTC) 1af01936 53e76631

+147 -7
+1 -1
src/backend/common/AbstractInitializable.ts
··· 33 33 34 34 initializing: boolean = false; 35 35 36 - config: CommonClientConfig | CommonSourceConfig; 36 + config: Record<string, any>; 37 37 38 38 logger: Logger; 39 39 componentLogger?: Logger;
+7 -1
src/backend/common/Cache.ts
··· 12 12 import { childLogger, Logger } from '@foxxmd/logging'; 13 13 import { projectDir } from './index.js'; 14 14 import path from 'path'; 15 + import { cacheFunctions } from "@foxxmd/regex-buddy-core"; 15 16 import { fileOrDirectoryIsWriteable } from '../utils.js'; 16 17 import { asCacheAuthProvider, asCacheMetadataProvider, asCacheScrobbleProvider, CacheAuthProvider, CacheConfig, CacheConfigOptions, CacheMetadataProvider, CacheProvider, CacheScrobbleProvider } from './infrastructure/Atomic.js'; 17 18 import { Typeson } from 'typeson'; ··· 47 48 cacheMetadata: Cacheable; 48 49 cacheScrobble: Cacheable; 49 50 cacheAuth: Cacheable; 51 + regexCache: ReturnType<typeof cacheFunctions>; 50 52 51 53 logger: Logger; 52 54 ··· 69 71 connection: aConn = (process.env.CACHE_AUTH_CONN ?? configDir), 70 72 ...restAuth 71 73 } = {}, 74 + regex = 200, 72 75 } = config; 73 76 74 77 this.config = { ··· 86 89 provider: aProvider, 87 90 connection: aConn, 88 91 ...restAuth 89 - } 92 + }, 93 + regex 90 94 }; 95 + 96 + this.regexCache = cacheFunctions(this.config.regex); 91 97 } 92 98 93 99 init = async () => {
+5
src/backend/common/infrastructure/Atomic.ts
··· 327 327 metadata?: CacheMetadataConfig; 328 328 scrobble?: CacheScrobbleConfig; 329 329 auth?: CacheAuthConfig; 330 + /** Number of regex functions to cache (LRU) 331 + * 332 + * @default 200 333 + */ 334 + regex?: number 330 335 } 331 336
+128 -1
src/backend/common/transforms/AbstractTransformer.ts
··· 1 + import { ObjectPlayData, PlayObject, TrackMeta } from "../../../core/Atomic.js"; 2 + import { getRoot } from "../../ioc.js"; 3 + import { testWhenConditions } from "../../utils/PlayTransformUtils.js"; 1 4 import AbstractInitializable from "../AbstractInitializable.js"; 5 + import { ConditionalSearchAndReplaceRegExp, PlayTransformParts } from "../infrastructure/Transform.js"; 6 + import { cacheFunctions } from "@foxxmd/regex-buddy-core"; 2 7 8 + export interface TransformerCommonConfig { 9 + data?: Record<string, any> 10 + options?: { 11 + failOnFetch?: boolean 12 + throwOnFailure?: boolean | ('artists' | 'title' | 'albumArtists' | 'album')[] 13 + } 14 + } 15 + export default abstract class AbstractTransformer<T = any> extends AbstractInitializable { 16 + 17 + declare config: TransformerCommonConfig; 18 + 19 + regexCache: ReturnType<typeof cacheFunctions> 3 20 4 - export default abstract class AbstractTransformer extends AbstractInitializable { 21 + protected constructor(config: TransformerCommonConfig) { 22 + super(config); 23 + this.regexCache = getRoot().items.cache().regexCache; 24 + } 25 + 26 + public async handle(parts: PlayTransformParts<ConditionalSearchAndReplaceRegExp>, play: PlayObject): Promise<PlayObject> { 27 + 28 + if (parts.when !== undefined) { 29 + if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regexCache.testMaybeRegex })) { 30 + this.logger.debug('When condition not met, returning original Play'); 31 + return play; 32 + } 33 + } 34 + 35 + const { 36 + failOnFetch = false, 37 + throwOnFailure = false, 38 + } = this.config.options || {}; 39 + 40 + let transformData: T; 41 + try { 42 + transformData = await this.getTransformerData(play); 43 + } catch (e) { 44 + if (failOnFetch) { 45 + throw new Error(`Could not fetch transformer data`, { cause: e }); 46 + } 47 + this.logger.warn(new Error(`Could not fetch transformer data, returning original Play`, { cause: e })); 48 + return play; 49 + } 50 + 51 + try { 52 + await this.checkShouldTransform(play, transformData); 53 + } catch (e) { 54 + this.logger.debug(new Error('checkShouldTransform did not pass, returning original Play', { cause: e })); 55 + return play; 56 + } 57 + 58 + const transformedPlayData: Partial<ObjectPlayData> = {}; 59 + 60 + try { 61 + const title = await this.handleTitle(play, transformData); 62 + transformedPlayData.track = title; 63 + } catch (e) { 64 + const err = new Error(`Failed to transform title: ${play.data.track}`, { cause: e }); 65 + if (throwOnFailure === true || (throwOnFailure !== false && throwOnFailure.includes('title'))) { 66 + throw err; 67 + } else { 68 + this.logger.warn(err); 69 + } 70 + } 5 71 72 + try { 73 + const artists = await this.handleArtists(play, transformData); 74 + transformedPlayData.artists = artists; 75 + } catch (e) { 76 + const err = new Error(`Failed to transform artists`, { cause: e }); 77 + if (throwOnFailure === true || (throwOnFailure !== false && throwOnFailure.includes('artists'))) { 78 + throw err; 79 + } else { 80 + this.logger.warn(err); 81 + } 82 + } 83 + 84 + try { 85 + const albumArtists = await this.handleArtists(play, transformData); 86 + transformedPlayData.albumArtists = albumArtists; 87 + } catch (e) { 88 + const err = new Error(`Failed to transform album artists`, { cause: e }); 89 + if (throwOnFailure === true || (throwOnFailure !== false && throwOnFailure.includes('albumArtists'))) { 90 + throw err; 91 + } else { 92 + this.logger.warn(err); 93 + } 94 + } 95 + 96 + try { 97 + const album = await this.handleTitle(play, transformData); 98 + transformedPlayData.album = album; 99 + } catch (e) { 100 + const err = new Error(`Failed to transform album: ${play.data.album}`, { cause: e }); 101 + if (throwOnFailure === true || (throwOnFailure !== false && throwOnFailure.includes('album'))) { 102 + throw err; 103 + } else { 104 + this.logger.warn(err); 105 + } 106 + } 107 + 108 + const transformedPlay = { 109 + ...play, 110 + data: { 111 + ...play.data, 112 + ...transformedPlayData 113 + } 114 + } 115 + 116 + return transformedPlay; 117 + } 118 + 119 + public abstract getTransformerData(play: PlayObject): Promise<T>; 120 + 121 + public async checkShouldTransform(play: PlayObject, transformData: T): Promise<void> { 122 + return; 123 + } 124 + 125 + protected abstract handleTitle(play: PlayObject, transformData: T): Promise<string>; 126 + protected abstract handleArtists(play: PlayObject, transformData: T): Promise<string[]>; 127 + protected abstract handleAlbumArtists(play: PlayObject, transformData: T): Promise<string[]>; 128 + protected abstract handleAlbum(play: PlayObject, transformData: T): Promise<string | undefined>; 129 + 130 + protected async handleMeta(play: PlayObject, transformData: T): Promise<TrackMeta | undefined> { 131 + return play.data.meta; 132 + } 6 133 }
+6 -4
src/core/Atomic.ts
··· 136 136 track?: string 137 137 } 138 138 139 + export interface TrackMeta { 140 + brainz?: BrainzMeta 141 + spotify?: SpotifyMeta 142 + } 143 + 139 144 export interface TrackData { 140 145 artists?: string[] 141 146 albumArtists?: string[] ··· 146 151 * */ 147 152 duration?: number 148 153 149 - meta?: { 150 - brainz?: BrainzMeta 151 - spotify?: SpotifyMeta 152 - } 154 + meta?: TrackMeta 153 155 } 154 156 155 157 export interface PlayData extends TrackData {