Skip to content

Enhancing Scrobbles

Multi-scrobbler configs support the ability to enhance scrobble data in an automated fashion by matching and replacing strings in title, artists, and album at many different times in multi-scrobbler’s lifecycle.

Why Would I Do This?

You may need to “clean up” data from a Source or before sending to a scrobble Client due to any number of reasons:

  • ID3 tags in your music collection are dirty or have repeating garbage IE [YourMusicSource.com] My Artist - My Title
  • A Source’s service often incorrectly adds data to some field IE My Artist - My Title (Album Version) when the title should just be My Title
  • An Artist you listen to often is spelled different between a Source and a Client which causes duplicate scrobbles

In any scenario where a repeating pattern can be found in the data it would be nice to be able to fix it before the data gets downstream or to help prevent duplicate scrobbling. Multi-scrobbler can help you do this.

First, let’s recap the lifecycle of a scrobble in multi-scrobbler:

Sources are the beginning of the journey for a Play (song you’ve listened to long enough to be scrobblable)

  • A Source finds a new valid Play
  • The Source compares this new Play to all the other Plays it has already seen, if the Play is unique (title/artist/album/listened datetime) then…
  • The Source discovers the Play, adds it to Plays it has seen already, and broadcasts the Play should be scrobbled to all Clients

Scrobble Clients listen for discovered Plays from Sources, then…

  • A Client receives a Play from a Source
  • The Client compares this Play to all the other scrobbles it has already seen, if the Play is unique (title/artist/album/listened datetime) then…
  • The Client scrobbles the Play downstream to the scrobble service and adds it as a Scrobble it has seen already

You’ll notice there is a pattern above that looks like this:

  • Before data is compared
  • Data is compared
  • After data is compared

These points, during both Source and Client processes, are when you can hook into the scrobble lifecycle and modify it.

In more concrete terms this is the structure of hooks within a configuration (can be used in any Source or Client):

lastfm.json
[
{
"name": "myLastFm",
"enable": true,
"configureAs": "source",
"data": {
// ...
},
"options": {
"playTransform": {
"preCompare": [/* ... */],
"compare": [/* ... */],
"postCompare": [/* ... */]
}
}
}
]

For Sources:

  • preCompare - modify Play data immediately when received
  • compare - temporarily modify Play data when it is being compared to see if Play was already discovered
  • postCompare - modify Play data before sending to scrobble Clients

For Clients:

  • preCompare - modify Play data immediately when received
  • compare - temporarily modify Play data when it is being compared to see if it was already scrobbled
  • postCompare - modify Play data before scrobbling it to downstream service and adding to already seen scrobbles
Transformed Scrobbles Are Permanently Modified

Keep in mind that modifying Scrobble/Play data earlier in the lifecycle will affect that data at all times later in the lifecycle (except when using the compare hook).

For example, to modify the track so it’s the same anywhere it is processed in multi-scrobbler you only need to modify it in the Source’s preCompare hook because all later processes will receive the data with the modified track.

Using compare hook

The compare hook is slightly different than preCompare and postCompare. It consists of an object where you define which side(s) of the comparison should be modified. It also does not modify downstream data! Instead, the modifications are made only for use in the comparison.

lastfm.json
[
{
"name": "myLastFm",
// ...
"options": {
"playTransform": {
"compare": [
{
modify the "new" Play being compared
"candidate": {/* ... */},
modify all "existing" Play/Scrobbles the new Play is being compared against
"existing": {/* ... */},
}
],
}
}
}
]

Each hook is made up of one or more Stages. A Stage is a self-contained, unique way of enhancing or modifying the Play data. Some examples of a Stage:

  • The User Stage allows a user to define search-and-replace terms for Artist/Title/Album
  • The Native Stage uses MS’s built-in heuristics to extract Artists from a single Artist string
  • The Musicbrainz Stage tries to match Play data with the Musicbrainz database and to standardize the Artist/Title/Album data
  • The Rocksky Stage tries to match Play data with the Rocksky metadata API to enrich meta IDs (like MBID, ISRC), duration, and artwork
  • The Spotify Stage tries to match Play data with the Spotify catalog (prioritizing ISRC lookups) to standardize the Artist/Title/Album data
  • The Cover Art Archive Stage adds album art to your scrobble based on Musicbrainz Relase/Release-Group MBID

Each Stage in a Hook receives Play data from the previous Stage.

Within a hook, each Stage minimally consists of a type to identify what Stage it is along with any other data specific to that stage:

{
"type": "native"
// optional, stage specific data here...
}

Stages may be globally configured using AIO Config config.json file in the top-level transformers block.

Each Stage consists of:

  • type the type of Stage
  • name a unique name for the Stage, to be (potentially) used with hooks
  • defaults - An object defining default configuration for this stage, when used in a Hook.
  • data - An object containing any data required to initially configure the stage itself (Example: API URL, username, password, etc…)
Example

Your AIO Config:

config.json
{
// ...your AIO Config
"transformers": [
{
"type": "native",
"name": "MyNativeTransformer",
"defaults": {
"delimiters": [1
"•"
],
"artistsParseFrom": ["artists"]2
}
}
]
}
  1. 1.default delimiters when this Stage is used in a hook
  2. 2.default artistsParseFrom when this Stage is used in a hook

Multiple stages of the same type may be configured, allowing you to define several sets of default behavior.

Example

Your AIO Config:

config.json
{
// ...your AIO Config
"transformers": [
{
"type": "native",
"name": "DotTransformer",
"defaults": {
"delimiters": [
"•"
],
"artistsParseFrom": ["artists"]
}
},
{
"type": "native",
"name": "TitleOnly",
"defaults": {
"artistsParseFrom": ["title"]1
}
}
]
}
  1. 1.extracts and uses *only* artists found in title string

When specifying a Stage within a Hook (like in preCompare), the name for a Stage is potentially optional.

If Stages exists for the type you want to use in the hook and you do not specify a name…

Example
vlc.json
[
{
"name": "MyVLC",
"data": { /* ... */},
"options": {
"playTransform": {
"preCompare": [
{
no "name" is specified
"type": "rocksky"
}
]
}
}
}
]

Multi-Scrobbler looks for configured Stages in this order:

  • The first non-default, non-ENV configured Stage IE a Stage you configured with an explicit name
  • An ENV configured stage, like Rocksky’s ENV Configuration
    • ENV configured stages are always named MSEnv
  • The default, built-in Stage from Multi-Scrobbler. This is always named MSDefault

All of the Modification Stages have an MSDefault configured Stage except for Spotify.

The default configuration you set for your Stage may be overridden in any usage of the Stage within a Hook.

Example

Your AIO Config:

config.json
{
// ...your AIO Config
"transformers": [
{
"type": "native",
"name": "MyNativeTransformer",
"defaults": {
"delimiters": [1
"•"
],
"artistsParseFrom": ["artists"]2
}
}
]
}
  1. 1.default delimiters when this Stage is used in a hook
  2. 2.default artistsParseFrom when this Stage is used in a hook

Each Stage may specify whether it should apply the resulting transformation to different parts of the Play data by specifying title, artists and/or album in the Stage object.

{
"type": "native",
// ...
"title": false,1
"artists": {
"when": {/* ... */},2
/* ... */
},
"albumArtists": true,3
"album": true,4
"duration": true,5
"meta": true,6
"art": true7
}
  1. 1.will not apply any changes to Play title
  2. 2.will only apply changes to Play artists if "when" is satisfied
  3. 3.will always apply changes to Play album artists
  4. 4.will always apply changes to Play album
  5. 5.will always apply changes to Play duration (length of track)
  6. 6.will always apply changes to Play meta (MBIDs, spotify links, etc...)
  7. 7.will always apply art to Play (if stage provides art)

The actual value of each property may be different for each Stage. Check the docs for the Stage you want to use to see its usage of each.

Generically, though, each property may be some value or an object combining a when condition and that value.

If none of the properties are specified in the stage then it’s assumed all transformed data should be used.

How meta and art Combine With Other Rules

IDs (MBIDs, Spotify IDs…) and art belong to a specific part of your Play: the title, the album, or an individual artist. So what meta and art do depends on whether the Stage is also allowed to change that part.

Rule for a part (EX title) meta What happens to that part
true true Name is replaced and IDs from the Stage are added. IDs the part already has from other services/stages are kept.
true false Name is replaced and no IDs are added. If the name actually changed, the IDs the part already had are removed.
false true Name is not changed. IDs from the Stage are added to the existing part.
false false Nothing is changed.

Stages may be optionally configured to continue to the next Stage or stop (end early) all subsequent Stages, based on the outcome of the currently running Stage.

These three properties can be added to the Modification Stage data, alongside Rules:

  • onSuccess (default continue) - If the Stage successfully finishes processing
  • onFailure (default stop) - If the Stage encounters an error while processing, or otherwise fails to achieve the transformation result
  • onSkip (default continue) - If the Stage does not process the Play data due to stage-level when or other stage-specific skip conditions

Stages within a Hook, and Rules within each Stage, support a when object for testing if they should be run.

The when object may have propertes for rule. Each property may be a string or regular expression. The value of the property is used to match the pre-transformation values from Play data.

All parts of an individual when clause must test true to “pass” but if any when clauses pass the Stage/Rule is processed.

If the when test does not pass then the Stage is skipped (onSkip).

Single condition
{
"when":
{
"artist": "Elephant Gym",1
"album": "Dreams"2
}
}
  1. 1.Play must have an artist matching "Elephant Gym" (AND)
  2. 2.Play object must have an album matching "Dreams" (AND)
Checking for Art

To test if your Play/scrobble has no art associated with the data use an empty string for art:

{
"when": [
{
run the stage when play has no artwork (album, track, or artist)
"art": "",
},
]
}

Otherwise, using a string/regex pattern for art will test if any art url includes that pattern.

EX: only run stage if art does not come from deezer, use deezer’s cdn in a not regex statement:

{
"when": [
{
run the stage when no art url comes from deezer (using this regex)
"art": "/^((?!(dzcdn)).)*$/",
},
]
}

More succinctly:

  • All parts (artist album albumArtist duration meta title art) of a when are AND conditions
  • All part-objects in the when array are OR conditions
Stage with 'when' condition
{
"playTransform": {
"preCompare": [
{
"type": "native",
"when": [1
{
"artist": "/Elephant Gym/"2
}
]
}
],
}
}
  1. 1.Only run native if these conditions are true...
  2. 2.if artist is Elephant Gym

MS can log a diff of Stage transformations if/when they occur. In the playTransform object of a Source/Client config use log:

  • "log": true => Output original play + final transformed output of last Stage in the array
  • "log": "all" => Output original play + final transformed output of each Stage in the array
{
"name": "myThing",
"data": {/*...*/},
"options": {
"playTransform": {
"preCompare": {/*...*/},
"log": true
}
}
}
Example

The output shows the diff between the previous stage (or original Play) and the current stage. In docker logs this is highlighted with diff syntax:

[2025-12-17 08:53:10.467 -0500] DEBUG : [App] [Sources] [Lastfm - mylfm] [Play Transform] [preCompare] [VLDJJo] Transform Diff
Original
musicbrainz-MyMB
Title : Demons Theme Part II (original 12" mix)
Artists : LTJ Bukem
Album Artists: (None)
Album : Producer 05: Rarities (Original 12" Version)
Album : Producer 05: Rarities (original 12" version)
Meta :
* brainz-album: 36759a8a-d3df-47da-a236-60f84fdc0cab
* brainz-artist: 28c1b7b7-355a-48b1-b2c4-75b8eb8080ef
* brainz-track: a0d51240-7ef1-4676-882e-be9f354075cb
* brainz-releaseGroup: e0cce74f-abcc-343b-9390-1673e4d57ce7

MS uses caching to reduce the number of API calls needed for stages like Musicbrainz and to speed up all transforms by caching steps and results. However, the default caching strategy uses a small cache size and very short TTLs because it is in-memory.

If you are using any Transform stages you should configure secondary caching to increase the cache size and lifetime of cached items. This will also reduce memory usage in MS.

See Examples sections in specific Stage docs (also in the sidebar):

These examples use all of the features described above. They walk you through the entire process of building configuration to transform your scrobble data, from start to end, in one usecase.

You want to use the User Stage to remove instances of (Album Version) in track titles from Navidrome.

config.jsonDecide on Stage
{
"type": "user"1
}
  1. 1.Set the Stage type
Step 1 of 5
config.jsonDecide on Rule
{
"type": "user",
"title": [1
]
}
  1. 1.Define which Rule this Stage will apply. This equates to the part of the scrobble data you want to modify
Step 2 of 5
config.jsonDefine Stage Behavior
{
"type": "user",
"title": [
{1
"search": "(Album Version)",
"replace": ""2
}
]
}
  1. 1.Add the behavior for the chosen Stage to that Rule
  2. 2.Replacing with an empty string ("") removes the value found in search
Step 3 of 5
config.jsonDecide When Stage should Run
{
"preCompare": [1
{
"type": "user",
"title": [
{
"search": "(Album Version)",
"replace": ""
}
]
}
]
}
  1. 1.Run this behavior when scrobble data is first seen
Step 4 of 5
config.jsonAdd everything to a Source/Client
{
"sources": [
{
"type": "subsonic",
"id": "myNavidrome",
"data": {},
"options": {
"playTransform": {1
"preCompare": [
{
"type": "user",
"title": [
{
"search": "(Album Version)",
"replace": ""
}
]
}
]
}
}
}
]
}
  1. 1.Defined on the Navidrome source scrobble data comes from
Step 5 of 5

Walkthrough - Using Musicbrainz on Many Sources/Clients

Section titled “Walkthrough - Using Musicbrainz on Many Sources/Clients”

You want to use the Musicbrainz Stage, with specific behavior, to correct/enrich your scrobble data. It should do this for both Navidrome and Jellyfin.

config.jsonDecide on Stage
{
"transformers": [1
{
"type": "musicbrainz",2
"name": "myMB"3
}
]
}
  1. 1.Add to transformers in AIO config. This is where Stages with reusable configuration are defined
  2. 2.Set the Musicbrainz Stage
  3. 3.Give it a unique name so we can reference it in Client/Sources later
Step 1 of 5
config.jsonDecide on Rules
{
"transformers": [
{
"type": "musicbrainz",
"name": "myMB"
//"title": true,1
//"artists": true,
//"album": true,
//...
}
]
}
  1. 1.You want all Rules from this Stage to apply. All rules default to true when not defined, so defining nothing here is the same as "apply everything".
Step 2 of 5
config.jsonSet Stage Default
{
"transformers": [
{
"type": "musicbrainz",
"name": "myMB",
"defaults": {}1
}
]
}
  1. 1.Unlike the User Stage Walkthrough which sets behavior directly in the Rule, we want the behavior we set to apply to *all* Sources/Clients that use it, so we set behavior in defaults
Step 3 of 5
config.jsonSet Stage Default Behavior
{
"transformers": [
{
"type": "musicbrainz",
"name": "myMB",
"defaults": {1
"releaseGroupPrimaryTypePriority": ["single", "ep", "album"],2
"releaseCountryPriority": ["US"],
}
}3
]
}
  1. 1.Add our stage behvior to defaults
  2. 2.Prioritize releases and country for releases
  3. 3.Now our stage is ready to be used in Sources/Clients
Step 4 of 5
config.jsonAdd Stage to Sources/Clients
{
"transformers": [...],
"sources": [
{
"type": "subsonic",
"id": "myNavidrome",
"data": {},
"options": {
"playTransform": {1
"preCompare": [2
{
"type": "musicbrainz",
"name": "myMB",3
}
]
}
}
},
{
"type": "jellyfin",
"id": "myJellyfin",
"data": {},
"options": {
"playTransform": {4
"preCompare": [
{
"type": "musicbrainz",
"name": "myMB",
}
]
}
}
},
],
"clients": [...]
}
  1. 1.Defined on the Navidrome source scrobble data comes from
  2. 2.Run this behavior [when scrobble data is first seen](#lifecycle-hooks
  3. 3.Referencing Stage by name means "use the defaults in the Stage in transformers with this name"
  4. 4.Defined on the jellyfin source scrobble data comes from
Step 5 of 5