feat(transformers): Implement applyng limited transform configs via ENV

This commit is contained in:
FoxxMD
2025-12-08 19:02:54 +00:00
parent 023bd74f12
commit 17baebdbf2
7 changed files with 312 additions and 29 deletions
@@ -108,6 +108,66 @@ Each [Rule](/configuration/transforms#stage-rules) should be either a boolean, s
}
```
## ENV Configuration
The general configuration shown above can also be configured from a selection of *presets* using [ENV Config](/configuration?configType=env#configuration-types) for individual Sources/Clients.
You must **always** include the `MB_CONTACT` ENV to [identify your application](#api-setup):
```ini
MB_CONTACT=contact@mydomain.com
```
To configure [stage defaults](http://localhost:3000/docs/configuration/transforms/musicbrainz/#stage-configuration) use `MB_PRESETS` with a comma-delimited list of presets you wish to apply. More than one preset can be applied, in which case they combine. You must choose at least one of the presets below:
* `default` - Applies no defaults. This is the same as using no options from [Matching With Musicbrainz](#matching-with-musicbrainz)
* `sensible` - Applies the [Sensible Default](#sensible-default) configuration for sorting releases
* `native` - Applies `fallbackArtistSearch: native` for [Artist Extraction](#artist-extraction) if the first search fails to find matches
* `aggressive` - Applies [Free Text Search](#free-text) `fallbackFreeTextSearch: true` if all other searches fail
Finally, use ENV `*_TRANSFORMS=musicbrainz` on each Source/Client you wish to apply this stage to. This applies the stage in the [`preTransform` Hook](#/configuration/transforms#lifecycle-hooks) with all [Rules](#rules) enabled.
The `*` stands for the prefix used for each Source/Client's ENV keys. Refer to the individual Source/Client Configuration sections to find this.
:::tip
Enhancing a scrobble with the `preTransform` hook for a Source means that all Clients that recieve the scrobble get the enhanced version. You only need to apply the transform once, for a Source, to use it everywhere for downstream Clients.
:::
<details>
<summary>Example Full Docker Deploy with ENV Configuration</summary>
Using [Jellyfin + Maloja](/quickstart#create-docker-compose-file) example from Quickstart:
```yaml
services:
multi-scrobbler:
image: foxxmd/multi-scrobbler
container_name: multi-scrobbler
environment:
- MB_CONTACT=contact@mydomain.com
- MB_PRESETS=sensible,native # creates sensible defaults with native fallback searching
- JELLYFIN_URL=192.168.0.110:8096
- JELLYFIN_APIKEY=c9fae8756fbf481ebd9c5bb56b
- JELLYFIN_USER=MyUser
- JELLYFIN_TRANSFORMS=musicbrainz # applies musicbrainz Stage to preTransform of Jellyfin source
- MALOJA_URL=http://192.168.0.100:42010 # maloja receives enhanced scrobble from Jellyfin
- MALOJA_API_KEY=myApiKey
volumes:
- "./config:/config"
ports:
- "9078:9078"
restart: unless-stopped
```
</details>
## Matching with Musicbrainz
:::note
@@ -73,6 +73,50 @@ Each [Rule](/configuration/transforms#stage-rules) should be either a boolean, s
If a rule is not present then multi-scrobbler defaults it to `true`.
## ENV Configuration
The default configuration shown above can also be applied using [ENV Config](/configuration?configType=env#configuration-types) for individual Sources/Clients.
Use ENV `*_TRANSFORMS=native` on each Source/Client you wish to apply this stage to. This applies the stage in the [`preTransform` Hook](#/configuration/transforms#lifecycle-hooks) with all [Rules](#rules) enabled.
The `*` stands for the prefix used for each Source/Client's ENV keys. Refer to the individual Source/Client Configuration sections to find this.
:::tip
Enhancing a scrobble with the `preTransform` hook for a Source means that all Clients that recieve the scrobble get the enhanced version. You only need to apply the transform once, for a Source, to use it everywhere for downstream Clients.
:::
<details>
<summary>Example Full Docker Deploy with ENV Configuration</summary>
Using [Jellyfin + Maloja](/quickstart#create-docker-compose-file) example from Quickstart:
```yaml
services:
multi-scrobbler:
image: foxxmd/multi-scrobbler
container_name: multi-scrobbler
environment:
- JELLYFIN_URL=192.168.0.110:8096
- JELLYFIN_APIKEY=c9fae8756fbf481ebd9c5bb56b
- JELLYFIN_USER=MyUser
- JELLYFIN_TRANSFORMS=native # applies native Stage to preTransform of Jellyfin source
- MALOJA_URL=http://192.168.0.100:42010 # maloja receives enhanced scrobble from Jellyfin
- MALOJA_API_KEY=myApiKey
volumes:
- "./config:/config"
ports:
- "9078:9078"
restart: unless-stopped
```
</details>
## Examples
### Parse only artists string using a custom delimiter
@@ -642,4 +642,73 @@ export const rankReleasesByPriority = (list: IRecordingMatch[], stageConfig: Mus
rec.releases.sort((a, b) => b.rankedScore - a.rankedScore);
}
return rankedList;
};
};
export const DEFAULTS_SENSIBLE = {
// use official release over anything else
"releaseStatusPriority": ["official"],
// prefer album, then single, then ep
"releaseGroupPrimaryTypePriority": ["album", "single", "ep"],
// prefer worldwide release
"releaseCountryPriority": ["XW"]
}
export const DEFAULTS_NATIVE: {fallbackArtistSearch: "native"} = {
"fallbackArtistSearch": "native"
}
export const DEFAULTS_AGGRESSIVE = {
"fallbackFreeText": true
}
export const configFromEnv = (logger: MaybeLogger = new MaybeLogger()) => {
const mbEnv = process.env.MB_PRESETS;
const mbContact = process.env.MB_CONTACT;
let mbConfig: MusicbrainzTransformerConfig;
if (mbEnv !== undefined && mbEnv.trim() !== '') {
if (mbContact === undefined || mbContact.trim() === '') {
throw new SimpleError('Must provide a contact url/email for musicbrainz ENV present!');
}
mbConfig = {
type: 'musicbrainz',
name: 'MSDefault',
data: {
apis: [
{
contact: mbEnv
}
]
},
defaults: {
}
}
const presets = mbEnv.split(',').map(x => x.trim().toLocaleLowerCase());
for (const p of presets) {
switch (p) {
case 'default':
break;
case 'sensible':
mbConfig.defaults = {
...mbConfig.defaults,
...DEFAULTS_SENSIBLE
}
break;
case 'native':
mbConfig.defaults = {
...mbConfig.defaults,
...DEFAULTS_NATIVE
}
break;
case 'aggressive':
mbConfig.defaults = {
...mbConfig.defaults,
...DEFAULTS_AGGRESSIVE
}
break;
}
}
logger.debug(`Using presets: ${presets.join(',')}`);
}
return mbConfig;
}
@@ -7,9 +7,11 @@ import { PlayObject } from "../../../core/Atomic.js";
import { isStageTyped } from "../../utils/PlayTransformUtils.js";
import { MSCache } from "../Cache.js";
import NativeTransformer from "./NativeTransformer.js";
import MusicbrainzTransformer, { MusicbrainzTransformerConfig } from "./MusicbrainzTransformer.js";
import MusicbrainzTransformer, { configFromEnv, MusicbrainzTransformerConfig } from "./MusicbrainzTransformer.js";
import { AsyncLocalStorage } from 'node:async_hooks';
import { nanoid } from "nanoid";
import { SimpleError } from "../errors/MSErrors.js";
import { toHaveStyle } from "@testing-library/jest-dom/matchers.js";
export default class TransformerManager {
@@ -61,6 +63,27 @@ export default class TransformerManager {
this.logger.verbose(`${config.type} transformer with name '${tName}' registered`);
}
public async registeryDefaults() {
if(!this.hasTransformerType('user')) {
this.register({type: 'user', name: 'MSDefault'});
}
if(!this.hasTransformerType('native')) {
this.register({type: 'native', name: 'MSDefault'});
}
}
public async registerFromEnv() {
try {
const mbConfig = configFromEnv(this.logger);
this.register(mbConfig);
} catch (e) {
if(e instanceof SimpleError) {
this.logger.error(`Unable to build Musicbrainz Transformer from ENV: ${e.message}`);
}
this.logger.error(new Error('Unable to build Musicbrainz Transformer from ENV', {cause: e}));
}
}
public async initTransformers() {
this.logger.verbose('Initializing transformers...');
for (const list of this.transformers.values()) {
+2
View File
@@ -119,6 +119,8 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
const notifiers = new Notifiers(root.get('notifierEmitter'), root.get('clientEmitter'), root.get('sourceEmitter'), root.get('logger')); //root.get('notifiers');
await notifiers.buildWebhooks(webhooks);
await root.items.transformerManager.registerFromEnv();
await root.items.transformerManager.registeryDefaults();
await root.items.transformerManager.initTransformers();
/*
+43 -6
View File
@@ -25,6 +25,8 @@ import TealScrobbler from './TealfmScrobbler.js';
import { TealClientConfig } from '../common/infrastructure/config/client/tealfm.js';
import RockskyScrobbler from './RockskyScrobbler.js';
import { RockSkyClientConfig } from '../common/infrastructure/config/client/rocksky.js';
import { CommonClientOptions } from '../common/infrastructure/config/client/index.js';
import { ExternalMetadataTerm, PlayTransformHooks } from '../common/infrastructure/Transform.js';
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
@@ -192,7 +194,8 @@ export default class ScrobbleClients {
data: {
url,
apiKey
}
},
options: transformPresetEnv('MALOJA')
})
}
break;
@@ -210,7 +213,8 @@ export default class ScrobbleClients {
source: 'ENV',
mode: 'single',
configureAs: 'client',
data: {...lfm, redirectUri: lfm.redirectUri ?? joinedUrl(this.localUrl, 'lastfm/callback').toString()}
data: {...lfm, redirectUri: lfm.redirectUri ?? joinedUrl(this.localUrl, 'lastfm/callback').toString()},
options: transformPresetEnv('LASTFM')
})
}
break;
@@ -227,7 +231,8 @@ export default class ScrobbleClients {
source: 'ENV',
mode: 'single',
configureAs: 'client',
data: lz
data: lz,
options: transformPresetEnv('LZ')
})
}
break;
@@ -244,7 +249,8 @@ export default class ScrobbleClients {
source: 'ENV',
mode: 'single',
configureAs: 'client',
data: koit
data: koit,
options: transformPresetEnv('KOITO')
})
}
break;
@@ -261,7 +267,8 @@ export default class ScrobbleClients {
source: 'ENV',
mode: 'single',
configureAs: 'client',
data: teal
data: teal,
options: transformPresetEnv('TEALFM')
})
}
break;
@@ -277,7 +284,8 @@ export default class ScrobbleClients {
source: 'ENV',
mode: 'single',
configureAs: 'client',
data: rocksky
data: rocksky,
options: transformPresetEnv('ROCKSKY')
})
}
break;
@@ -468,3 +476,32 @@ ${sources.join('\n')}`);
}
}
}
const transformPresetEnv = <T extends CommonClientOptions = CommonClientOptions>(prefix: string, existing: T = undefined): undefined | T => {
const env = process.env[`${prefix}_TRANSFORMS`];
if(env === undefined || env.trim() === '') {
return existing;
}
const popts: PlayTransformHooks<ExternalMetadataTerm> = {
preCompare: [
]
}
for(const p of env.split(',').map(x => x.trim().toLocaleLowerCase())) {
switch(p) {
case 'native':
popts.preCompare.push({type: 'native'});
break;
case 'musicbrainz':
popts.preCompare.push({type: 'musicbrainz'});
break;
}
}
// @ts-ignore
return {
...(existing || {}),
playTransform: popts
};
}
+69 -21
View File
@@ -20,7 +20,7 @@ import { LastfmSourceConfig } from "../common/infrastructure/config/source/lastf
import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz.js";
import { MopidySourceConfig } from "../common/infrastructure/config/source/mopidy.js";
import { MusicCastData, MusicCastSourceConfig } from "../common/infrastructure/config/source/musiccast.js";
import { IcecastData, IcecastSourceConfig } from "../common/infrastructure/config/source/icecast.js";
import { IcecastData, IcecastSourceConfig, IcecastSourceOptions } from "../common/infrastructure/config/source/icecast.js";
import { MPDSourceConfig } from "../common/infrastructure/config/source/mpd.js";
import { MPRISData, MPRISSourceConfig } from "../common/infrastructure/config/source/mpris.js";
import { MusikcubeData, MusikcubeSourceConfig } from "../common/infrastructure/config/source/musikcube.js";
@@ -73,6 +73,8 @@ import TealfmSource from './TealfmSource.js';
import { TealSourceConfig } from '../common/infrastructure/config/source/tealfm.js';
import { RockskySourceConfig } from '../common/infrastructure/config/source/rocksky.js';
import RockskySource from './RockskySource.js';
import { CommonSourceOptions } from '../common/infrastructure/config/source/index.js';
import { ExternalMetadataTerm, PlayTransformHooks, PlayTransformOptions } from '../common/infrastructure/Transform.js';
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
@@ -339,7 +341,8 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: s as SpotifySourceData
data: s as SpotifySourceData,
options: transformPresetEnv('SPOTIFY')
})
}
break;
@@ -358,7 +361,7 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: t
data: t,
})
}
break;
@@ -381,7 +384,8 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: p
data: p,
options: transformPresetEnv('PLEX')
})
}
break;
@@ -398,7 +402,8 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: sub as SubsonicData
data: sub as SubsonicData,
options: transformPresetEnv('SUBSONIC')
})
}
break;
@@ -425,7 +430,8 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: j
data: j,
options: transformPresetEnv('JELLYFIN')
})
}
break;
@@ -448,7 +454,8 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: d as DeezerData
data: d as DeezerData,
options: transformPresetEnv('DEEZER')
});
}
break;
@@ -465,7 +472,8 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: mp as MPRISData
data: mp as MPRISData,
options: transformPresetEnv('MPRIS')
});
}
break;
@@ -486,7 +494,8 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: lze as ListenbrainzEndpointData
data: lze as ListenbrainzEndpointData,
options: transformPresetEnv('LZE')
});
}
break;
@@ -502,7 +511,8 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: lfme as LastFMEndpointData
data: lfme as LastFMEndpointData,
options: transformPresetEnv('LFM')
});
}
break;
@@ -519,9 +529,9 @@ export default class ScrobbleSources {
mode: 'single',
configureAs: defaultConfigureAs,
data: icecast as IcecastData,
options: {
options: transformPresetEnv<IcecastSourceOptions>('ICECAST', {
systemScrobble: parseBool(icecast.scrobbleOnStart)
}
})
});
}
break;
@@ -538,7 +548,8 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: jr as JRiverData
data: jr as JRiverData,
options: transformPresetEnv('JRIVER')
});
}
break;
@@ -555,7 +566,8 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: ko as KodiData
data: ko as KodiData,
options: transformPresetEnv('KODI')
});
}
break;
@@ -575,7 +587,8 @@ export default class ScrobbleSources {
data: {
blacklist: ws.blacklist !== undefined ? ws.blacklist.split(',') : [],
whitelist: ws.whitelist !== undefined ? ws.whitelist.split(',') : [],
}
},
options: transformPresetEnv('WS')
});
}
break;
@@ -599,7 +612,8 @@ export default class ScrobbleSources {
whitelistDevices: cc.whitelistDevices !== undefined ? cc.whitelistDevices.split(',') : [],
blacklistApps: cc.blacklistApps !== undefined ? cc.blacklistApps.split(',') : [],
whitelistApps: cc.whitelistApps !== undefined ? cc.whitelistApps.split(',') : [],
}
},
options: transformPresetEnv('CC')
});
}
break;
@@ -614,7 +628,8 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: musecase as MusicCastData
data: musecase as MusicCastData,
options: transformPresetEnv('MCAST')
});
}
break;
@@ -630,7 +645,8 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: mc as MusikcubeData
data: mc as MusikcubeData,
options: transformPresetEnv('MC')
});
}
break;
@@ -646,7 +662,8 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: vlc as VLCData
data: vlc as VLCData,
options: transformPresetEnv('VLC')
});
}
break;
@@ -664,7 +681,8 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: ytm as YTMusicData
data: ytm as YTMusicData,
options: transformPresetEnv('YTM')
});
}
break;
@@ -683,7 +701,8 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: azura as unknown as AzuracastData
data: azura as unknown as AzuracastData,
options: transformPresetEnv('AZURA')
});
}
break;
@@ -911,3 +930,32 @@ export default class ScrobbleSources {
newSource.logger.info(`Source Added from ${source}`);
}
}
const transformPresetEnv = <T extends CommonSourceOptions = CommonSourceOptions>(prefix: string, existing: T = undefined): undefined | T => {
const env = process.env[`${prefix}_TRANSFORMS`];
if(env === undefined || env.trim() === '') {
return existing;
}
const popts: PlayTransformHooks<ExternalMetadataTerm> = {
preCompare: [
]
}
for(const p of env.split(',').map(x => x.trim().toLocaleLowerCase())) {
switch(p) {
case 'native':
popts.preCompare.push({type: 'native'});
break;
case 'musicbrainz':
popts.preCompare.push({type: 'musicbrainz'});
break;
}
}
// @ts-ignore
return {
...(existing || {}),
playTransform: popts
};
}