diff --git a/docsite/docs/configuration/transforms/spotify.mdx b/docsite/docs/configuration/transforms/spotify.mdx
new file mode 100644
index 00000000..03079fc9
--- /dev/null
+++ b/docsite/docs/configuration/transforms/spotify.mdx
@@ -0,0 +1,341 @@
+---
+title: Spotify Stage
+description: Enhancing Scrobbles with Spotify
+toc_min_heading_level: 2
+toc_max_heading_level: 5
+---
+
+The **Spotify** [Stage](/configuration/transforms#stage) matches your Play data with the [Spotify](https://open.spotify.com/) catalog, using the [Spotify Web API](https://developer.spotify.com/documentation/web-api). If the match is confident enough then Multi-Scrobbler uses it to correct and fill-in missing information in your Play data.
+
+**This Stage is useful for standardizing your Scrobble's Play data, regardless of the Source it is coming from**, and is a good complement (or alternative) to the [Musicbrainz Stage](/configuration/transforms/musicbrainz) if you'd prefer matches to come from Spotify's own catalog.
+
+:::tip
+
+If you are using [ENV Config](/configuration?config-type=env#configuration-types) for multi-scrobbler and just want a quick and easy setup, skip to [**ENV Configuration**](#env-configuration).
+
+:::
+
+:::tip
+
+Set up [Valkey Caching](/configuration?cachedThings=metadata#caching) to cache Spotify API calls for faster processing.
+
+:::
+
+## Configuration
+
+### API Setup
+
+This Stage uses Spotify's [Client Credentials Flow](https://developer.spotify.com/documentation/web-api/tutorials/client-credentials-flow) to search/lookup the Spotify catalog. This does **not** require a user to log in -- only an app `clientId`/`clientSecret` is needed. [Create a Spotify application](https://developer.spotify.com/documentation/web-api/concepts/apps) if you don't already have one (the same application used for the [Spotify Source](/configuration/sources/spotify) can be reused here).
+
+```json5 title="config.json"
+{
+ // ...
+ "transformers": [
+ {
+ "type": "spotify",
+ "name": "MySpotify",
+ "data": {
+ "clientId": "787c921a2a2ab42320831aba0c8f2fc2",
+ "clientSecret": "ec42e09d5ae0ee0f0816ca151008412a"
+ },
+ }
+ ]
+}
+```
+
+
+
+Market and Rate Limiting
+
+`market` (an [ISO 3166-1 alpha-2 country code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2)) can be set to bias/limit search results to what is available in a specific market:
+
+```json5
+{
+ "type": "spotify",
+ "name": "MySpotify",
+ "data": {
+ "clientId": "787c921a2a2ab42320831aba0c8f2fc2",
+ "clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
+ "market": "US"
+ },
+}
+```
+
+`rate` can be used to configure how many requests are made to the Spotify API, defined by **max number of requests** within **timespan of N seconds** (default `10 req/1s`):
+
+```json5
+{
+ "type": "spotify",
+ "name": "MySpotify",
+ "data": {
+ "clientId": "787c921a2a2ab42320831aba0c8f2fc2",
+ "clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
+ "rate": {
+ "requests": 10,
+ "perTime": 1
+ }
+ },
+}
+```
+
+
+
+### Stage Configuration
+
+All of the properties found in [**Matching with Spotify**](#matching-with-spotify) section are configured in [Stage Configuration](/configuration/transforms#configuring-stages) as `defaults`.
+
+```json5 title="config.json"
+{
+ // ...
+ "transformers": [
+ {
+ "type": "spotify",
+ "name": "MySpotify",
+ "data": {
+ "clientId": "787c921a2a2ab42320831aba0c8f2fc2",
+ "clientSecret": "ec42e09d5ae0ee0f0816ca151008412a"
+ },
+ "defaults": {
+ "score": 0.6,
+ "deprioritizeCompilations": true
+ }
+ }
+ ]
+}
+```
+
+### Rules and Hooks
+
+[Add your Stage](/configuration/transforms/#stage) to a Source or Client by specifying it in a [Hook](/configuration/transforms/#hook):
+
+```json5 title="subsonic.json"
+[
+ {
+ "name": "MySubsonic",
+ "data": { /* ... */},
+ "options": {
+ "playTransform": {
+ "preCompare": [
+ {
+ "type": "spotify",
+ "name": "MySpotify"
+ }
+ ]
+ }
+ }
+ }
+]
+```
+
+Each [**Stage Rule**](/configuration/transforms#stage-rules) (`title`/`artists`/`albumArtists`/`album`/`duration`/`meta`) works the same way as it does for the [Musicbrainz Stage](/configuration/transforms/musicbrainz#rules-and-hooks): either a boolean specifying whether the transformed data should be used for this field, or a [`when` condition](/configuration/transforms#conditional-modification).
+
+:::tip[Per Component Override]
+
+The `defaults` you set in [Stage Configuration](#stage-configuration) [can be overriden/added to](/configuration/transforms/#overriding-configuration) (per property) in each Hook, exactly as with the [Musicbrainz Stage](/configuration/transforms/musicbrainz#rules-and-hooks).
+
+:::
+
+## ENV Configuration
+
+Use `SPOTIFY_TRANSFORM=true` to enable this Stage from ENV. `clientId`/`clientSecret` are read from `SPOTIFY_TRANSFORM_CLIENT_ID`/`SPOTIFY_TRANSFORM_CLIENT_SECRET`, falling back to `SPOTIFY_CLIENT_ID`/`SPOTIFY_CLIENT_SECRET` (the same ENVs used by the [Spotify Source](/configuration/sources/spotify), if configured) when the `_TRANSFORM_` variants are not set.
+
+* `SPOTIFY_TRANSFORM=true` - Enables this Stage
+* `SPOTIFY_TRANSFORM_CLIENT_ID` / `SPOTIFY_TRANSFORM_CLIENT_SECRET` - App credentials (optional if `SPOTIFY_CLIENT_ID`/`SPOTIFY_CLIENT_SECRET` are already set)
+* `SPOTIFY_TRANSFORM_MARKET` - Optional [market](#market-and-rate-limiting)
+* `SPOTIFY_TRANSFORM_DEPRIORITIZE_COMPILATIONS=true` - Optionally enable [`deprioritizeCompilations`](#deprioritize-compilations)
+
+Finally, use ENV `*_TRANSFORMS=spotify` on each Source/Client you wish to apply this stage to. This applies the stage in the [`preCompare` Hook](/configuration/transforms#lifecycle-hooks) with all [Rules](#rules-and-hooks) 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. Example:
+
+* All [Subsonic Source](/configuration/sources/subsonic) ENVs look like `SUBSONIC_USER=myuser` etc...
+* Use `SUBSONIC_TRANSFORMS=spotify`
+
+
+
+Example Full Docker Deploy with ENV Configuration
+
+```yaml
+services:
+ multi-scrobbler:
+ image: foxxmd/multi-scrobbler
+ container_name: multi-scrobbler
+ environment:
+ // highlight-start
+ - SPOTIFY_TRANSFORM=true
+ - SPOTIFY_CLIENT_ID=787c921a2a2ab42320831aba0c8f2fc2
+ - SPOTIFY_CLIENT_SECRET=ec42e09d5ae0ee0f0816ca151008412a
+ // highlight-end
+ - JELLYFIN_URL=192.168.0.110:8096
+ - JELLYFIN_APIKEY=c9fae8756fbf481ebd9c5bb56b
+ - JELLYFIN_USER=MyUser
+ // highlight-start
+ # applies spotify Stage to preCompare of Jellyfin source
+ - JELLYFIN_TRANSFORMS=spotify
+ // highlight-end
+
+ # maloja receives enhanced scrobble from Jellyfin
+ - MALOJA_URL=http://192.168.0.100:42010
+ - MALOJA_API_KEY=myApiKey
+
+ volumes:
+ - "./config:/config"
+ ports:
+ - "9078:9078"
+ restart: unless-stopped
+```
+
+
+
+## Matching with Spotify
+
+:::note
+
+**All properties found in this section are optional.**
+
+:::
+
+Matching your Scrobble's Play data with a result from Spotify is comprised of two steps:
+
+* [**Searching**](#searching) Spotify using parts of your Scrobble as queries
+* [**Ranking**](#ranking) matched results to select the desired match
+
+### Searching
+
+#### Should MS Search?
+
+Before MS begins a search it checks if your Scrobble data already contains Spotify IDs for artist(s), album, and track, and a duration. If it already has all required data types then the entire Spotify Stage [is **skipped**.](/configuration/transforms/#flow-control) If any are missing then a search is performed.
+
+Define which data types are required using:
+
+* `searchWhenMissing` (defaults to all) - A list containing any of: `artists` `title` `album` `duration`
+* `forceSearch` (default `false`) - Force searching even if all required data is present
+
+#### Search Methods
+
+Multi-scrobbler searches for a Spotify match using, in order, up to two methods. Use `searchOrder` to control which methods run and in what order:
+
+```json5
+// ...
+"defaults": {
+ "searchOrder": ["isrc", "basic"]
+ }
+```
+
+
+
+ISRC (`isrc`)
+
+If your Scrobble data contains an [ISRC](https://musicbrainz.org/doc/ISRC) then Multi-scrobbler searches Spotify's catalog using this ID.
+
+**If the ISRC is present on more than one Spotify album/track** (which happens often -- singles, re-releases, and compilation appearances of the same recording all share an ISRC) then the results are [ranked using fuzzy matching](#ranking) against your scrobble's existing album/artist data to pick the best candidate.
+
+
+
+
+
+Album, Artist, and Title Fields (`basic`)
+
+Searches Spotify using any/all available text fields from your scrobble: Album, primary Artist, and Title. Results are always [ranked using fuzzy matching](#ranking) since Spotify's text search does not otherwise guarantee an accurate/confident result.
+
+
+
+:::tip[Default Search Methods]
+
+If `searchOrder` is undefined Multi-scrobbler defaults to using `isrc` then `basic`.
+
+:::
+
+:::note
+
+If all defined search methods do not return any results (or no results score high enough, see [Ranking](#ranking)) then the stage is marked as [**failed** (`onFailure`) for **Flow Control**](/configuration/transforms/#flow-control).
+
+:::
+
+### Ranking
+
+Unlike Musicbrainz (whose search backend returns its own relevance score) Spotify's search results do not carry a comparable score. So Multi-scrobbler always fuzzy-matches every candidate against your original scrobble's title/artist(s)/album to:
+
+* disambiguate between multiple candidates (EX an ISRC present on more than one album)
+* determine whether any candidate is a confident enough match to use at all
+
+#### Score
+
+Each candidate is scored between `0` and `1` based on how similar its title/artist(s)/album are to your original scrobble. Set `score` to change the minimum score a candidate must have to be used. Default is `0.6`.
+
+```json5
+{
+ // ...
+ "score": 0.6 // matches must score 0.6 or higher to be considered
+}
+```
+
+You can bias which of title/artist/album contributes most to a candidate's score with `titleWeight`, `artistWeight`, and `albumWeight` (defaults are `0.4`/`0.3`/`0.3`, respectively):
+
+```json5
+// ...
+"defaults": {
+ "titleWeight": 0.4,
+ "artistWeight": 0.3,
+ "albumWeight": 0.3
+ }
+```
+
+#### Deprioritize Compilations
+
+Spotify catalogs many recordings across multiple compilation albums (Greatest Hits, movie soundtracks that reuse a song, etc...) in addition to their "proper" studio album. If you'd prefer matches to avoid compilation albums when a better alternative exists, enable `deprioritizeCompilations`:
+
+```json5
+// ...
+"defaults": {
+ "deprioritizeCompilations": true
+ }
+```
+
+This does not **exclude** compilations -- it only lowers their score relative to other candidates, so a compilation can still be used if it's the only match found.
+
+## Best Practices
+
+### Caching
+
+You **should** setup [metadata caching](/configuration/transforms#caching) to reduce API calls, improve transform performance, and reduce memory usage when using this stage.
+
+### Using Partial Match
+
+Use [Rules](#rules-and-hooks) to apply Spotify match data selectively, exactly as described for the [Musicbrainz Stage](/configuration/transforms/musicbrainz#using-partial-match). This is useful if you don't want your scrobble's Artist/Title/Album modified but still want the Spotify IDs attached to `meta` for Clients that use them.
+
+
+
+Example
+
+```json5 title="subsonic.json"
+[
+ {
+ "name": "MySubsonic",
+ "data": { /* ... */},
+ "options": {
+ "playTransform": {
+ "preCompare": [
+ {
+ "type": "spotify",
+ "name": "MySpotify",
+ "title": false,
+ "artists": false,
+ "album": false,
+ "albumArtists": false,
+ "meta": true
+ }
+ ]
+ }
+ }
+ }
+]
+```
+
+
+
+## Logging
+
+If Spotify is not returning matches, or the resulting enhanced Scrobble is not what you expected, enable [**Debug Mode**](/configuration#debug-mode) to help diagnose issues with the Spotify API and Scrobble enhancement.
+
+If you have multiple Modification Stages and need to see the diff for your Play between each Stage, enable `"log": "all"` in the individual [Modification Stage](#rules-and-hooks), as described for the [Musicbrainz Stage](/configuration/transforms/musicbrainz#logging).
diff --git a/docsite/docs/configuration/transforms/transforms.mdx b/docsite/docs/configuration/transforms/transforms.mdx
index c1e1a909..0c31ed5a 100644
--- a/docsite/docs/configuration/transforms/transforms.mdx
+++ b/docsite/docs/configuration/transforms/transforms.mdx
@@ -134,6 +134,7 @@ Each [**hook**](#hook) is made up of one or more **Stages**. A Stage is a self-c
* The [Native](/configuration/transforms/native) Stage uses MS's built-in heuristics to extract Artists from a single Artist string
* The [Musicbrainz](/configuration/transforms/musicbrainz) Stage tries to match Play data with the Musicbrainz database and to standardize the Artist/Title/Album data
* The [Rocksky](/configuration/transforms/rocksky) Stage tries to match Play data with the Rocksky metadata API to enrich meta IDs (like MBID, ISRC), duration, and artwork
+* The [Spotify](/configuration/transforms/spotify) Stage tries to match Play data with the Spotify catalog (prioritizing ISRC lookups) to standardize the Artist/Title/Album data
Each Stage in a Hook receives Play data from the previous Stage.
diff --git a/src/backend/common/infrastructure/config/common.ts b/src/backend/common/infrastructure/config/common.ts
index 1c5caca4..9eff5a61 100644
--- a/src/backend/common/infrastructure/config/common.ts
+++ b/src/backend/common/infrastructure/config/common.ts
@@ -213,6 +213,9 @@ export const transformPresetEnv = {
+ const clean = str.trim().toLocaleLowerCase();
+ if (clean === 'isrc' || clean === 'basic') {
+ return clean;
+ }
+ throw new Error(`SearchType must be one of 'isrc' or 'basic', given: ${clean}`);
+}
+
+/** How much to subtract from a candidate's match score when it belongs to a compilation album and deprioritizeCompilations is enabled */
+export const COMPILATION_PENALTY = 0.15;
+
+export interface SpotifyTransformerDataStrong extends SpotifyTransformerData {
+ searchWhenMissing: MissingMbidType[]
+
+ titleWeight?: number
+ artistWeight?: number
+ albumWeight?: number
+}
+
+export interface SpotifyTransformerDataStage extends SpotifyTransformerDataStrong, PlayTransformMetadataStage {
+}
+
+export interface SpotifyTrackSearchResult {
+ tracks: SpotifyApi.TrackObjectFull[]
+ requestQueries: LifecycleInput[]
+}
+
+export interface RankedSpotifyTrack {
+ track: SpotifyApi.TrackObjectFull
+ matchScore: number
+}
+
+export const parseStageConfig = (data: SpotifyTransformerData | undefined = {}, logger: MaybeLogger = new MaybeLogger()): SpotifyTransformerDataStrong => {
+
+ if (data === null || typeof data !== 'object') {
+ throw new Error('Spotify Transformer data should be an object or not defined.');
+ }
+
+ const {
+ searchWhenMissing,
+ searchOrder,
+ titleWeight,
+ albumWeight,
+ artistWeight,
+ ...rest
+ } = data;
+
+ const config: SpotifyTransformerDataStrong = {
+ searchWhenMissing: DEFAULT_MISSING_TYPES,
+ score: 0.6,
+ ...rest,
+ };
+
+ if (searchWhenMissing !== undefined) {
+ config.searchWhenMissing = searchWhenMissing.map(asMissingMbid);
+ }
+
+ logger.debug(`Will search if missing: ${config.searchWhenMissing.join(', ')} | Match if (default) score is >= ${config.score}`);
+
+ if (searchOrder !== undefined) {
+ const so = parseArrayFromMaybeString(searchOrder as unknown as string[], { lower: true }).map(asSpotifySearchType);
+ if (so.length > 0) {
+ config.searchOrder = so;
+ logger.debug(`Search Order => ${so.join(' | ')}`);
+ }
+ }
+
+ if (titleWeight !== undefined) {
+ config.titleWeight = titleWeight === true ? TITLE_WEIGHT : titleWeight;
+ }
+ if (artistWeight !== undefined) {
+ config.artistWeight = artistWeight === true ? ARTIST_WEIGHT : artistWeight;
+ }
+ if (albumWeight !== undefined) {
+ config.albumWeight = albumWeight === true ? 0.3 : albumWeight;
+ }
+
+ return config;
+}
+
+/** Analogous to musicbrainz's missingMbidTypes but checks the presence of Spotify IDs on the Play instead of MBIDs */
+export const missingSpotifyTypes = (play: PlayObject): MissingMbidType[] => {
+ let missing: MissingMbidType[] = [];
+
+ if (play.data.duration === undefined) {
+ missing.push('duration');
+ }
+
+ if (play.data.meta?.spotify === undefined) {
+ missing = missing.concat(DEFAULT_MISSING_MBIDS_TYPES);
+ return missing;
+ }
+
+ const {
+ track,
+ album,
+ artist
+ } = play.data.meta.spotify;
+
+ if (track === undefined) {
+ missing.push('title');
+ }
+ if (album === undefined) {
+ missing.push('album');
+ }
+ if (artist === undefined || (artist ?? []).length !== (play.data.artists ?? []).length) {
+ missing.push('artists');
+ }
+
+ return missing;
+}
+
+/** Ranks candidate Spotify tracks by fuzzy similarity to the original scrobble.
+ *
+ * Unlike Musicbrainz (which returns its own relevance score from its search backend) Spotify's search results
+ * do not carry a comparable score, so fuzzy matching against the original Play's title/artist(s)/album is always
+ * used to both disambiguate results (EX an ISRC present on more than one album) and to determine whether a match
+ * is confident enough to use at all.
+ */
+export const rankTracksBySimilarity = (tracks: SpotifyApi.TrackObjectFull[], play: PlayObject, stageConfig: SpotifyTransformerDataStage): RankedSpotifyTrack[] => {
+ const {
+ titleWeight = TITLE_WEIGHT,
+ artistWeight = ARTIST_WEIGHT,
+ albumWeight = 0.3,
+ deprioritizeCompilations = false,
+ } = stageConfig;
+
+ const ranked = tracks.map((track) => {
+ const candidate = trackToPlay(track);
+ let matchScore = scorePlaySameness(play, candidate, {
+ weights: {
+ track: titleWeight,
+ artist: artistWeight,
+ album: albumWeight
+ }
+ });
+
+ if (deprioritizeCompilations && isCompilation(track)) {
+ matchScore = matchScore - COMPILATION_PENALTY;
+ }
+
+ return { track, matchScore };
+ });
+
+ ranked.sort((a, b) => b.matchScore - a.matchScore);
+ return ranked;
+}
+
+export default class SpotifyTransformer extends AtomicPartsTransformer {
+
+ declare config: SpotifyTransformerConfig;
+
+ protected defaults: SpotifyTransformerDataStrong;
+
+ protected api: SpotifyApiClient;
+ protected clientCache?: Cacheable;
+
+ public constructor(config: SpotifyTransformerConfig, options: TransformerOptions & { clientCache?: Cacheable }) {
+ super(config, options);
+ this.clientCache = options.clientCache;
+ this.staggerOpts = {
+ initialInterval: 0,
+ maxRandomStagger: 100
+ }
+ }
+
+ protected async doBuildInitData(): Promise {
+ this.defaults = parseStageConfig(this.config.defaults, childLogger(this.logger, 'Defaults'));
+
+ const {
+ clientId,
+ clientSecret,
+ market,
+ rate
+ } = this.config.data ?? {};
+
+ if (clientId === undefined || clientSecret === undefined) {
+ throw new Error(`Spotify Transformer requires 'clientId' and 'clientSecret' to be set in 'data'`);
+ }
+
+ this.api = new SpotifyApiClient(this.config.name, { clientId, clientSecret, market, rate }, {
+ logger: this.logger,
+ cache: this.clientCache
+ });
+
+ return true;
+ }
+
+ protected doParseConfig(data: SpotifyTransformerDataStage) {
+ if (data.type !== 'spotify') {
+ throw new Error(`Spotify Transformer is only usable with 'spotify' type stages`);
+ }
+
+ const stage: SpotifyTransformerDataStage = {
+ ...data,
+ ...parseStageConfig(data),
+ type: 'spotify'
+ }
+
+ for (const k of ['artists', 'albumArtists', 'title', 'album', 'meta', 'duration']) {
+ if (!(k in stage)) {
+ stage[k] = true;
+ continue;
+ }
+ if (Array.isArray(stage[k])) {
+ throw new Error(`${k} must be a boolean or when object`);
+ }
+ if (typeof stage[k] === 'boolean') {
+ continue;
+ }
+ if (typeof stage[k] === 'object' && !isWhenCondition(stage[k])) {
+ throw new Error(`${k} is not a valid when object`);
+ }
+ }
+ return stage;
+ }
+
+ public async handlePreFetch(play: PlayObject, stageConfig: SpotifyTransformerDataStage): Promise {
+ const {
+ searchWhenMissing = this.defaults.searchWhenMissing,
+ forceSearch = this.defaults.forceSearch ?? false,
+ } = stageConfig;
+
+ const missing = missingSpotifyTypes(play);
+ if (intersect(searchWhenMissing, missing).length > 0) {
+ this.logger.debug(`Desired Spotify data for ${searchWhenMissing.join(',')} and Play is missing: ${missing.join(', ')}`);
+ } else if (forceSearch) {
+ this.logger.debug(`All desired Spotify data (${searchWhenMissing.join(',')}) exist but forceSearch = true`);
+ } else {
+ throw new SkipTransformStageError(`No desired Spotify data (${searchWhenMissing.join(',')}) are missing`, { shortStack: true });
+ }
+ }
+
+ public async getTransformerData(play: PlayObject, stageConfig: SpotifyTransformerDataStage, opts?: OptionalCacheUsage): Promise {
+
+ const {
+ searchOrder = this.defaults.searchOrder ?? DEFAULT_SPOTIFY_SEARCH_ORDER
+ } = stageConfig;
+
+ let tracks: SpotifyApi.TrackObjectFull[] = [];
+ const queries: LifecycleInput[] = [];
+
+ for (const searchType of searchOrder) {
+ try {
+ switch (searchType) {
+ case 'isrc':
+ tracks = await this.searchByIsrc(play, stageConfig, opts);
+ break;
+ case 'basic':
+ tracks = await this.searchByBasicFields(play, stageConfig, opts);
+ break;
+ }
+ queries.push({ type: `spotifyQuery-${searchType}${tracks.length === 0 ? '-empty' : ''}`, input: `${searchType} search for '${play.data.track}'` });
+ if (tracks.length === 0) {
+ this.logger.debug(`'${searchType}' search type returned no matches`);
+ } else {
+ break;
+ }
+ } catch (e) {
+ if (e instanceof SearchPrerequisiteError) {
+ queries.push({ type: `spotifyQuery-${searchType}-prereqFailure`, input: `Search type ${searchType} did not meet prerequisites: ${e.message}` });
+ this.logger.debug(`Search type ${searchType} did not meet prerequisites: ${e.message}`);
+ } else {
+ // we should be catching any unrecoverable errors in api calls
+ // so we should only get here if something truly bad has happened
+ throw new StageTransformError('Search Error', 'Unexpected error occurred while searching the Spotify API', { cause: e, inputs: queries });
+ }
+ }
+ }
+
+ return { tracks, requestQueries: queries };
+ }
+
+ public async searchByIsrc(play: PlayObject, stageConfig: SpotifyTransformerDataStage, opts: OptionalCacheUsage = {}): Promise {
+ if (play.data.isrc === undefined) {
+ throw new SearchPrerequisiteError('Play does not have ISRC');
+ }
+ this.logger.debug({ labels: ['ISRC Search'] }, 'Searching with ISRC');
+ const {
+ market = this.defaults.market
+ } = stageConfig;
+ return await this.api.searchByIsrc(play.data.isrc, { market, useCachedResult: opts.useCachedResult });
+ }
+
+ public async searchByBasicFields(play: PlayObject, stageConfig: SpotifyTransformerDataStage, opts: OptionalCacheUsage = {}): Promise {
+ if (play.data.track === undefined) {
+ throw new SearchPrerequisiteError('Play does not have a title');
+ }
+ this.logger.debug({ labels: ['Basic Search'] }, 'Searching by artist/album/track');
+ const {
+ market = this.defaults.market
+ } = stageConfig;
+ return await this.api.searchByFields(play, { market, useCachedResult: opts.useCachedResult });
+ }
+
+ public async handlePostFetch(play: PlayObject, transformData: SpotifyTrackSearchResult, stageConfig: SpotifyTransformerDataStage): Promise {
+
+ const {
+ tracks = [],
+ requestQueries = []
+ } = transformData ?? {};
+
+ if (tracks.length === 0) {
+ throw new StagePrerequisiteError('No matches returned from the Spotify API', { shortStack: true, inputs: requestQueries });
+ }
+
+ const {
+ score = this.defaults.score ?? 0.6
+ } = stageConfig;
+
+ const mergedConfig = Object.assign({}, removeUndefinedKeys({ ...this.defaults }), removeUndefinedKeys({ ...stageConfig }));
+
+ const ranked = rankTracksBySimilarity(tracks, play, mergedConfig);
+
+ const filtered = ranked.filter(x => x.matchScore >= score);
+ if (filtered.length === 0) {
+ throw new StagePrerequisiteError(`All ${tracks.length} fetched matches had a score < ${score}, best match was ${ranked[0]?.matchScore.toFixed(3)}`, { shortStack: true, inputs: requestQueries });
+ }
+
+ this.logger.debug(`${filtered.length} of ${tracks.length} fetched matches were valid. Using match with best score of ${filtered[0].matchScore.toFixed(3)}`);
+
+ const spotifyPlay = trackToPlay(filtered[0].track);
+ spotifyPlay.meta.lifecycleInputs = [...(spotifyPlay.meta.lifecycleInputs ?? []), ...requestQueries, { type: 'spotifyTrack', input: filtered[0].track.id }];
+ return spotifyPlay;
+ }
+
+ protected async handleTitle(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise {
+ if (parts === false) {
+ return play.data.track;
+ }
+ if (typeof parts === 'object') {
+ if (parts.when !== undefined) {
+ if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) {
+ this.logger.debug('When condition for track not met, returning original track');
+ return play.data.track;
+ }
+ }
+ }
+ return transformData.data.track;
+ }
+
+ protected async handleArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise {
+ if (parts === false) {
+ return play.data.artists;
+ }
+ if (typeof parts === 'object') {
+ if (parts.when !== undefined) {
+ if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) {
+ this.logger.debug('When condition for artists not met, returning original artists');
+ return play.data.artists;
+ }
+ }
+ }
+ return transformData.data.artists;
+ }
+
+ protected async handleAlbumArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise {
+ if (parts === false) {
+ return play.data.albumArtists;
+ }
+ if (typeof parts === 'object') {
+ if (parts.when !== undefined) {
+ if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) {
+ this.logger.debug('When condition for albumArtists not met, returning original artists');
+ return play.data.albumArtists;
+ }
+ }
+ }
+ return transformData.data.albumArtists;
+ }
+
+ protected async handleAlbum(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise {
+ if (parts === false) {
+ return play.data.album;
+ }
+ if (typeof parts === 'object') {
+ if (parts.when !== undefined) {
+ if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) {
+ this.logger.debug('When condition for album not met, returning original album');
+ return play.data.album;
+ }
+ }
+ }
+ return transformData.data.album;
+ }
+
+ protected async handleDuration(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise {
+ if (parts === false || transformData.data.duration === undefined) {
+ return play.data.duration;
+ }
+ if (typeof parts === 'object') {
+ if (parts.when !== undefined) {
+ if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) {
+ this.logger.debug('When condition for duration not met, returning original duration');
+ return play.data.duration;
+ }
+ }
+ }
+ return transformData.data.duration;
+ }
+
+ protected async handleMeta(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise {
+ if (parts === false) {
+ return play.data.meta;
+ }
+ if (typeof parts === 'object') {
+ if (parts.when !== undefined) {
+ if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) {
+ this.logger.debug('When condition for meta not met, returning original meta');
+ return play.data.meta;
+ }
+ }
+ }
+ return transformData.data.meta;
+ }
+
+ public notify(payload: WebhookPayload): Promise {
+ return;
+ }
+}
diff --git a/src/backend/common/transforms/TransformerManager.ts b/src/backend/common/transforms/TransformerManager.ts
index b27db14b..2a531fcf 100644
--- a/src/backend/common/transforms/TransformerManager.ts
+++ b/src/backend/common/transforms/TransformerManager.ts
@@ -11,6 +11,7 @@ import { nanoid } from "nanoid";
import { SimpleError, StageTransformError } from "../errors/MSErrors.ts";
import { configFromEnv as rsConfigFromEnv } from "./rocksky/RockskyTransformerUtil.ts";
import { type RockskyTransformerConfig } from "../vendor/rocksky/interfaces.ts";
+import { configFromEnv as spotifyConfigFromEnv, type SpotifyTransformerConfig } from "./spotify/SpotifyTransformerUtil.ts";
export const DEFAULT_TRANSFORMER_NAME = 'MSDefault';
export default class TransformerManager {
@@ -96,6 +97,10 @@ export default class TransformerManager {
const RockskyTransformer = (await import("./rocksky/RockskyTransformer.ts")).default;
t = new RockskyTransformer({ name: tName, ...config as RockskyTransformerConfig }, {logger: tLogger, regexCache: this.cache.regexCache, cache: this.cache.cacheTransform});
} break;
+ case 'spotify': {
+ const SpotifyTransformer = (await import("./SpotifyTransformer.ts")).default;
+ t = new SpotifyTransformer({ name: tName, ...config as SpotifyTransformerConfig }, {logger: tLogger, regexCache: this.cache.regexCache, cache: this.cache.cacheTransform});
+ } break;
default:
throw new Error(`No transformer of type '${config.type}' exists.`);
}
@@ -130,6 +135,19 @@ export default class TransformerManager {
}
this.logger.error(new Error('Unable to build Rocksky Transformer from ENV', {cause: e}));
}
+ try {
+ const spotifyConfig = spotifyConfigFromEnv(this.logger);
+ if(spotifyConfig !== undefined) {
+ this.addTransformerConfig(spotifyConfig);
+ } else {
+ this.logger.debug('No Spotify transformer to build from ENV');
+ }
+ } catch (e) {
+ if(e instanceof SimpleError) {
+ this.logger.error(`Unable to build Spotify Transformer from ENV: ${e.message}`);
+ }
+ this.logger.error(new Error('Unable to build Spotify Transformer from ENV', {cause: e}));
+ }
}
public async initTransformers() {
diff --git a/src/backend/common/transforms/spotify/SpotifyTransformerUtil.ts b/src/backend/common/transforms/spotify/SpotifyTransformerUtil.ts
new file mode 100644
index 00000000..dcd6f3b0
--- /dev/null
+++ b/src/backend/common/transforms/spotify/SpotifyTransformerUtil.ts
@@ -0,0 +1,68 @@
+import type {
+ MissingMbidType,
+ TransformerCommon,
+ TransformOptions,
+} from "../../../../core/Atomic.ts";
+import type { SpotifyTransformerApiConfigData } from "../../vendor/spotify/SpotifyTypes.ts";
+import { MaybeLogger } from "../../MaybeLogger.ts";
+
+export type SpotifySearchType = 'isrc' | 'basic';
+
+export const DEFAULT_SPOTIFY_SEARCH_ORDER: SpotifySearchType[] = ['isrc', 'basic'];
+
+export interface SpotifyTransformerData {
+ searchWhenMissing?: MissingMbidType[]
+ forceSearch?: boolean
+ /** Minimum (0-1) fuzzy match score a candidate must have to be used
+ *
+ * @default 0.6
+ */
+ score?: number
+ searchOrder?: SpotifySearchType[]
+ /** An ISO 3166-1 alpha-2 country code used to bias/limit search results to what is available in this market */
+ market?: string
+ /** Deprioritize (but do not exclude) matches whose album is a compilation when ranking candidates
+ *
+ * @default false
+ */
+ deprioritizeCompilations?: boolean
+
+ titleWeight?: number | true
+ artistWeight?: number | true
+ albumWeight?: number | true
+}
+
+export interface SpotifyTransformerDataConfig extends SpotifyTransformerApiConfigData {
+}
+
+export type SpotifyTransformerConfig = TransformerCommon & { options?: TransformOptions };
+
+export const configFromEnv = (logger: MaybeLogger = new MaybeLogger()): SpotifyTransformerConfig | undefined => {
+ const enabled = process.env.SPOTIFY_TRANSFORM;
+ if (enabled === undefined || enabled.trim() === '' || enabled.trim().toLocaleLowerCase() === 'false') {
+ return undefined;
+ }
+
+ const clientId = process.env.SPOTIFY_TRANSFORM_CLIENT_ID ?? process.env.SPOTIFY_CLIENT_ID;
+ const clientSecret = process.env.SPOTIFY_TRANSFORM_CLIENT_SECRET ?? process.env.SPOTIFY_CLIENT_SECRET;
+
+ if (clientId === undefined || clientSecret === undefined) {
+ logger.warn(`SPOTIFY_TRANSFORM was set but no clientId/clientSecret could be found. Set SPOTIFY_TRANSFORM_CLIENT_ID/SPOTIFY_TRANSFORM_CLIENT_SECRET, or SPOTIFY_CLIENT_ID/SPOTIFY_CLIENT_SECRET if also using the Spotify Source.`);
+ return undefined;
+ }
+
+ const deprioritizeCompilations = (process.env.SPOTIFY_TRANSFORM_DEPRIORITIZE_COMPILATIONS ?? '').trim().toLocaleLowerCase() === 'true';
+
+ return {
+ type: 'spotify',
+ name: 'MSDefault',
+ data: {
+ clientId,
+ clientSecret,
+ market: process.env.SPOTIFY_TRANSFORM_MARKET
+ },
+ defaults: {
+ ...(deprioritizeCompilations ? { deprioritizeCompilations } : {})
+ }
+ };
+}
diff --git a/src/backend/common/vendor/spotify/SpotifyApiClient.ts b/src/backend/common/vendor/spotify/SpotifyApiClient.ts
new file mode 100644
index 00000000..3869a6fb
--- /dev/null
+++ b/src/backend/common/vendor/spotify/SpotifyApiClient.ts
@@ -0,0 +1,175 @@
+import SpotifyWebApi from "spotify-web-api-node";
+import { RateLimiterMemory, RateLimiterQueue } from 'rate-limiter-flexible';
+import type { Cacheable } from "cacheable";
+import type { PlayObject, PlayObjectMinimal } from "../../../../core/Atomic.ts";
+import { artistNameToCredit } from "../../../../core/StringUtils.ts";
+import { isrcNoHyphens } from "../../../../core/PlayUtils.ts";
+import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.ts";
+import { hashObject } from "../../../utils/StringUtils.ts";
+import { UpstreamError } from "../../errors/UpstreamError.ts";
+import AbstractApiClient from "../AbstractApiClient.ts";
+import type { AbstractApiOptions, FormatPlayObjectOptions } from "../../infrastructure/Atomic.ts";
+import { getRoot } from "../../../ioc.ts";
+import type { SpotifyTransformerApiConfigData } from "./SpotifyTypes.ts";
+
+export interface SpotifySearchOptions {
+ limit?: number
+ market?: string
+ useCachedResult?: boolean
+}
+
+const luceneQuoteIfNeeded = (val: string): string => {
+ // spotify search field filters (track:"" artist:"" album:"") expect the value quoted
+ // if it contains any whitespace, otherwise quoting is not required but also not harmful
+ const escaped = val.replaceAll('"', '\\"');
+ return `"${escaped}"`;
+}
+
+export class SpotifyApiClient extends AbstractApiClient {
+
+ declare config: SpotifyTransformerApiConfigData;
+ protected spotifyApi: SpotifyWebApi;
+ protected rateLimiterQueue: RateLimiterQueue;
+ protected cache: Cacheable;
+ protected tokenExpiresAt: number = 0;
+
+ constructor(name: any, config: SpotifyTransformerApiConfigData, options: AbstractApiOptions & { cache?: Cacheable }) {
+ super('Spotify', name, config, options);
+ this.cache = options.cache ?? getRoot().items.cache().cacheApi;
+
+ const {
+ requests = 10,
+ perTime = 1
+ } = config.rate || {};
+ this.rateLimiterQueue = new RateLimiterQueue(new RateLimiterMemory({ points: requests, duration: perTime }), { maxQueueSize: 20 });
+
+ this.spotifyApi = new SpotifyWebApi({ clientId: config.clientId, clientSecret: config.clientSecret });
+ }
+
+ protected ensureToken = async (): Promise => {
+ // refresh a little before actual expiration to avoid a race with an in-flight request
+ if (this.spotifyApi.getAccessToken() !== undefined && Date.now() < this.tokenExpiresAt - 5000) {
+ return;
+ }
+ try {
+ const res = await this.spotifyApi.clientCredentialsGrant();
+ this.spotifyApi.setAccessToken(res.body['access_token']);
+ this.tokenExpiresAt = Date.now() + (res.body['expires_in'] * 1000);
+ } catch (e) {
+ throw new UpstreamError('Could not obtain a Spotify access token using the Client Credentials flow. Check clientId/clientSecret.', { cause: e, showStopper: true });
+ }
+ }
+
+ protected callApi = async (func: (api: SpotifyWebApi) => Promise, options: { cacheKey?: string, useCachedResult?: boolean } = {}): Promise => {
+ const {
+ cacheKey,
+ useCachedResult = true
+ } = options;
+
+ if (cacheKey !== undefined && useCachedResult) {
+ const cached = await this.cache.get(cacheKey);
+ if (cached !== undefined) {
+ this.logger.debug(`Cache hit for ${cacheKey}`);
+ return cached;
+ }
+ }
+
+ await this.rateLimiterQueue.removeTokens(1);
+ await this.ensureToken();
+
+ try {
+ const res = await func(this.spotifyApi);
+ if (cacheKey !== undefined) {
+ await this.cache.set(cacheKey, res);
+ }
+ return res;
+ } catch (e) {
+ throw new UpstreamError('Spotify API call failed', { cause: e });
+ }
+ }
+
+ searchByIsrc = async (isrc: string, opts: SpotifySearchOptions = {}): Promise => {
+ const { limit = 50, market = this.config.market, useCachedResult } = opts;
+ const q = `isrc:${isrcNoHyphens(isrc)}`;
+ const cacheKey = `spotify-search-${hashObject({ q, limit, market })}`;
+ this.logger.debug({ labels: ['ISRC Search'] }, `Search Query => ${q}`);
+ const res = await this.callApi((api) => api.searchTracks(q, { limit, market }), { cacheKey, useCachedResult });
+ return res.body.tracks?.items ?? [];
+ }
+
+ searchByFields = async (play: PlayObject, opts: SpotifySearchOptions = {}): Promise => {
+ const { limit = 50, market = this.config.market, useCachedResult } = opts;
+
+ const parts: string[] = [];
+ if (play.data.track !== undefined) {
+ parts.push(`track:${luceneQuoteIfNeeded(play.data.track)}`);
+ }
+ if (play.data.artists !== undefined && play.data.artists.length > 0) {
+ // use only the primary artist -- Spotify's search does not support matching multiple artist filters well
+ // and a fuzzy rank pass happens afterwards to confirm the rest of the artist credits
+ parts.push(`artist:${luceneQuoteIfNeeded(play.data.artists[0].name)}`);
+ }
+ if (play.data.album !== undefined) {
+ parts.push(`album:${luceneQuoteIfNeeded(play.data.album)}`);
+ }
+
+ const q = parts.join(' ');
+ const cacheKey = `spotify-search-${hashObject({ q, limit, market })}`;
+ this.logger.debug({ labels: ['Basic Search'] }, `Search Query => ${q}`);
+ const res = await this.callApi((api) => api.searchTracks(q, { limit, market }), { cacheKey, useCachedResult });
+ return res.body.tracks?.items ?? [];
+ }
+
+ static formatPlayObj(obj: SpotifyApi.TrackObjectFull, options: FormatPlayObjectOptions = {}): PlayObject {
+ return trackToPlay(obj);
+ }
+}
+
+export const trackToPlay = (track: SpotifyApi.TrackObjectFull): PlayObject => {
+
+ const {
+ id,
+ name,
+ artists = [],
+ album,
+ duration_ms,
+ external_ids: {
+ isrc
+ } = {}
+ } = track;
+
+ const albumArtists = album?.artists ?? [];
+
+ let actualAlbumArtists: SpotifyApi.ArtistObjectSimplified[] = [];
+ if ((artists.length !== albumArtists.length) || !artists.every(artist => albumArtists.some(albumArtist => artist.id === albumArtist.id))) {
+ // only include album artists if they are not the EXACT same as the track artists
+ actualAlbumArtists = albumArtists;
+ }
+
+ const play: PlayObjectMinimal = {
+ data: {
+ track: name,
+ artists: artists.map(x => artistNameToCredit(x.name)),
+ albumArtists: actualAlbumArtists.map(x => artistNameToCredit(x.name)),
+ album: album?.name,
+ duration: duration_ms !== undefined ? Math.round(duration_ms / 1000) : undefined,
+ isrc,
+ meta: {
+ spotify: {
+ track: id,
+ artist: artists.map(x => x.id),
+ albumArtist: actualAlbumArtists.map(x => x.id),
+ album: album?.id
+ }
+ }
+ },
+ meta: {
+ source: 'spotify',
+ trackId: id
+ }
+ }
+
+ return baseFormatPlayObj(track, play);
+}
+
+export const isCompilation = (track: SpotifyApi.TrackObjectFull): boolean => track.album?.album_type === 'compilation';
diff --git a/src/backend/common/vendor/spotify/SpotifyTypes.ts b/src/backend/common/vendor/spotify/SpotifyTypes.ts
new file mode 100644
index 00000000..d6c2d981
--- /dev/null
+++ b/src/backend/common/vendor/spotify/SpotifyTypes.ts
@@ -0,0 +1,30 @@
+export interface SpotifyTransformerApiConfigData {
+ /**
+ * Spotify application client id, used to authenticate with the Client Credentials flow for catalog search/lookup.
+ *
+ * Can also be set using the SPOTIFY_CLIENT_ID ENV (shared with the Spotify Source, if configured).
+ */
+ clientId: string
+ /**
+ * Spotify application client secret, used to authenticate with the Client Credentials flow for catalog search/lookup.
+ *
+ * Can also be set using the SPOTIFY_CLIENT_SECRET ENV (shared with the Spotify Source, if configured).
+ */
+ clientSecret: string
+ /**
+ * An ISO 3166-1 alpha-2 country code. Limits/biases search results to what is available in this market.
+ */
+ market?: string
+ rate?: {
+ /** max number of requests allowed during perTime unit of time
+ *
+ * @default 10
+ */
+ requests?: number
+ /** A span of time (in seconds) during which requests made me made, resets after perTime
+ *
+ * @default 1
+ */
+ perTime?: number
+ }
+}
diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts
index 4ca8800f..6a0d9f42 100644
--- a/src/backend/sources/ScrobbleSources.ts
+++ b/src/backend/sources/ScrobbleSources.ts
@@ -484,6 +484,9 @@ const transformPresetEnv =
case 'musicbrainz':
popts.preCompare.push({type: 'musicbrainz'});
break;
+ case 'spotify':
+ popts.preCompare.push({type: 'spotify'});
+ break;
}
}
diff --git a/src/backend/tests/spotify/spotifyTransformer.test.ts b/src/backend/tests/spotify/spotifyTransformer.test.ts
new file mode 100644
index 00000000..dbd1a3ef
--- /dev/null
+++ b/src/backend/tests/spotify/spotifyTransformer.test.ts
@@ -0,0 +1,191 @@
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import dayjs from 'dayjs';
+import type { PlayObject } from '../../../core/Atomic.ts';
+import {
+ COMPILATION_PENALTY,
+ missingSpotifyTypes,
+ parseStageConfig,
+ rankTracksBySimilarity,
+ type SpotifyTransformerDataStage,
+} from '../../common/transforms/SpotifyTransformer.ts';
+import { isCompilation, trackToPlay } from '../../common/vendor/spotify/SpotifyApiClient.ts';
+
+const basePlay = (data: Partial = {}, meta: Partial = {}): PlayObject => ({
+ data: {
+ track: 'My Track',
+ artists: [{ name: 'My Artist' }],
+ album: 'My Album',
+ duration: 180,
+ ...data,
+ },
+ meta: {
+ seenAt: dayjs(),
+ ...meta,
+ },
+});
+
+const artist = (id: string, name: string): SpotifyApi.ArtistObjectSimplified => ({ id, name } as SpotifyApi.ArtistObjectSimplified);
+
+const fakeTrack = (opts: {
+ id?: string,
+ name?: string,
+ artists?: SpotifyApi.ArtistObjectSimplified[],
+ albumName?: string,
+ albumId?: string,
+ albumArtists?: SpotifyApi.ArtistObjectSimplified[],
+ albumType?: string,
+ isrc?: string,
+ durationMs?: number,
+} = {}): SpotifyApi.TrackObjectFull => {
+ const {
+ id = 'track1',
+ name = 'My Track',
+ artists = [artist('artist1', 'My Artist')],
+ albumName = 'My Album',
+ albumId = 'album1',
+ albumArtists = artists,
+ albumType = 'album',
+ isrc = 'USRC17607839',
+ durationMs = 180000,
+ } = opts;
+
+ return {
+ id,
+ name,
+ artists,
+ duration_ms: durationMs,
+ external_ids: { isrc },
+ album: {
+ id: albumId,
+ name: albumName,
+ artists: albumArtists,
+ album_type: albumType,
+ },
+ } as unknown as SpotifyApi.TrackObjectFull;
+};
+
+describe('Spotify Transformer', function () {
+
+ describe('parseStageConfig', function () {
+
+ it('applies defaults when no data is given', function () {
+ const config = parseStageConfig();
+ expect(config.score).to.equal(0.6);
+ expect(config.searchWhenMissing).to.deep.equal(['artists', 'title', 'album', 'duration']);
+ });
+
+ it('converts weight shorthand (true) to library default weight constants', function () {
+ const config = parseStageConfig({ titleWeight: true, artistWeight: true, albumWeight: true });
+ expect(config.titleWeight).to.be.a('number').and.to.be.greaterThan(0);
+ expect(config.artistWeight).to.be.a('number').and.to.be.greaterThan(0);
+ expect(config.albumWeight).to.equal(0.3);
+ });
+
+ it('parses searchOrder', function () {
+ const config = parseStageConfig({ searchOrder: ['ISRC', 'basic' as any] });
+ expect(config.searchOrder).to.deep.equal(['isrc', 'basic']);
+ });
+
+ it('throws on an invalid searchOrder value', function () {
+ expect(() => parseStageConfig({ searchOrder: ['bogus' as any] })).to.throw();
+ });
+ });
+
+ describe('missingSpotifyTypes', function () {
+
+ it('returns all types when no spotify meta or duration exists', function () {
+ const play = basePlay({ duration: undefined });
+ const missing = missingSpotifyTypes(play);
+ expect(missing).to.include.members(['duration', 'artists', 'title', 'album']);
+ });
+
+ it('returns empty when all spotify ids and duration are present', function () {
+ const play = basePlay({}, {});
+ play.data.meta = { spotify: { track: 't1', album: 'a1', artist: ['ar1'] } };
+ const missing = missingSpotifyTypes(play);
+ expect(missing).to.be.empty;
+ });
+
+ it('flags artists as missing when spotify artist id count does not match play artist count', function () {
+ const play = basePlay({ artists: [{ name: 'One' }, { name: 'Two' }] });
+ play.data.meta = { spotify: { track: 't1', album: 'a1', artist: ['ar1'] } };
+ const missing = missingSpotifyTypes(play);
+ expect(missing).to.include('artists');
+ });
+ });
+
+ describe('trackToPlay', function () {
+
+ it('maps core fields and does not duplicate album artists that match track artists', function () {
+ const track = fakeTrack();
+ const play = trackToPlay(track);
+ expect(play.data.track).to.equal('My Track');
+ expect(play.data.album).to.equal('My Album');
+ expect(play.data.isrc).to.equal('USRC17607839');
+ expect(play.data.duration).to.equal(180);
+ expect(play.data.artists).to.deep.equal([{ name: 'My Artist' }]);
+ expect(play.data.albumArtists).to.deep.equal([]);
+ expect(play.data.meta.spotify.track).to.equal('track1');
+ expect(play.data.meta.spotify.album).to.equal('album1');
+ });
+
+ it('includes album artists when they differ from track artists', function () {
+ const track = fakeTrack({
+ artists: [artist('artist1', 'Featured Artist')],
+ albumArtists: [artist('artist2', 'Various Artists')],
+ });
+ const play = trackToPlay(track);
+ expect(play.data.albumArtists).to.deep.equal([{ name: 'Various Artists' }]);
+ });
+ });
+
+ describe('isCompilation', function () {
+ it('detects a compilation album', function () {
+ expect(isCompilation(fakeTrack({ albumType: 'compilation' }))).to.be.true;
+ expect(isCompilation(fakeTrack({ albumType: 'album' }))).to.be.false;
+ });
+ });
+
+ describe('rankTracksBySimilarity', function () {
+
+ const stageConfig = { type: 'spotify' } as SpotifyTransformerDataStage;
+
+ it('ranks the candidate closest to the original scrobble highest', function () {
+ const play = basePlay({ track: 'Little Joe and Mary', artists: [{ name: 'Khruangbin' }], album: 'The Universe Smiles Upon You' });
+
+ const goodMatch = fakeTrack({
+ id: 'good',
+ name: 'Little Joe and Mary',
+ artists: [artist('a1', 'Khruangbin')],
+ albumName: 'The Universe Smiles Upon You',
+ });
+ const badMatch = fakeTrack({
+ id: 'bad',
+ name: 'Some Other Song',
+ artists: [artist('a2', 'Some Other Artist')],
+ albumName: 'Some Other Album',
+ });
+
+ const ranked = rankTracksBySimilarity([badMatch, goodMatch], play, stageConfig);
+ expect(ranked[0].track.id).to.equal('good');
+ expect(ranked[0].matchScore).to.be.greaterThan(ranked[1].matchScore);
+ });
+
+ it('deprioritizes compilation matches when configured', function () {
+ const play = basePlay({ track: 'Little Joe and Mary', artists: [{ name: 'Khruangbin' }], album: 'The Universe Smiles Upon You' });
+
+ // identical text match on both candidates -- only the compilation flag differs
+ const compilationMatch = fakeTrack({ id: 'comp', albumType: 'compilation' });
+ const studioMatch = fakeTrack({ id: 'studio', albumType: 'album' });
+
+ const withoutDeprioritize = rankTracksBySimilarity([compilationMatch, studioMatch], play, stageConfig);
+ expect(withoutDeprioritize[0].matchScore).to.equal(withoutDeprioritize[1].matchScore);
+
+ const withDeprioritize = rankTracksBySimilarity([compilationMatch, studioMatch], play, { ...stageConfig, deprioritizeCompilations: true });
+ const ranked = new Map(withDeprioritize.map(x => [x.track.id, x.matchScore]));
+ expect(ranked.get('studio')).to.be.greaterThan(ranked.get('comp'));
+ expect(ranked.get('studio') - ranked.get('comp')).to.be.closeTo(COMPILATION_PENALTY, 0.0001);
+ });
+ });
+});
diff --git a/src/core/Transform.ts b/src/core/Transform.ts
index 12a1978f..5396daaa 100644
--- a/src/core/Transform.ts
+++ b/src/core/Transform.ts
@@ -21,7 +21,7 @@ export interface PlayTransformPartsAtomic {
}
export const STAGE_TYPES_USER: StageTypeUser[] = ['user'];
-export const STAGE_TYPES_METADATA: StageTypeMetadata[] = ['musicbrainz','native','rocksky'];
+export const STAGE_TYPES_METADATA: StageTypeMetadata[] = ['musicbrainz','native','rocksky','spotify'];
export const STAGE_TYPES: StageType[] = [...STAGE_TYPES_METADATA, ...STAGE_TYPES_USER];
export interface StageTyped {
@@ -173,9 +173,9 @@ export const flowControlSchema = z.object({
export type FlowControl = z.infer;
-export const stageTypeMetadataSchema = z.enum(['musicbrainz', 'native', 'rocksky']).meta({title: 'Stage Type Metadata'});
+export const stageTypeMetadataSchema = z.enum(['musicbrainz', 'native', 'rocksky', 'spotify']).meta({title: 'Stage Type Metadata'});
-export const typedStageSchema = z.enum(['musicbrainz', 'native','user', 'rocksky']).meta({title: 'Stage Type'});
+export const typedStageSchema = z.enum(['musicbrainz', 'native','user', 'rocksky', 'spotify']).meta({title: 'Stage Type'});
export type StageTypeMetadata = z.infer;
@@ -259,7 +259,7 @@ const playTransformUserStageRulesSchema = z.object({
}).meta({title: 'User Stage Rules'});
const metadataStageForUnionSchema = playTransformMetadataStageSchema.extend({
- type: z.enum(['musicbrainz','rocksky']),
+ type: z.enum(['musicbrainz','rocksky','spotify']),
}).meta({title: 'Transform External Stage'});
const playTransformTypedStageOptionsSchema = z.discriminatedUnion('type', [
--
2.51.2
From 1693d1ed940cf73769daca0b9384a5f1f7d32ae2 Mon Sep 17 00:00:00 2001
From: Digital Star System
Date: Mon, 21 Sep 2026 15:35:59 -0700
Subject: [PATCH 02/19] fix(spotify-transformer): loosen basic-search query,
fix docs anchor
The 'basic' fuzzy-search fallback ANDed track+artist+album together
into a single literal Spotify search query. Unlike Musicbrainz's fuzzy
Lucene backend, Spotify's field search is literal, so requiring an
exact album match in the query itself caused near-total misses for
real plays (verified live against a real Apple Music scrobble that
legitimately exists on Spotify). Drop the album filter from the query
and rely on the existing fuzzy ranking pass to confirm/score album
similarity afterwards, which was the original design intent.
Also fixes a broken doc anchor: linking to a section
needs an explicit id since Docusaurus only auto-anchors real headings.
---
docsite/docs/configuration/transforms/spotify.mdx | 2 +-
src/backend/common/vendor/spotify/SpotifyApiClient.ts | 7 ++++---
2 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/docsite/docs/configuration/transforms/spotify.mdx b/docsite/docs/configuration/transforms/spotify.mdx
index 03079fc9..022e5628 100644
--- a/docsite/docs/configuration/transforms/spotify.mdx
+++ b/docsite/docs/configuration/transforms/spotify.mdx
@@ -43,7 +43,7 @@ This Stage uses Spotify's [Client Credentials Flow](https://developer.spotify.co
}
```
-
+
Market and Rate Limiting
diff --git a/src/backend/common/vendor/spotify/SpotifyApiClient.ts b/src/backend/common/vendor/spotify/SpotifyApiClient.ts
index 3869a6fb..5ec799c7 100644
--- a/src/backend/common/vendor/spotify/SpotifyApiClient.ts
+++ b/src/backend/common/vendor/spotify/SpotifyApiClient.ts
@@ -109,9 +109,10 @@ export class SpotifyApiClient extends AbstractApiClient {
// and a fuzzy rank pass happens afterwards to confirm the rest of the artist credits
parts.push(`artist:${luceneQuoteIfNeeded(play.data.artists[0].name)}`);
}
- if (play.data.album !== undefined) {
- parts.push(`album:${luceneQuoteIfNeeded(play.data.album)}`);
- }
+ // intentionally NOT filtering by album here -- unlike Musicbrainz's fuzzy Lucene backend, Spotify's field
+ // search is literal, so ANDing album into the query causes near-total misses whenever the track's Spotify
+ // album metadata differs even slightly from the scrobble (singles, re-releases, etc). Album confirmation
+ // happens afterwards via fuzzy ranking instead.
const q = parts.join(' ');
const cacheKey = `spotify-search-${hashObject({ q, limit, market })}`;
--
2.51.2
From 75d7a39417f02aad9f98a5b523212f960a70f90f Mon Sep 17 00:00:00 2001
From: Digital Star System
Date: Mon, 21 Sep 2026 15:42:16 -0700
Subject: [PATCH 03/19] chore: ignore local .claude/ dev tooling config
---
.gitignore | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index b6ee35df..f09de06e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -162,4 +162,5 @@ bun.lock
**/.DS_Store
*.car
-*-rsindex/
\ No newline at end of file
+*-rsindex/
+.claude/
--
2.51.2
From 71e58eb615875a024ed0b617a543bbcd4fbc7042 Mon Sep 17 00:00:00 2001
From: Digital Star System
Date: Mon, 21 Sep 2026 16:05:25 -0700
Subject: [PATCH 04/19] docs(transforms): document unmodified-play behavior on
stage failure
Clarify what wasn't previously spelled out: a failed or skipped Stage
never partially applies changes, and by default reverts the whole Hook
back to the pre-Hook Play (with failureReturnPartial as the documented
exception). Add a concrete "ISRC Only, No Fallback" example to the
Spotify Stage doc for the common case of wanting Spotify data applied
only from a confident ID match, with no fuzzy-search fallback.
Also list the Spotify catalog alongside Musicbrainz in the docs home
page's feature summary now that the Spotify Stage exists.
---
.../docs/configuration/transforms/spotify.mdx | 19 +++++++++++++++++++
.../configuration/transforms/transforms.mdx | 10 ++++++++++
docsite/docs/index.mdx | 2 +-
3 files changed, 30 insertions(+), 1 deletion(-)
diff --git a/docsite/docs/configuration/transforms/spotify.mdx b/docsite/docs/configuration/transforms/spotify.mdx
index 022e5628..bfe8f73a 100644
--- a/docsite/docs/configuration/transforms/spotify.mdx
+++ b/docsite/docs/configuration/transforms/spotify.mdx
@@ -250,8 +250,27 @@ If `searchOrder` is undefined Multi-scrobbler defaults to using `isrc` then `bas
If all defined search methods do not return any results (or no results score high enough, see [Ranking](#ranking)) then the stage is marked as [**failed** (`onFailure`) for **Flow Control**](/configuration/transforms/#flow-control).
+**A failed Stage never partially applies -- your Play is left completely unmodified.** See [Failed and Skipped Stages](/configuration/transforms/#flow-control) for details.
+
:::
+
+
+ISRC Only, No Fallback
+
+If you only want Spotify matches sourced from ISRC lookups -- and want your Play left untouched whenever an ISRC isn't available or Spotify has no match for it -- set `searchOrder` to only `isrc`. No further configuration is needed; a Stage that fails never modifies your Play (see above).
+
+```json5
+// ...
+"defaults": {
+ // only ever search by ISRC. If a Play has no ISRC, or Spotify has no match for it,
+ // the Play is passed through completely unmodified.
+ "searchOrder": ["isrc"]
+ }
+```
+
+
+
### Ranking
Unlike Musicbrainz (whose search backend returns its own relevance score) Spotify's search results do not carry a comparable score. So Multi-scrobbler always fuzzy-matches every candidate against your original scrobble's title/artist(s)/album to:
diff --git a/docsite/docs/configuration/transforms/transforms.mdx b/docsite/docs/configuration/transforms/transforms.mdx
index 0c31ed5a..17017013 100644
--- a/docsite/docs/configuration/transforms/transforms.mdx
+++ b/docsite/docs/configuration/transforms/transforms.mdx
@@ -384,6 +384,16 @@ These three properties can be added to the [Modification Stage](#stage) data, al
* `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`](#conditional-modification) or other stage-specific skip conditions
+:::important[A Failed or Skipped Stage Never Applies Its Own Changes]
+
+`onFailure`/`onSkip` only control whether **later** Stages in the same Hook still run -- they do **not** cause a failed or skipped Stage to partially apply, or fall back to, any changes. A Stage that fails (EX no results found) or is skipped (EX a `when` condition wasn't met, or a stage-specific prerequisite wasn't met) contributes nothing to your Play.
+
+If you want a Stage to only ever apply data from a single, specific match method (EX only from an ID lookup) and do nothing at all otherwise, you don't need any special configuration for this -- it's the default behavior whenever that lookup method doesn't succeed.
+
+By default (`onFailure: "stop"`), a failure also reverts your Play all the way back to how it was **before any Stage in that Hook ran** -- so if you have multiple Stages in one Hook and a later one fails, earlier successful changes from that same Hook are discarded too. Set `failureReturnPartial: true` on the failing Stage to instead keep whatever earlier Stages in the same Hook already successfully applied, and only discard the failed Stage's own (nonexistent) contribution.
+
+:::
+
:::tip
The default behaviors for Flow Control are the same as you would intuitively think Stages should run IE the next Stage you have defined runs if the current Stage does not fail.
diff --git a/docsite/docs/index.mdx b/docsite/docs/index.mdx
index ff3e54d8..abab3f2a 100644
--- a/docsite/docs/index.mdx
+++ b/docsite/docs/index.mdx
@@ -55,7 +55,7 @@ A dockerized app that monitors your music listening activity from *everywhere* a
* [Maloja](/configuration/clients/maloja)
* [Rocksky](/configuration/clients/rocksky)
* [teal.fm](/configuration/clients/tealfm)
-* Enhance/correct scrobble data with [search patterns](/configuration/transforms), [built-in corrections](/configuration/transforms), or the [Musicbrainz](/configuration/transforms/musicbrainz) database
+* Enhance/correct scrobble data with [search patterns](/configuration/transforms), [built-in corrections](/configuration/transforms), or the [Musicbrainz](/configuration/transforms/musicbrainz) or [Spotify](/configuration/transforms/spotify) catalogs
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](/configuration#webhook-configurations) or [healthcheck endpoint](/configuration#health-endpoint)
* Supports [Now Playing](/configuration/clients#now-playing) for scrobble Clients
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
--
2.51.2
From 08bca8b1952648c872d940a80dc79e88717de6d7 Mon Sep 17 00:00:00 2001
From: Digital Star System
Date: Mon, 21 Sep 2026 17:44:21 -0700
Subject: [PATCH 05/19] fix(spotify): ISRC matches always used regardless of
fuzzy score
Title/artist text confirmed by ISRC can legitimately diverge a lot from
the scrobble source (localized titles, theatrical/movie edition
suffixes, etc), so the fuzzy score threshold was rejecting correct
ISRC matches. ISRC now identifies the match; fuzzy ranking is only
used to disambiguate when an ISRC returns more than one candidate.
Co-Authored-By: Claude Sonnet 5
---
.../docs/configuration/transforms/spotify.mdx | 14 +++-
.../common/transforms/SpotifyTransformer.ts | 30 ++++++--
.../tests/spotify/spotifyTransformer.test.ts | 70 ++++++++++++++++++-
3 files changed, 101 insertions(+), 13 deletions(-)
diff --git a/docsite/docs/configuration/transforms/spotify.mdx b/docsite/docs/configuration/transforms/spotify.mdx
index bfe8f73a..dbbc4a92 100644
--- a/docsite/docs/configuration/transforms/spotify.mdx
+++ b/docsite/docs/configuration/transforms/spotify.mdx
@@ -230,6 +230,14 @@ If your Scrobble data contains an [ISRC](https://musicbrainz.org/doc/ISRC) then
**If the ISRC is present on more than one Spotify album/track** (which happens often -- singles, re-releases, and compilation appearances of the same recording all share an ISRC) then the results are [ranked using fuzzy matching](#ranking) against your scrobble's existing album/artist data to pick the best candidate.
+:::tip[ISRC Matches Skip the Score Threshold]
+
+The ISRC itself is treated as confirmation of the match, so an ISRC result is **always used regardless of its fuzzy match [score](#score)** -- fuzzy matching is only used to pick between multiple ISRC candidates, never to reject one. This matters because Spotify's title/artist text for the same recording can differ substantially from your scrobble source (localized titles, "feat." credits, movie/theatrical edition suffixes, etc) while still being the correct match.
+
+Only results from a `basic` search are filtered by the minimum `score`.
+
+:::
+
@@ -248,7 +256,7 @@ If `searchOrder` is undefined Multi-scrobbler defaults to using `isrc` then `bas
:::note
-If all defined search methods do not return any results (or no results score high enough, see [Ranking](#ranking)) then the stage is marked as [**failed** (`onFailure`) for **Flow Control**](/configuration/transforms/#flow-control).
+If all defined search methods do not return any results (or, for a `basic` search, no results score high enough, see [Ranking](#ranking)) then the stage is marked as [**failed** (`onFailure`) for **Flow Control**](/configuration/transforms/#flow-control).
**A failed Stage never partially applies -- your Play is left completely unmodified.** See [Failed and Skipped Stages](/configuration/transforms/#flow-control) for details.
@@ -276,11 +284,11 @@ If you only want Spotify matches sourced from ISRC lookups -- and want your Play
Unlike Musicbrainz (whose search backend returns its own relevance score) Spotify's search results do not carry a comparable score. So Multi-scrobbler always fuzzy-matches every candidate against your original scrobble's title/artist(s)/album to:
* disambiguate between multiple candidates (EX an ISRC present on more than one album)
-* determine whether any candidate is a confident enough match to use at all
+* for `basic` search results only, determine whether any candidate is a confident enough match to use at all -- **an `isrc` match is always used**, see [ISRC Matches Skip the Score Threshold](#search-methods)
#### Score
-Each candidate is scored between `0` and `1` based on how similar its title/artist(s)/album are to your original scrobble. Set `score` to change the minimum score a candidate must have to be used. Default is `0.6`.
+Each candidate is scored between `0` and `1` based on how similar its title/artist(s)/album are to your original scrobble. Set `score` to change the minimum score a `basic`-search candidate must have to be used. Default is `0.6`. This has no effect on `isrc` matches, which are always used.
```json5
{
diff --git a/src/backend/common/transforms/SpotifyTransformer.ts b/src/backend/common/transforms/SpotifyTransformer.ts
index d21f61ce..4edc63ee 100644
--- a/src/backend/common/transforms/SpotifyTransformer.ts
+++ b/src/backend/common/transforms/SpotifyTransformer.ts
@@ -56,6 +56,12 @@ export interface SpotifyTransformerDataStage extends SpotifyTransformerDataStron
export interface SpotifyTrackSearchResult {
tracks: SpotifyApi.TrackObjectFull[]
requestQueries: LifecycleInput[]
+ /** Which search type produced `tracks`. When 'isrc' the ISRC itself is treated as confirmation of the match --
+ * fuzzy title/artist/album scoring is only used to disambiguate between multiple candidates (EX the same ISRC
+ * appearing on more than one album/release) and is not used to reject the match, since an ISRC-identified
+ * recording may legitimately have very different title/artist text on Spotify (localized titles, "feat." credits,
+ * movie/theatrical edition suffixes, etc) than the scrobbling source. */
+ searchType?: SpotifySearchType
}
export interface RankedSpotifyTrack {
@@ -287,7 +293,7 @@ export default class SpotifyTransformer extends AtomicPartsTransformer x.matchScore >= score);
- if (filtered.length === 0) {
- throw new StagePrerequisiteError(`All ${tracks.length} fetched matches had a score < ${score}, best match was ${ranked[0]?.matchScore.toFixed(3)}`, { shortStack: true, inputs: requestQueries });
+ let filtered: RankedSpotifyTrack[];
+ if (searchType === 'isrc') {
+ // an ISRC match already identifies the exact recording -- fuzzy scoring here is only used to pick
+ // between multiple candidates (the same ISRC on more than one album/release), not to reject the match.
+ // Title/artist text can legitimately diverge (localized titles, movie/theatrical edition suffixes, etc)
+ // for a track that is nonetheless the correct recording.
+ filtered = ranked;
+ this.logger.debug(`Using ISRC-confirmed match, skipping score threshold. Best match score of ${ranked[0].matchScore.toFixed(3)} from ${tracks.length} candidate(s)`);
+ } else {
+ filtered = ranked.filter(x => x.matchScore >= score);
+ if (filtered.length === 0) {
+ throw new StagePrerequisiteError(`All ${tracks.length} fetched matches had a score < ${score}, best match was ${ranked[0]?.matchScore.toFixed(3)}`, { shortStack: true, inputs: requestQueries });
+ }
+ this.logger.debug(`${filtered.length} of ${tracks.length} fetched matches were valid. Using match with best score of ${filtered[0].matchScore.toFixed(3)}`);
}
- this.logger.debug(`${filtered.length} of ${tracks.length} fetched matches were valid. Using match with best score of ${filtered[0].matchScore.toFixed(3)}`);
-
const spotifyPlay = trackToPlay(filtered[0].track);
spotifyPlay.meta.lifecycleInputs = [...(spotifyPlay.meta.lifecycleInputs ?? []), ...requestQueries, { type: 'spotifyTrack', input: filtered[0].track.id }];
return spotifyPlay;
diff --git a/src/backend/tests/spotify/spotifyTransformer.test.ts b/src/backend/tests/spotify/spotifyTransformer.test.ts
index dbd1a3ef..a6c1bea0 100644
--- a/src/backend/tests/spotify/spotifyTransformer.test.ts
+++ b/src/backend/tests/spotify/spotifyTransformer.test.ts
@@ -1,16 +1,23 @@
-import { expect } from 'chai';
-import { describe, it } from 'mocha';
+import { loggerTest } from '@foxxmd/logging';
+import { Cacheable } from 'cacheable';
+import chai, { expect } from 'chai';
+import asPromised from 'chai-as-promised';
+import { before, describe, it } from 'mocha';
import dayjs from 'dayjs';
import type { PlayObject } from '../../../core/Atomic.ts';
-import {
+import { initMemoryCache } from '../../common/Cache.ts';
+import SpotifyTransformer, {
COMPILATION_PENALTY,
missingSpotifyTypes,
parseStageConfig,
rankTracksBySimilarity,
+ type SpotifyTransformerConfig,
type SpotifyTransformerDataStage,
} from '../../common/transforms/SpotifyTransformer.ts';
import { isCompilation, trackToPlay } from '../../common/vendor/spotify/SpotifyApiClient.ts';
+chai.use(asPromised);
+
const basePlay = (data: Partial = {}, meta: Partial = {}): PlayObject => ({
data: {
track: 'My Track',
@@ -65,6 +72,25 @@ const fakeTrack = (opts: {
} as unknown as SpotifyApi.TrackObjectFull;
};
+const memorycache = () => new Cacheable({ primary: initMemoryCache({ ttl: '1ms' }) });
+
+const createSpotifyTransformer = (config: Partial = {}) => {
+ const transformer = new SpotifyTransformer({
+ name: 'test',
+ type: 'spotify',
+ data: {
+ clientId: 'test-client-id',
+ clientSecret: 'test-client-secret',
+ },
+ ...config,
+ } as SpotifyTransformerConfig, {
+ logger: loggerTest,
+ cache: memorycache(),
+ clientCache: memorycache(),
+ });
+ return transformer;
+}
+
describe('Spotify Transformer', function () {
describe('parseStageConfig', function () {
@@ -188,4 +214,42 @@ describe('Spotify Transformer', function () {
expect(ranked.get('studio') - ranked.get('comp')).to.be.closeTo(COMPILATION_PENALTY, 0.0001);
});
});
+
+ describe('handlePostFetch', function () {
+
+ const stageConfig = { type: 'spotify' } as SpotifyTransformerDataStage;
+
+ let transformer: SpotifyTransformer;
+
+ before(async function () {
+ transformer = createSpotifyTransformer();
+ await transformer.initialize();
+ });
+
+ it('uses an ISRC match even when its title/artist text scores below the minimum threshold', async function () {
+ // scrobble source title is drastically different from the Spotify catalog title (localized/theatrical
+ // edition naming) but the ISRC identifies it as the same recording
+ const play = basePlay({ track: 'KAISEI:Movie Edition from Project SEKAI', artists: [{ name: 'Project SEKAI' }], isrc: 'JPPO02201234' });
+ const track = fakeTrack({ name: '快晴「劇場版プロジェクトセカイ」ver.', artists: [artist('a1', 'Project SEKAI')], isrc: 'JPPO02201234' });
+
+ const result = await transformer.handlePostFetch(play, { tracks: [track], requestQueries: [], searchType: 'isrc' }, stageConfig);
+ expect(result.data.track).to.equal('快晴「劇場版プロジェクトセカイ」ver.');
+ });
+
+ it('still filters a basic-search match by the minimum score threshold', async function () {
+ const play = basePlay({ track: 'KAISEI:Movie Edition from Project SEKAI', artists: [{ name: 'Project SEKAI' }], album: 'Original Soundtrack' });
+ const track = fakeTrack({ name: '快晴「劇場版プロジェクトセカイ」ver.', artists: [artist('a1', 'Project SEKAI')], albumName: 'Theatrical Edition Single' });
+
+ await expect(transformer.handlePostFetch(play, { tracks: [track], requestQueries: [], searchType: 'basic' }, stageConfig)).to.be.rejected;
+ });
+
+ it('picks the best-matching candidate by fuzzy score when an ISRC returns more than one album', async function () {
+ const play = basePlay({ track: 'My Track', artists: [{ name: 'My Artist' }], album: 'The Real Album', isrc: 'USRC17607839' });
+ const wrongAlbum = fakeTrack({ id: 'wrong', albumName: 'Some Compilation', isrc: 'USRC17607839' });
+ const rightAlbum = fakeTrack({ id: 'right', albumName: 'The Real Album', isrc: 'USRC17607839' });
+
+ const result = await transformer.handlePostFetch(play, { tracks: [wrongAlbum, rightAlbum], requestQueries: [], searchType: 'isrc' }, stageConfig);
+ expect(result.data.meta.spotify.track).to.equal('right');
+ });
+ });
});
--
2.51.2
From 2a1354a4381c8f9934b144ea01fa2a45bfd2cc58 Mon Sep 17 00:00:00 2001
From: Digital Star System
Date: Tue, 22 Sep 2026 00:03:11 -0700
Subject: [PATCH 06/19] feat(spotify): add locale config option for catalog
name language
Spotify's search API can return localized artist/album/track names
(EX Japanese script for a JP-registered catalog entry) and market
alone doesn't reliably control this. Add an optional locale
(ISO-639-1_ISO-3166-1) passed through to search calls, usable
alongside market, to bias which translation is returned.
Co-Authored-By: Claude Sonnet 5
---
.../docs/configuration/transforms/spotify.mdx | 27 ++++++++++++++++---
.../common/transforms/SpotifyTransformer.ts | 10 ++++---
.../spotify/SpotifyTransformerUtil.ts | 9 ++++++-
.../common/vendor/spotify/SpotifyApiClient.ts | 13 ++++-----
.../common/vendor/spotify/SpotifyTypes.ts | 7 +++++
5 files changed, 52 insertions(+), 14 deletions(-)
diff --git a/docsite/docs/configuration/transforms/spotify.mdx b/docsite/docs/configuration/transforms/spotify.mdx
index dbbc4a92..919fb878 100644
--- a/docsite/docs/configuration/transforms/spotify.mdx
+++ b/docsite/docs/configuration/transforms/spotify.mdx
@@ -43,9 +43,9 @@ This Stage uses Spotify's [Client Credentials Flow](https://developer.spotify.co
}
```
-
+
-Market and Rate Limiting
+Market, Locale, and Rate Limiting
`market` (an [ISO 3166-1 alpha-2 country code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2)) can be set to bias/limit search results to what is available in a specific market:
@@ -61,6 +61,26 @@ This Stage uses Spotify's [Client Credentials Flow](https://developer.spotify.co
}
```
+`locale` (in `ISO-639-1_ISO-3166-1` format, EX `en_US`, `ja_JP`) can additionally be set to try to bias which translation of a localized catalog name (artist/album/track) Spotify returns for a match:
+
+```json5
+{
+ "type": "spotify",
+ "name": "MySpotify",
+ "data": {
+ "clientId": "787c921a2a2ab42320831aba0c8f2fc2",
+ "clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
+ "locale": "en_US"
+ },
+}
+```
+
+:::note
+
+`locale` is not an officially documented parameter of the Spotify Web API and its behavior can be inconsistent -- some catalog entries only exist with one language's name at all, in which case no `locale`/`market` combination will produce an alternate translation because Spotify doesn't have one to return. Try setting `market` to your account's actual region first; add `locale` on top of that if names still aren't coming back in your preferred language.
+
+:::
+
`rate` can be used to configure how many requests are made to the Spotify API, defined by **max number of requests** within **timespan of N seconds** (default `10 req/1s`):
```json5
@@ -141,7 +161,8 @@ Use `SPOTIFY_TRANSFORM=true` to enable this Stage from ENV. `clientId`/`clientSe
* `SPOTIFY_TRANSFORM=true` - Enables this Stage
* `SPOTIFY_TRANSFORM_CLIENT_ID` / `SPOTIFY_TRANSFORM_CLIENT_SECRET` - App credentials (optional if `SPOTIFY_CLIENT_ID`/`SPOTIFY_CLIENT_SECRET` are already set)
-* `SPOTIFY_TRANSFORM_MARKET` - Optional [market](#market-and-rate-limiting)
+* `SPOTIFY_TRANSFORM_MARKET` - Optional [market](#market-locale-and-rate-limiting)
+* `SPOTIFY_TRANSFORM_LOCALE` - Optional [locale](#market-locale-and-rate-limiting)
* `SPOTIFY_TRANSFORM_DEPRIORITIZE_COMPILATIONS=true` - Optionally enable [`deprioritizeCompilations`](#deprioritize-compilations)
Finally, use ENV `*_TRANSFORMS=spotify` on each Source/Client you wish to apply this stage to. This applies the stage in the [`preCompare` Hook](/configuration/transforms#lifecycle-hooks) with all [Rules](#rules-and-hooks) enabled.
diff --git a/src/backend/common/transforms/SpotifyTransformer.ts b/src/backend/common/transforms/SpotifyTransformer.ts
index 4edc63ee..37d03579 100644
--- a/src/backend/common/transforms/SpotifyTransformer.ts
+++ b/src/backend/common/transforms/SpotifyTransformer.ts
@@ -316,9 +316,10 @@ export default class SpotifyTransformer extends AtomicPartsTransformer {
@@ -327,9 +328,10 @@ export default class SpotifyTransformer extends AtomicPartsTransformer {
diff --git a/src/backend/common/transforms/spotify/SpotifyTransformerUtil.ts b/src/backend/common/transforms/spotify/SpotifyTransformerUtil.ts
index dcd6f3b0..4e92f198 100644
--- a/src/backend/common/transforms/spotify/SpotifyTransformerUtil.ts
+++ b/src/backend/common/transforms/spotify/SpotifyTransformerUtil.ts
@@ -21,6 +21,12 @@ export interface SpotifyTransformerData {
searchOrder?: SpotifySearchType[]
/** An ISO 3166-1 alpha-2 country code used to bias/limit search results to what is available in this market */
market?: string
+ /** A locale (EX en_US, ja_JP) used to try to bias which translation of a localized catalog name
+ * (artist/album/track) the Spotify API returns. Not officially documented by Spotify -- results may be
+ * inconsistent -- but can be used alongside (or instead of) `market` to try to force names into a
+ * specific language.
+ */
+ locale?: string
/** Deprioritize (but do not exclude) matches whose album is a compilation when ranking candidates
*
* @default false
@@ -59,7 +65,8 @@ export const configFromEnv = (logger: MaybeLogger = new MaybeLogger()): SpotifyT
data: {
clientId,
clientSecret,
- market: process.env.SPOTIFY_TRANSFORM_MARKET
+ market: process.env.SPOTIFY_TRANSFORM_MARKET,
+ locale: process.env.SPOTIFY_TRANSFORM_LOCALE
},
defaults: {
...(deprioritizeCompilations ? { deprioritizeCompilations } : {})
diff --git a/src/backend/common/vendor/spotify/SpotifyApiClient.ts b/src/backend/common/vendor/spotify/SpotifyApiClient.ts
index 5ec799c7..89769138 100644
--- a/src/backend/common/vendor/spotify/SpotifyApiClient.ts
+++ b/src/backend/common/vendor/spotify/SpotifyApiClient.ts
@@ -15,6 +15,7 @@ import type { SpotifyTransformerApiConfigData } from "./SpotifyTypes.ts";
export interface SpotifySearchOptions {
limit?: number
market?: string
+ locale?: string
useCachedResult?: boolean
}
@@ -89,16 +90,16 @@ export class SpotifyApiClient extends AbstractApiClient {
}
searchByIsrc = async (isrc: string, opts: SpotifySearchOptions = {}): Promise => {
- const { limit = 50, market = this.config.market, useCachedResult } = opts;
+ const { limit = 50, market = this.config.market, locale = this.config.locale, useCachedResult } = opts;
const q = `isrc:${isrcNoHyphens(isrc)}`;
- const cacheKey = `spotify-search-${hashObject({ q, limit, market })}`;
+ const cacheKey = `spotify-search-${hashObject({ q, limit, market, locale })}`;
this.logger.debug({ labels: ['ISRC Search'] }, `Search Query => ${q}`);
- const res = await this.callApi((api) => api.searchTracks(q, { limit, market }), { cacheKey, useCachedResult });
+ const res = await this.callApi((api) => api.searchTracks(q, { limit, market, ...(locale !== undefined ? { locale } : {}) }), { cacheKey, useCachedResult });
return res.body.tracks?.items ?? [];
}
searchByFields = async (play: PlayObject, opts: SpotifySearchOptions = {}): Promise => {
- const { limit = 50, market = this.config.market, useCachedResult } = opts;
+ const { limit = 50, market = this.config.market, locale = this.config.locale, useCachedResult } = opts;
const parts: string[] = [];
if (play.data.track !== undefined) {
@@ -115,9 +116,9 @@ export class SpotifyApiClient extends AbstractApiClient {
// happens afterwards via fuzzy ranking instead.
const q = parts.join(' ');
- const cacheKey = `spotify-search-${hashObject({ q, limit, market })}`;
+ const cacheKey = `spotify-search-${hashObject({ q, limit, market, locale })}`;
this.logger.debug({ labels: ['Basic Search'] }, `Search Query => ${q}`);
- const res = await this.callApi((api) => api.searchTracks(q, { limit, market }), { cacheKey, useCachedResult });
+ const res = await this.callApi((api) => api.searchTracks(q, { limit, market, ...(locale !== undefined ? { locale } : {}) }), { cacheKey, useCachedResult });
return res.body.tracks?.items ?? [];
}
diff --git a/src/backend/common/vendor/spotify/SpotifyTypes.ts b/src/backend/common/vendor/spotify/SpotifyTypes.ts
index d6c2d981..87a58504 100644
--- a/src/backend/common/vendor/spotify/SpotifyTypes.ts
+++ b/src/backend/common/vendor/spotify/SpotifyTypes.ts
@@ -15,6 +15,13 @@ export interface SpotifyTransformerApiConfigData {
* An ISO 3166-1 alpha-2 country code. Limits/biases search results to what is available in this market.
*/
market?: string
+ /**
+ * A locale in ISO-639-1_ISO-3166-1 format (EX en_US, ja_JP) used to bias which translation of a localized
+ * catalog name (artist/album/track) the Spotify API returns. Support for this is not officially documented
+ * by Spotify and results may be inconsistent, but it can be used alongside (or instead of) `market` to try
+ * to force names into a specific language.
+ */
+ locale?: string
rate?: {
/** max number of requests allowed during perTime unit of time
*
--
2.51.2
From 1e82db31648dfb6b2e19e2d8b7ae9244a596b4d9 Mon Sep 17 00:00:00 2001
From: Digital Star System
Date: Tue, 22 Sep 2026 00:12:38 -0700
Subject: [PATCH 07/19] fix(spotify): log market/locale used for each search
query
The search debug log only printed the Lucene query string, so market
and locale being applied (or not) was unobservable from logs.
Co-Authored-By: Claude Sonnet 5
---
src/backend/common/vendor/spotify/SpotifyApiClient.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/backend/common/vendor/spotify/SpotifyApiClient.ts b/src/backend/common/vendor/spotify/SpotifyApiClient.ts
index 89769138..6c1cccc8 100644
--- a/src/backend/common/vendor/spotify/SpotifyApiClient.ts
+++ b/src/backend/common/vendor/spotify/SpotifyApiClient.ts
@@ -93,7 +93,7 @@ export class SpotifyApiClient extends AbstractApiClient {
const { limit = 50, market = this.config.market, locale = this.config.locale, useCachedResult } = opts;
const q = `isrc:${isrcNoHyphens(isrc)}`;
const cacheKey = `spotify-search-${hashObject({ q, limit, market, locale })}`;
- this.logger.debug({ labels: ['ISRC Search'] }, `Search Query => ${q}`);
+ this.logger.debug({ labels: ['ISRC Search'] }, `Search Query => ${q} | market: ${market ?? '(none)'} | locale: ${locale ?? '(none)'}`);
const res = await this.callApi((api) => api.searchTracks(q, { limit, market, ...(locale !== undefined ? { locale } : {}) }), { cacheKey, useCachedResult });
return res.body.tracks?.items ?? [];
}
@@ -117,7 +117,7 @@ export class SpotifyApiClient extends AbstractApiClient {
const q = parts.join(' ');
const cacheKey = `spotify-search-${hashObject({ q, limit, market, locale })}`;
- this.logger.debug({ labels: ['Basic Search'] }, `Search Query => ${q}`);
+ this.logger.debug({ labels: ['Basic Search'] }, `Search Query => ${q} | market: ${market ?? '(none)'} | locale: ${locale ?? '(none)'}`);
const res = await this.callApi((api) => api.searchTracks(q, { limit, market, ...(locale !== undefined ? { locale } : {}) }), { cacheKey, useCachedResult });
return res.body.tracks?.items ?? [];
}
--
2.51.2
From fc8ffa74ee957ff492b3eba5d4abe2a6087bef64 Mon Sep 17 00:00:00 2001
From: Digital Star System
Date: Tue, 22 Sep 2026 00:18:36 -0700
Subject: [PATCH 08/19] fix(spotify): actually pass data.locale to the API
client
doBuildInitData built the SpotifyApiClient config from clientId/
clientSecret/market/rate but dropped locale on the floor, so
data.locale was silently never applied to any search call.
Co-Authored-By: Claude Sonnet 5
---
src/backend/common/transforms/SpotifyTransformer.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/backend/common/transforms/SpotifyTransformer.ts b/src/backend/common/transforms/SpotifyTransformer.ts
index 37d03579..0c0bb6c5 100644
--- a/src/backend/common/transforms/SpotifyTransformer.ts
+++ b/src/backend/common/transforms/SpotifyTransformer.ts
@@ -210,6 +210,7 @@ export default class SpotifyTransformer extends AtomicPartsTransformer
Date: Tue, 22 Sep 2026 00:26:25 -0700
Subject: [PATCH 09/19] docs(spotify): add concrete locale example, correct
market claim
Verified against the live Spotify API: market alone does not affect
returned catalog name, only locale does. Use ATLUS Sound Team as a
real worked example instead of a hypothetical.
Co-Authored-By: Claude Sonnet 5
---
docsite/docs/configuration/transforms/spotify.mdx | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/docsite/docs/configuration/transforms/spotify.mdx b/docsite/docs/configuration/transforms/spotify.mdx
index 919fb878..ef076fb7 100644
--- a/docsite/docs/configuration/transforms/spotify.mdx
+++ b/docsite/docs/configuration/transforms/spotify.mdx
@@ -75,9 +75,15 @@ This Stage uses Spotify's [Client Credentials Flow](https://developer.spotify.co
}
```
+:::tip[Example]
+
+Some catalog entries carry a translated name depending on locale, independent of `market`. EX the Japanese game-music artist "ATLUS Sound Team" is stored in Spotify's catalog under multiple localized names for the exact same artist ID -- without `locale` set, a lookup returns `アトラスサウンドチーム`; with `"locale": "en_US"` the same lookup returns `ATLUS Sound Team`. `market` alone does **not** control this -- it was tested independently and had no effect on the returned name for this artist.
+
+:::
+
:::note
-`locale` is not an officially documented parameter of the Spotify Web API and its behavior can be inconsistent -- some catalog entries only exist with one language's name at all, in which case no `locale`/`market` combination will produce an alternate translation because Spotify doesn't have one to return. Try setting `market` to your account's actual region first; add `locale` on top of that if names still aren't coming back in your preferred language.
+`locale` is not an officially documented parameter of the Spotify Web API and its behavior can be inconsistent -- some catalog entries only exist with one language's name at all, in which case no `locale`/`market` combination will produce an alternate translation because Spotify doesn't have one to return.
:::
--
2.51.2
From 0389f192feea95808de68d881f849e95ce945ec8 Mon Sep 17 00:00:00 2001
From: Digital Star System
Date: Wed, 23 Sep 2026 10:44:11 -0700
Subject: [PATCH 10/19] fix(spotify): fix test import path after PR #724 rebase
PR #724 restructured TransformerManager to lazily dynamic-import
transformer classes, moving Spotify's light config types/configFromEnv
into a new spotify/SpotifyTransformerUtil.ts (mirroring the Musicbrainz
and Rocksky transformers) so the heavy vendor client isn't pulled in
eagerly at startup. Update the test's import path to match.
Co-Authored-By: Claude Sonnet 5
---
src/backend/tests/spotify/spotifyTransformer.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/backend/tests/spotify/spotifyTransformer.test.ts b/src/backend/tests/spotify/spotifyTransformer.test.ts
index a6c1bea0..ffef3149 100644
--- a/src/backend/tests/spotify/spotifyTransformer.test.ts
+++ b/src/backend/tests/spotify/spotifyTransformer.test.ts
@@ -11,9 +11,9 @@ import SpotifyTransformer, {
missingSpotifyTypes,
parseStageConfig,
rankTracksBySimilarity,
- type SpotifyTransformerConfig,
type SpotifyTransformerDataStage,
} from '../../common/transforms/SpotifyTransformer.ts';
+import type { SpotifyTransformerConfig } from '../../common/transforms/spotify/SpotifyTransformerUtil.ts';
import { isCompilation, trackToPlay } from '../../common/vendor/spotify/SpotifyApiClient.ts';
chai.use(asPromised);
--
2.51.2
From 9f6740476a260a1aa06aeb9ea1a3712a31d6f7b6 Mon Sep 17 00:00:00 2001
From: Digital Star System
Date: Wed, 23 Sep 2026 11:08:44 -0700
Subject: [PATCH 11/19] fix(transforms): match transformer names
case-insensitively when lazily registering
TransformerManager's lazy-registration lookups (hasTransformerConfigByIdentifiers,
registerByIdentifiers) compared names with strict equality, while the
already-registered-instance lookup a few lines down did a case-insensitive
comparison. Any hook whose stage 'name' didn't exactly match the declared
transformer config's casing (EX "spotifyTransformer" vs "SpotifyTransformer")
would fail with "No transformer configuration of type '...' with name '...'
exists." even though it worked before PR #724 introduced the lazy two-tier
config/registered split. Normalize both lookups to match the existing
case-insensitive convention.
Co-Authored-By: Claude Sonnet 5
---
.../common/transforms/TransformerManager.ts | 4 +--
.../tests/component/transformers.test.ts | 33 +++++++++++++++++++
2 files changed, 35 insertions(+), 2 deletions(-)
diff --git a/src/backend/common/transforms/TransformerManager.ts b/src/backend/common/transforms/TransformerManager.ts
index 2a531fcf..e0ae5d28 100644
--- a/src/backend/common/transforms/TransformerManager.ts
+++ b/src/backend/common/transforms/TransformerManager.ts
@@ -42,7 +42,7 @@ export default class TransformerManager {
}
public hasTransformerConfigByIdentifiers(type: string, name: string = DEFAULT_TRANSFORMER_NAME) {
- return this.transformerConfigs.some(x => x.type === type && x.name === name);
+ return this.transformerConfigs.some(x => x.type === type && x.name.toLocaleLowerCase().trim() === name.toLocaleLowerCase().trim());
}
public hasTransformerConfigByType(type: string) {
@@ -55,7 +55,7 @@ export default class TransformerManager {
this.logger.debug(`Transformer type ${type} with name ${name} already registered`);
return;
}
- const config = this.transformerConfigs.find(x => x.name === name && x.type === type);
+ const config = this.transformerConfigs.find(x => x.type === type && x.name.toLocaleLowerCase().trim() === name.toLocaleLowerCase().trim());
if(config === undefined) {
throw new Error(`No existing configuration for transformer of type ${type} with name ${name} exists`);
}
diff --git a/src/backend/tests/component/transformers.test.ts b/src/backend/tests/component/transformers.test.ts
index b2e7589e..b8a39b54 100644
--- a/src/backend/tests/component/transformers.test.ts
+++ b/src/backend/tests/component/transformers.test.ts
@@ -792,6 +792,39 @@ describe('Play Transforms', function () {
const transformed = await multiTransformComponent.transformPlay(play, TRANSFORM_HOOK.preCompare);
expect(transformed.data.track).eq('My Bar Title');
});
+
+ it('Resolves a lazily-registered transformer by name regardless of case', async function() {
+ const tmanager = new TransformerManager(loggerTest, transientCache());
+ tmanager.addTransformerConfig({
+ type: 'user',
+ name: 'MyTransformer',
+ defaults: {
+ title: [
+ {
+ search: "Cool",
+ replace: "Fun"
+ }
+ ]
+ }
+ });
+
+ const play = generatePlay({track: 'My Cool Track'});
+
+ const component = createTestComponent({transformManager: tmanager});
+ component.config.options = {
+ playTransform: {
+ preCompare: [
+ {
+ type: "user",
+ name: "mytransformer"
+ }
+ ]
+ }
+ };
+ await component.buildTransformRules();
+ const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare);
+ expect(transformed.data.track).eq('My Fun Track');
+ });
});
})
--
2.51.2
From 254021d8a7bf7a060135dcf797e8f62b94f21f4b Mon Sep 17 00:00:00 2001
From: Digital Star System
Date: Wed, 23 Sep 2026 11:10:11 -0700
Subject: [PATCH 12/19] Revert "fix(transforms): match transformer names
case-insensitively when lazily registering"
This reverts commit 9f6740476a260a1aa06aeb9ea1a3712a31d6f7b6.
---
.../common/transforms/TransformerManager.ts | 4 +--
.../tests/component/transformers.test.ts | 33 -------------------
2 files changed, 2 insertions(+), 35 deletions(-)
diff --git a/src/backend/common/transforms/TransformerManager.ts b/src/backend/common/transforms/TransformerManager.ts
index e0ae5d28..2a531fcf 100644
--- a/src/backend/common/transforms/TransformerManager.ts
+++ b/src/backend/common/transforms/TransformerManager.ts
@@ -42,7 +42,7 @@ export default class TransformerManager {
}
public hasTransformerConfigByIdentifiers(type: string, name: string = DEFAULT_TRANSFORMER_NAME) {
- return this.transformerConfigs.some(x => x.type === type && x.name.toLocaleLowerCase().trim() === name.toLocaleLowerCase().trim());
+ return this.transformerConfigs.some(x => x.type === type && x.name === name);
}
public hasTransformerConfigByType(type: string) {
@@ -55,7 +55,7 @@ export default class TransformerManager {
this.logger.debug(`Transformer type ${type} with name ${name} already registered`);
return;
}
- const config = this.transformerConfigs.find(x => x.type === type && x.name.toLocaleLowerCase().trim() === name.toLocaleLowerCase().trim());
+ const config = this.transformerConfigs.find(x => x.name === name && x.type === type);
if(config === undefined) {
throw new Error(`No existing configuration for transformer of type ${type} with name ${name} exists`);
}
diff --git a/src/backend/tests/component/transformers.test.ts b/src/backend/tests/component/transformers.test.ts
index b8a39b54..b2e7589e 100644
--- a/src/backend/tests/component/transformers.test.ts
+++ b/src/backend/tests/component/transformers.test.ts
@@ -792,39 +792,6 @@ describe('Play Transforms', function () {
const transformed = await multiTransformComponent.transformPlay(play, TRANSFORM_HOOK.preCompare);
expect(transformed.data.track).eq('My Bar Title');
});
-
- it('Resolves a lazily-registered transformer by name regardless of case', async function() {
- const tmanager = new TransformerManager(loggerTest, transientCache());
- tmanager.addTransformerConfig({
- type: 'user',
- name: 'MyTransformer',
- defaults: {
- title: [
- {
- search: "Cool",
- replace: "Fun"
- }
- ]
- }
- });
-
- const play = generatePlay({track: 'My Cool Track'});
-
- const component = createTestComponent({transformManager: tmanager});
- component.config.options = {
- playTransform: {
- preCompare: [
- {
- type: "user",
- name: "mytransformer"
- }
- ]
- }
- };
- await component.buildTransformRules();
- const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare);
- expect(transformed.data.track).eq('My Fun Track');
- });
});
})
--
2.51.2
From 567836634ff34c96e817ae13c61a545e078b2c12 Mon Sep 17 00:00:00 2001
From: FoxxMD
Date: Wed, 23 Sep 2026 20:27:52 +0000
Subject: [PATCH 13/19] fix: fix bad merge oops
---
src/backend/common/transforms/TransformerManager.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/backend/common/transforms/TransformerManager.ts b/src/backend/common/transforms/TransformerManager.ts
index 787e3e4f..cb564ed2 100644
--- a/src/backend/common/transforms/TransformerManager.ts
+++ b/src/backend/common/transforms/TransformerManager.ts
@@ -102,6 +102,7 @@ export default class TransformerManager {
case 'spotify': {
const SpotifyTransformer = (await import("./SpotifyTransformer.ts")).default;
t = new SpotifyTransformer({ name: tName, ...config as SpotifyTransformerConfig }, {logger: tLogger, regexCache: this.cache.regexCache, cache: this.cache.cacheTransform});
+ } break;
case 'coverartarchive': {
const CovertArtArchiveTransformer = (await import("./coverartarchive/CoverArtArchiveTransformer.ts")).default;
t = new CovertArtArchiveTransformer({ name: tName, ...config as CovertArtArchiveTransformerConfig }, {logger: tLogger, regexCache: this.cache.regexCache, cache: this.cache.cacheTransform});
--
2.51.2
From 09379d50b41d79ebb3833e29667b68d60bfbb54a Mon Sep 17 00:00:00 2001
From: Digital Star System
Date: Wed, 23 Sep 2026 14:22:36 -0700
Subject: [PATCH 14/19] docs(spotify): warn that locale is undocumented and may
break
Clarify that locale is an undocumented Spotify Web API parameter that may
change or break without notice, and drop the Client Credentials Flow link
from the API setup blurb.
Co-Authored-By: Claude Sonnet 5
---
docsite/docs/configuration/transforms/spotify.mdx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docsite/docs/configuration/transforms/spotify.mdx b/docsite/docs/configuration/transforms/spotify.mdx
index ef076fb7..1d42983f 100644
--- a/docsite/docs/configuration/transforms/spotify.mdx
+++ b/docsite/docs/configuration/transforms/spotify.mdx
@@ -25,7 +25,7 @@ Set up [Valkey Caching](/configuration?cachedThings=metadata#caching) to cache S
### API Setup
-This Stage uses Spotify's [Client Credentials Flow](https://developer.spotify.com/documentation/web-api/tutorials/client-credentials-flow) to search/lookup the Spotify catalog. This does **not** require a user to log in -- only an app `clientId`/`clientSecret` is needed. [Create a Spotify application](https://developer.spotify.com/documentation/web-api/concepts/apps) if you don't already have one (the same application used for the [Spotify Source](/configuration/sources/spotify) can be reused here).
+This Stage uses Spotify's API to search/lookup the Spotify catalog. This does **not** require a user to log in -- only an app `clientId`/`clientSecret` is needed. [Create a Spotify application](https://developer.spotify.com/documentation/web-api/concepts/apps) if you don't already have one (the same application used for the [Spotify Source](/configuration/sources/spotify) can be reused here).
```json5 title="config.json"
{
@@ -83,7 +83,7 @@ Some catalog entries carry a translated name depending on locale, independent of
:::note
-`locale` is not an officially documented parameter of the Spotify Web API and its behavior can be inconsistent -- some catalog entries only exist with one language's name at all, in which case no `locale`/`market` combination will produce an alternate translation because Spotify doesn't have one to return.
+`locale` is an **UNDOCUMENTED** parameter of the Spotify Web API **and may break at any time, without notice or warning**. Its behavior may also be inconsistent - some catalog entries only exist with one language's name at all, in which case no `locale`/`market` combination will produce an alternate translation because Spotify doesn't have one to return.
:::
--
2.51.2
From 4e5e48bde3f4afaf9d4f5d5879523097fba69361 Mon Sep 17 00:00:00 2001
From: FoxxMD
Date: Thu, 24 Sep 2026 20:42:05 +0000
Subject: [PATCH 15/19] feat(transform): Implement isrc transform as part of
meta
---
.../common/transforms/AtomicPartsTransformer.ts | 14 ++++++++++----
.../common/transforms/MusicbrainzTransformer.ts | 6 +++---
.../transforms/rocksky/RockskyTransformer.ts | 2 +-
src/core/Atomic.ts | 4 ++++
4 files changed, 18 insertions(+), 8 deletions(-)
diff --git a/src/backend/common/transforms/AtomicPartsTransformer.ts b/src/backend/common/transforms/AtomicPartsTransformer.ts
index 52682436..ed4a0961 100644
--- a/src/backend/common/transforms/AtomicPartsTransformer.ts
+++ b/src/backend/common/transforms/AtomicPartsTransformer.ts
@@ -1,4 +1,4 @@
-import { type ArtistCredit, type ArtMeta, isPlayObject, type ObjectPlayData, type PlayObject, type TrackMeta } from "../../../core/Atomic.ts";
+import { type ArtistCredit, type ArtMeta, isPlayObject, type ObjectPlayData, type PlayObject, type TrackMetaIsrc } from "../../../core/Atomic.ts";
import type {AtomicStageConfig, StageConfig} from "../../../core/Transform.ts";
import AbstractTransformer from "./AbstractTransformer.ts";
@@ -84,7 +84,7 @@ export default abstract class AtomicPartsTransformer {
+ protected async handleMeta(play: PlayObject, parts: Y, transformData: T): Promise {
return undefined;
}
diff --git a/src/backend/common/transforms/MusicbrainzTransformer.ts b/src/backend/common/transforms/MusicbrainzTransformer.ts
index 29e260b2..c6902532 100644
--- a/src/backend/common/transforms/MusicbrainzTransformer.ts
+++ b/src/backend/common/transforms/MusicbrainzTransformer.ts
@@ -1,4 +1,4 @@
-import { type ArtistCredit, asMBReleasePrimaryGroupType, asMBReleaseSecondaryGroupType, asMBReleaseStatus, DEFAULT_MISSING_TYPES, type LifecycleInput, type MBReleaseGroupPrimaryType, type MBReleaseGroupSecondaryType, type MBReleaseStatus, type MissingMbidType, type OptionalCacheUsage, type PlayObject, type TrackMeta } from "../../../core/Atomic.ts";
+import { type ArtistCredit, asMBReleasePrimaryGroupType, asMBReleaseSecondaryGroupType, asMBReleaseStatus, DEFAULT_MISSING_TYPES, type LifecycleInput, type MBReleaseGroupPrimaryType, type MBReleaseGroupSecondaryType, type MBReleaseStatus, type MissingMbidType, type OptionalCacheUsage, type PlayObject, type TrackMeta, type TrackMetaIsrc } from "../../../core/Atomic.ts";
import { isWhenCondition, testWhenConditions } from "../../utils/PlayTransformUtils.ts";
import type {WebhookPayload} from "../infrastructure/config/health/webhooks.ts";
import type {ExternalMetadataTerm, PlayTransformMetadataStage} from "../../../core/Transform.ts";
@@ -628,7 +628,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer {
+ protected async handleMeta(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise {
if (parts === false) {
return play.data.meta;
}
@@ -640,7 +640,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer({...transformData.data.meta, isrc: transformData.data.isrc});
}
public notify(payload: WebhookPayload): Promise {
diff --git a/src/backend/common/transforms/rocksky/RockskyTransformer.ts b/src/backend/common/transforms/rocksky/RockskyTransformer.ts
index b3a765e4..3d40bd84 100644
--- a/src/backend/common/transforms/rocksky/RockskyTransformer.ts
+++ b/src/backend/common/transforms/rocksky/RockskyTransformer.ts
@@ -578,7 +578,7 @@ export default class RockskyTransformer extends AtomicPartsTransformer {
+ protected async handleMeta(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise {
if (parts === false) {
return play.data.meta;
}
diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts
index 64a62ea2..f3d25315 100644
--- a/src/core/Atomic.ts
+++ b/src/core/Atomic.ts
@@ -147,6 +147,10 @@ export interface TrackMeta {
spotify?: SpotifyMeta
}
+export interface TrackMetaIsrc extends TrackMeta {
+ isrc?: string
+}
+
export interface TrackData {
artists?: ArtistCredit[]
albumArtists?: ArtistCredit[]
--
2.51.2
From 03c34a1b72439d0dadf00ca75781d47a48bc29f0 Mon Sep 17 00:00:00 2001
From: FoxxMD
Date: Thu, 24 Sep 2026 20:44:42 +0000
Subject: [PATCH 16/19] feat(transform): Improved spotify transform dev
ergonimics and functionality
* Add isrc as search type and as meta transform
* Change missing types to search by fields with explicit search for spotify ids
* Add art to spotify transform play and add art transform function
* remove claude slop comments
* Update docs to reflect changed functionality and refine claude slop writing
---
.../docs/configuration/transforms/spotify.mdx | 40 ++++------
.../common/transforms/SpotifyTransformer.ts | 76 ++++++++++++-------
.../spotify/SpotifyTransformerUtil.ts | 11 ++-
.../common/vendor/spotify/SpotifyApiClient.ts | 46 ++++++++++-
4 files changed, 115 insertions(+), 58 deletions(-)
diff --git a/docsite/docs/configuration/transforms/spotify.mdx b/docsite/docs/configuration/transforms/spotify.mdx
index 1d42983f..c6a4d5db 100644
--- a/docsite/docs/configuration/transforms/spotify.mdx
+++ b/docsite/docs/configuration/transforms/spotify.mdx
@@ -231,11 +231,18 @@ Matching your Scrobble's Play data with a result from Spotify is comprised of tw
#### Should MS Search?
-Before MS begins a search it checks if your Scrobble data already contains Spotify IDs for artist(s), album, and track, and a duration. If it already has all required data types then the entire Spotify Stage [is **skipped**.](/configuration/transforms/#flow-control) If any are missing then a search is performed.
+Before MS begins a search it checks if your Scrobble data already contains:
+
+* A track title, artists, album, and duration
+* All spotify IDs and an ISRC
+
+If it already has all required data types then the entire Spotify Stage [is **skipped**.](/configuration/transforms/#flow-control) If any are missing then a search is performed.
Define which data types are required using:
-* `searchWhenMissing` (defaults to all) - A list containing any of: `artists` `title` `album` `duration`
+* `searchWhenMissing` - A list containing any of: `album`,`title`,`artists`,`duration`,`isrc`, and `ids`
+ * `ids` is *any* missing spotify id (track, artists, albumArtists, and album ids)
+ * the default when `searchWhenMissing` is not defined is: `album`,`artists`,`title`, and `duration`
* `forceSearch` (default `false`) - Force searching even if all required data is present
#### Search Methods
@@ -255,11 +262,13 @@ Multi-scrobbler searches for a Spotify match using, in order, up to two methods.
If your Scrobble data contains an [ISRC](https://musicbrainz.org/doc/ISRC) then Multi-scrobbler searches Spotify's catalog using this ID.
-**If the ISRC is present on more than one Spotify album/track** (which happens often -- singles, re-releases, and compilation appearances of the same recording all share an ISRC) then the results are [ranked using fuzzy matching](#ranking) against your scrobble's existing album/artist data to pick the best candidate.
+**If the ISRC is present on more than one Spotify album/track** (which happens often IE singles, re-releases, and compilation appearances of the same recording all share an ISRC) then the results are [ranked using fuzzy matching](#ranking) against your scrobble's existing album/artist data to pick the best candidate.
:::tip[ISRC Matches Skip the Score Threshold]
-The ISRC itself is treated as confirmation of the match, so an ISRC result is **always used regardless of its fuzzy match [score](#score)** -- fuzzy matching is only used to pick between multiple ISRC candidates, never to reject one. This matters because Spotify's title/artist text for the same recording can differ substantially from your scrobble source (localized titles, "feat." credits, movie/theatrical edition suffixes, etc) while still being the correct match.
+If an ISRC search finds results then filtering results by [score](#score) is **skipped** since all results are confirmed matches. Ranking by fuzzy matching, as described above, still occurs.
+
+This matters because Spotify's title/artist text for the same recording can differ substantially from your scrobble source (localized titles, "feat." credits, movie/theatrical edition suffixes, etc) while still being the correct match.
Only results from a `basic` search are filtered by the minimum `score`.
@@ -289,29 +298,12 @@ If all defined search methods do not return any results (or, for a `basic` searc
:::
-
-
-ISRC Only, No Fallback
-
-If you only want Spotify matches sourced from ISRC lookups -- and want your Play left untouched whenever an ISRC isn't available or Spotify has no match for it -- set `searchOrder` to only `isrc`. No further configuration is needed; a Stage that fails never modifies your Play (see above).
-
-```json5
-// ...
-"defaults": {
- // only ever search by ISRC. If a Play has no ISRC, or Spotify has no match for it,
- // the Play is passed through completely unmodified.
- "searchOrder": ["isrc"]
- }
-```
-
-
-
### Ranking
-Unlike Musicbrainz (whose search backend returns its own relevance score) Spotify's search results do not carry a comparable score. So Multi-scrobbler always fuzzy-matches every candidate against your original scrobble's title/artist(s)/album to:
+Spotify's search results do not carry a confidence/similarity score so Multi-scrobbler fuzzy-matches every candidate against your original scrobble's title/artist(s)/album to:
* disambiguate between multiple candidates (EX an ISRC present on more than one album)
-* for `basic` search results only, determine whether any candidate is a confident enough match to use at all -- **an `isrc` match is always used**, see [ISRC Matches Skip the Score Threshold](#search-methods)
+* for `basic` search results only, determine whether any candidate is a confident enough match to use at all
#### Score
@@ -356,7 +348,7 @@ You **should** setup [metadata caching](/configuration/transforms#caching) to re
### Using Partial Match
-Use [Rules](#rules-and-hooks) to apply Spotify match data selectively, exactly as described for the [Musicbrainz Stage](/configuration/transforms/musicbrainz#using-partial-match). This is useful if you don't want your scrobble's Artist/Title/Album modified but still want the Spotify IDs attached to `meta` for Clients that use them.
+Use [Rules](#rules-and-hooks) to apply Spotify match data selectively. This is useful if you don't want your scrobble's Artist/Title/Album modified but still want Spotify IDs or ISRC defined for [Clients](/configuration/clients) or other Stages to leverage them.
diff --git a/src/backend/common/transforms/SpotifyTransformer.ts b/src/backend/common/transforms/SpotifyTransformer.ts
index 0c0bb6c5..8de6c5cb 100644
--- a/src/backend/common/transforms/SpotifyTransformer.ts
+++ b/src/backend/common/transforms/SpotifyTransformer.ts
@@ -2,14 +2,12 @@ import { childLogger } from "@foxxmd/logging";
import type { Cacheable } from "cacheable";
import type { WebhookPayload } from "../infrastructure/config/health/webhooks.ts";
import {
- DEFAULT_MISSING_MBIDS_TYPES,
- DEFAULT_MISSING_TYPES,
type ArtistCredit,
+ type ArtMeta,
type LifecycleInput,
- type MissingMbidType,
type OptionalCacheUsage,
type PlayObject,
- type TrackMeta,
+ type TrackMetaIsrc,
} from "../../../core/Atomic.ts";
import { ARTIST_WEIGHT, TITLE_WEIGHT } from "../infrastructure/Atomic.ts";
import { removeUndefinedKeys } from '../../../core/DataUtils.ts';
@@ -23,27 +21,23 @@ import { MaybeLogger } from '../MaybeLogger.ts';
import { SkipTransformStageError, StagePrerequisiteError, StageTransformError } from "../errors/MSErrors.ts";
import AtomicPartsTransformer from "./AtomicPartsTransformer.ts";
import type { TransformerOptions } from "./AbstractTransformer.ts";
-import { asMissingMbid, SearchPrerequisiteError } from "./MusicbrainzTransformer.ts";
+import { SearchPrerequisiteError } from "./MusicbrainzTransformer.ts";
import {
+ DEFAULT_SPOTIFY_MISSING_TYPES,
DEFAULT_SPOTIFY_SEARCH_ORDER,
+ spotifyMissingTypes,
+ spotifySearchTypes,
+ type SpotifyMissingType,
type SpotifySearchType,
type SpotifyTransformerConfig,
type SpotifyTransformerData,
} from "./spotify/SpotifyTransformerUtil.ts";
-export const asSpotifySearchType = (str: string): SpotifySearchType => {
- const clean = str.trim().toLocaleLowerCase();
- if (clean === 'isrc' || clean === 'basic') {
- return clean;
- }
- throw new Error(`SearchType must be one of 'isrc' or 'basic', given: ${clean}`);
-}
-
/** How much to subtract from a candidate's match score when it belongs to a compilation album and deprioritizeCompilations is enabled */
export const COMPILATION_PENALTY = 0.15;
export interface SpotifyTransformerDataStrong extends SpotifyTransformerData {
- searchWhenMissing: MissingMbidType[]
+ searchWhenMissing: SpotifyMissingType[]
titleWeight?: number
artistWeight?: number
@@ -85,19 +79,19 @@ export const parseStageConfig = (data: SpotifyTransformerData | undefined = {},
} = data;
const config: SpotifyTransformerDataStrong = {
- searchWhenMissing: DEFAULT_MISSING_TYPES,
+ searchWhenMissing: DEFAULT_SPOTIFY_MISSING_TYPES,
score: 0.6,
...rest,
};
if (searchWhenMissing !== undefined) {
- config.searchWhenMissing = searchWhenMissing.map(asMissingMbid);
+ config.searchWhenMissing = searchWhenMissing.map((x) => spotifyMissingTypes.parse(x.toLocaleLowerCase().trim()));
}
logger.debug(`Will search if missing: ${config.searchWhenMissing.join(', ')} | Match if (default) score is >= ${config.score}`);
if (searchOrder !== undefined) {
- const so = parseArrayFromMaybeString(searchOrder as unknown as string[], { lower: true }).map(asSpotifySearchType);
+ const so = parseArrayFromMaybeString(searchOrder as unknown as string[], { lower: true }).map((x) => spotifySearchTypes.parse(x.trim()));
if (so.length > 0) {
config.searchOrder = so;
logger.debug(`Search Order => ${so.join(' | ')}`);
@@ -118,23 +112,32 @@ export const parseStageConfig = (data: SpotifyTransformerData | undefined = {},
}
/** Analogous to musicbrainz's missingMbidTypes but checks the presence of Spotify IDs on the Play instead of MBIDs */
-export const missingSpotifyTypes = (play: PlayObject): MissingMbidType[] => {
- let missing: MissingMbidType[] = [];
+export const missingSpotifyTypes = (play: PlayObject): SpotifyMissingType[] => {
+ let missing: SpotifyMissingType[] = [];
if (play.data.duration === undefined) {
missing.push('duration');
}
if (play.data.meta?.spotify === undefined) {
- missing = missing.concat(DEFAULT_MISSING_MBIDS_TYPES);
- return missing;
+ missing = missing.concat('ids');
+ } else {
+ const {
+ track,
+ album,
+ artist
+ } = play.data.meta.spotify;
+ if (track === undefined || album === undefined || artist === undefined) {
+ missing.push('ids');
+ }
}
const {
track,
album,
- artist
- } = play.data.meta.spotify;
+ artists,
+ duration
+ } = play.data;
if (track === undefined) {
missing.push('title');
@@ -142,9 +145,12 @@ export const missingSpotifyTypes = (play: PlayObject): MissingMbidType[] => {
if (album === undefined) {
missing.push('album');
}
- if (artist === undefined || (artist ?? []).length !== (play.data.artists ?? []).length) {
+ if (artists === undefined || (artists ?? []).length === 0) {
missing.push('artists');
}
+ if(duration === undefined) {
+ missing.push('duration');
+ }
return missing;
}
@@ -237,7 +243,7 @@ export default class SpotifyTransformer extends AtomicPartsTransformer {
+ protected async handleMeta(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise {
if (parts === false) {
return play.data.meta;
}
@@ -463,7 +469,23 @@ export default class SpotifyTransformer extends AtomicPartsTransformer({...transformData.data.meta, isrc: transformData.data.isrc});
+ }
+
+ protected async handleArt(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise {
+ if (parts === false) {
+ return play.meta.art;
+ }
+ if (typeof parts === 'object') {
+ if (parts.when !== undefined) {
+ if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) {
+ this.logger.debug('When condition for duration not met, returning original duration');
+ return play.meta.art;
+ }
+ }
+ }
+
+ return transformData.meta.art;
}
public notify(payload: WebhookPayload): Promise {
diff --git a/src/backend/common/transforms/spotify/SpotifyTransformerUtil.ts b/src/backend/common/transforms/spotify/SpotifyTransformerUtil.ts
index 4e92f198..a6264f22 100644
--- a/src/backend/common/transforms/spotify/SpotifyTransformerUtil.ts
+++ b/src/backend/common/transforms/spotify/SpotifyTransformerUtil.ts
@@ -1,17 +1,22 @@
import type {
- MissingMbidType,
TransformerCommon,
TransformOptions,
} from "../../../../core/Atomic.ts";
import type { SpotifyTransformerApiConfigData } from "../../vendor/spotify/SpotifyTypes.ts";
import { MaybeLogger } from "../../MaybeLogger.ts";
+import * as z from 'zod';
-export type SpotifySearchType = 'isrc' | 'basic';
+export const spotifyMissingTypes = z.enum(['album','title','artists','duration','isrc','ids']);
+export type SpotifyMissingType = z.infer;
+export const DEFAULT_SPOTIFY_MISSING_TYPES: SpotifyMissingType[] = ['album','artists','title','duration'] as const;
+
+export const spotifySearchTypes = z.enum(['isrc','basic']);
+export type SpotifySearchType = z.infer;
export const DEFAULT_SPOTIFY_SEARCH_ORDER: SpotifySearchType[] = ['isrc', 'basic'];
export interface SpotifyTransformerData {
- searchWhenMissing?: MissingMbidType[]
+ searchWhenMissing?: SpotifyMissingType[]
forceSearch?: boolean
/** Minimum (0-1) fuzzy match score a candidate must have to be used
*
diff --git a/src/backend/common/vendor/spotify/SpotifyApiClient.ts b/src/backend/common/vendor/spotify/SpotifyApiClient.ts
index 6c1cccc8..5e8fed2d 100644
--- a/src/backend/common/vendor/spotify/SpotifyApiClient.ts
+++ b/src/backend/common/vendor/spotify/SpotifyApiClient.ts
@@ -1,7 +1,7 @@
import SpotifyWebApi from "spotify-web-api-node";
import { RateLimiterMemory, RateLimiterQueue } from 'rate-limiter-flexible';
import type { Cacheable } from "cacheable";
-import type { PlayObject, PlayObjectMinimal } from "../../../../core/Atomic.ts";
+import type { ArtMeta, PlayObject, PlayObjectMinimal } from "../../../../core/Atomic.ts";
import { artistNameToCredit } from "../../../../core/StringUtils.ts";
import { isrcNoHyphens } from "../../../../core/PlayUtils.ts";
import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.ts";
@@ -70,7 +70,7 @@ export class SpotifyApiClient extends AbstractApiClient {
if (cacheKey !== undefined && useCachedResult) {
const cached = await this.cache.get(cacheKey);
if (cached !== undefined) {
- this.logger.debug(`Cache hit for ${cacheKey}`);
+ this.logger.trace(`Cache hit for ${cacheKey}`);
return cached;
}
}
@@ -106,11 +106,11 @@ export class SpotifyApiClient extends AbstractApiClient {
parts.push(`track:${luceneQuoteIfNeeded(play.data.track)}`);
}
if (play.data.artists !== undefined && play.data.artists.length > 0) {
- // use only the primary artist -- Spotify's search does not support matching multiple artist filters well
+ // use only the primary artist because Spotify's search does not support matching multiple artist filters well
// and a fuzzy rank pass happens afterwards to confirm the rest of the artist credits
parts.push(`artist:${luceneQuoteIfNeeded(play.data.artists[0].name)}`);
}
- // intentionally NOT filtering by album here -- unlike Musicbrainz's fuzzy Lucene backend, Spotify's field
+ // intentionally NOT filtering by album here because, unlike Musicbrainz's fuzzy Lucene backend, Spotify's field
// search is literal, so ANDing album into the query causes near-total misses whenever the track's Spotify
// album metadata differs even slightly from the scrobble (singles, re-releases, etc). Album confirmation
// happens afterwards via fuzzy ranking instead.
@@ -127,6 +127,38 @@ export class SpotifyApiClient extends AbstractApiClient {
}
}
+export const chooseImageByResolution = (images: SpotifyApi.ImageObject[], opts: { minHeight?: number, minWidth?: number, fallbackBest?: boolean } = {}): SpotifyApi.ImageObject => {
+ const {
+ minHeight,
+ minWidth,
+ fallbackBest = false
+ } = opts;
+
+ let bestImage: SpotifyApi.ImageObject,
+ bestRes: number = 0;
+
+ for (const i of images) {
+ if (fallbackBest && i.height + i.width > bestRes) {
+ bestRes = i.height + i.width;
+ bestImage = i;
+ }
+ if (minHeight !== undefined || minWidth !== undefined) {
+ if (minHeight !== undefined && i.height < minHeight) {
+ continue;
+ }
+ if (minWidth !== undefined && i.width < minWidth) {
+ continue;
+ }
+ return i;
+ }
+ }
+
+ if (fallbackBest === false) {
+ throw new Error(`No image met minimum resolution of ${minHeight}x${minHeight}`);
+ }
+ return bestImage;
+}
+
export const trackToPlay = (track: SpotifyApi.TrackObjectFull): PlayObject => {
const {
@@ -171,6 +203,12 @@ export const trackToPlay = (track: SpotifyApi.TrackObjectFull): PlayObject => {
}
}
+ if((album?.images ?? []).length > 0) {
+ play.meta.art = {
+ album: chooseImageByResolution(album.images, {fallbackBest: true}).url
+ }
+ }
+
return baseFormatPlayObj(track, play);
}
--
2.51.2
From 5d048f2dec6fcb36501c72454bf58024f199466a Mon Sep 17 00:00:00 2001
From: FoxxMD
Date: Thu, 24 Sep 2026 21:15:29 +0000
Subject: [PATCH 17/19] refactor(transform): Move spotify transform
market/locale into stage config only and update docs
* Breaking these out into api config is unnessary and confusing for both users and developers when they specifically affect searching and aren't required for api setup
* Fix tests to reflect previous changes and fix bugs
* Simplify some other claude slop code
* Change docs to reflect market/locale behavior
* More doc refining from claude slop
---
.../docs/configuration/transforms/spotify.mdx | 115 +++++++++++-------
.../common/transforms/SpotifyTransformer.ts | 30 +++--
.../spotify/SpotifyTransformerUtil.ts | 9 +-
.../common/vendor/spotify/SpotifyApiClient.ts | 11 +-
.../common/vendor/spotify/SpotifyTypes.ts | 11 --
.../tests/spotify/spotifyTransformer.test.ts | 16 +--
6 files changed, 108 insertions(+), 84 deletions(-)
diff --git a/docsite/docs/configuration/transforms/spotify.mdx b/docsite/docs/configuration/transforms/spotify.mdx
index c6a4d5db..843a543f 100644
--- a/docsite/docs/configuration/transforms/spotify.mdx
+++ b/docsite/docs/configuration/transforms/spotify.mdx
@@ -43,49 +43,9 @@ This Stage uses Spotify's API to search/lookup the Spotify catalog. This does **
}
```
-
-
-Market, Locale, and Rate Limiting
-
-`market` (an [ISO 3166-1 alpha-2 country code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2)) can be set to bias/limit search results to what is available in a specific market:
-
-```json5
-{
- "type": "spotify",
- "name": "MySpotify",
- "data": {
- "clientId": "787c921a2a2ab42320831aba0c8f2fc2",
- "clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
- "market": "US"
- },
-}
-```
-
-`locale` (in `ISO-639-1_ISO-3166-1` format, EX `en_US`, `ja_JP`) can additionally be set to try to bias which translation of a localized catalog name (artist/album/track) Spotify returns for a match:
-
-```json5
-{
- "type": "spotify",
- "name": "MySpotify",
- "data": {
- "clientId": "787c921a2a2ab42320831aba0c8f2fc2",
- "clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
- "locale": "en_US"
- },
-}
-```
-
-:::tip[Example]
-
-Some catalog entries carry a translated name depending on locale, independent of `market`. EX the Japanese game-music artist "ATLUS Sound Team" is stored in Spotify's catalog under multiple localized names for the exact same artist ID -- without `locale` set, a lookup returns `アトラスサウンドチーム`; with `"locale": "en_US"` the same lookup returns `ATLUS Sound Team`. `market` alone does **not** control this -- it was tested independently and had no effect on the returned name for this artist.
-
-:::
-
-:::note
-
-`locale` is an **UNDOCUMENTED** parameter of the Spotify Web API **and may break at any time, without notice or warning**. Its behavior may also be inconsistent - some catalog entries only exist with one language's name at all, in which case no `locale`/`market` combination will produce an alternate translation because Spotify doesn't have one to return.
+
-:::
+Rate Limiting
`rate` can be used to configure how many requests are made to the Spotify API, defined by **max number of requests** within **timespan of N seconds** (default `10 req/1s`):
@@ -96,10 +56,12 @@ Some catalog entries carry a translated name depending on locale, independent of
"data": {
"clientId": "787c921a2a2ab42320831aba0c8f2fc2",
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
+ // highlight-start
"rate": {
"requests": 10,
"perTime": 1
}
+ // highlight-end
},
}
```
@@ -110,6 +72,8 @@ Some catalog entries carry a translated name depending on locale, independent of
All of the properties found in [**Matching with Spotify**](#matching-with-spotify) section are configured in [Stage Configuration](/configuration/transforms#configuring-stages) as `defaults`.
+These are all also **optional** so you can use the Spotify Transform stage without configuring any of this.
+
```json5 title="config.json"
{
// ...
@@ -121,10 +85,12 @@ All of the properties found in [**Matching with Spotify**](#matching-with-spotif
"clientId": "787c921a2a2ab42320831aba0c8f2fc2",
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a"
},
+ // highlight-start
"defaults": {
"score": 0.6,
"deprioritizeCompilations": true
}
+ // highlight-end
}
]
}
@@ -294,10 +260,73 @@ If `searchOrder` is undefined Multi-scrobbler defaults to using `isrc` then `bas
If all defined search methods do not return any results (or, for a `basic` search, no results score high enough, see [Ranking](#ranking)) then the stage is marked as [**failed** (`onFailure`) for **Flow Control**](/configuration/transforms/#flow-control).
-**A failed Stage never partially applies -- your Play is left completely unmodified.** See [Failed and Skipped Stages](/configuration/transforms/#flow-control) for details.
+A failed Stage never modifies your play data unless you eplicitly configure it to. See [Failed and Skipped Stages](/configuration/transforms/#flow-control) for details.
+
+:::
+
+
+
+`market` (an [ISO 3166-1 alpha-2 country code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2)) can be set to bias/limit search results to what is available in a specific market:
+
+
+
+Example
+
+```json5
+{
+ "type": "spotify",
+ "name": "MySpotify",
+ "data": {/* */},
+ "defaults": {
+ // ...
+ "market": "US"
+ }
+}
+```
+
+
+
+`locale` (in `ISO-639-1_ISO-3166-1` format, EX `en_US`, `ja_JP`) can additionally be set to try to bias which translation of a localized catalog name (artist/album/track) Spotify returns for a match:
+
+
+
+Example
+
+```json5
+{
+ "type": "spotify",
+ "name": "MySpotify",
+ "data": {/* */},
+ "defaults": {
+ // ...
+ "locale": "en_US"
+ }
+}
+
+```
+
+
+:::tip[Real World Example]
+
+Some catalog entries carry a translated name depending on locale, independent of `market`. EX the Japanese game-music artist "ATLUS Sound Team" is stored in Spotify's catalog under multiple localized names for the exact same artist ID.
+
+* Without `locale` set, a lookup returns `アトラスサウンドチーム`;
+* with `"locale": "en_US"` the same lookup returns `ATLUS Sound Team`.
+
+`market` alone does **not** control this.
:::
+:::important
+
+`locale` is an **UNDOCUMENTED** parameter of the Spotify Web API **and may break at any time, without notice or warning**.
+
+Its behavior may also be inconsistent. Some catalog entries only exist with one language's name at all, in which case no `locale`/`market` combination will produce an alternate translation because Spotify doesn't have one to return.
+
+:::
+
+
+
### Ranking
Spotify's search results do not carry a confidence/similarity score so Multi-scrobbler fuzzy-matches every candidate against your original scrobble's title/artist(s)/album to:
diff --git a/src/backend/common/transforms/SpotifyTransformer.ts b/src/backend/common/transforms/SpotifyTransformer.ts
index 8de6c5cb..20b60ae7 100644
--- a/src/backend/common/transforms/SpotifyTransformer.ts
+++ b/src/backend/common/transforms/SpotifyTransformer.ts
@@ -115,9 +115,14 @@ export const parseStageConfig = (data: SpotifyTransformerData | undefined = {},
export const missingSpotifyTypes = (play: PlayObject): SpotifyMissingType[] => {
let missing: SpotifyMissingType[] = [];
- if (play.data.duration === undefined) {
- missing.push('duration');
- }
+ const {
+ track,
+ album,
+ artists: dataArtists,
+ artists,
+ duration,
+ isrc
+ } = play.data;
if (play.data.meta?.spotify === undefined) {
missing = missing.concat('ids');
@@ -130,14 +135,12 @@ export const missingSpotifyTypes = (play: PlayObject): SpotifyMissingType[] => {
if (track === undefined || album === undefined || artist === undefined) {
missing.push('ids');
}
+ if(artist !== undefined && dataArtists !== undefined && artist.length !== dataArtists.length) {
+ missing.push('ids');
+ }
}
- const {
- track,
- album,
- artists,
- duration
- } = play.data;
+
if (track === undefined) {
missing.push('title');
@@ -145,12 +148,15 @@ export const missingSpotifyTypes = (play: PlayObject): SpotifyMissingType[] => {
if (album === undefined) {
missing.push('album');
}
- if (artists === undefined || (artists ?? []).length === 0) {
+ if (dataArtists === undefined || (dataArtists ?? []).length === 0) {
missing.push('artists');
}
if(duration === undefined) {
missing.push('duration');
}
+ if(isrc === undefined) {
+ missing.push('isrc');
+ }
return missing;
}
@@ -215,8 +221,6 @@ export default class SpotifyTransformer extends AtomicPartsTransformer => {
- const { limit = 50, market = this.config.market, locale = this.config.locale, useCachedResult } = opts;
+ const { limit = 50, market, locale, useCachedResult } = opts;
const q = `isrc:${isrcNoHyphens(isrc)}`;
const cacheKey = `spotify-search-${hashObject({ q, limit, market, locale })}`;
this.logger.debug({ labels: ['ISRC Search'] }, `Search Query => ${q} | market: ${market ?? '(none)'} | locale: ${locale ?? '(none)'}`);
- const res = await this.callApi((api) => api.searchTracks(q, { limit, market, ...(locale !== undefined ? { locale } : {}) }), { cacheKey, useCachedResult });
+ const res = await this.callApi((api) => api.searchTracks(q, removeUndefinedKeys({ limit, market })), { cacheKey, useCachedResult });
return res.body.tracks?.items ?? [];
}
searchByFields = async (play: PlayObject, opts: SpotifySearchOptions = {}): Promise => {
- const { limit = 50, market = this.config.market, locale = this.config.locale, useCachedResult } = opts;
+ const { limit = 50, market, locale, useCachedResult } = opts;
const parts: string[] = [];
if (play.data.track !== undefined) {
@@ -118,7 +119,7 @@ export class SpotifyApiClient extends AbstractApiClient {
const q = parts.join(' ');
const cacheKey = `spotify-search-${hashObject({ q, limit, market, locale })}`;
this.logger.debug({ labels: ['Basic Search'] }, `Search Query => ${q} | market: ${market ?? '(none)'} | locale: ${locale ?? '(none)'}`);
- const res = await this.callApi((api) => api.searchTracks(q, { limit, market, ...(locale !== undefined ? { locale } : {}) }), { cacheKey, useCachedResult });
+ const res = await this.callApi((api) => api.searchTracks(q, removeUndefinedKeys({ limit, market })), { cacheKey, useCachedResult });
return res.body.tracks?.items ?? [];
}
diff --git a/src/backend/common/vendor/spotify/SpotifyTypes.ts b/src/backend/common/vendor/spotify/SpotifyTypes.ts
index 87a58504..7a7f3381 100644
--- a/src/backend/common/vendor/spotify/SpotifyTypes.ts
+++ b/src/backend/common/vendor/spotify/SpotifyTypes.ts
@@ -11,17 +11,6 @@ export interface SpotifyTransformerApiConfigData {
* Can also be set using the SPOTIFY_CLIENT_SECRET ENV (shared with the Spotify Source, if configured).
*/
clientSecret: string
- /**
- * An ISO 3166-1 alpha-2 country code. Limits/biases search results to what is available in this market.
- */
- market?: string
- /**
- * A locale in ISO-639-1_ISO-3166-1 format (EX en_US, ja_JP) used to bias which translation of a localized
- * catalog name (artist/album/track) the Spotify API returns. Support for this is not officially documented
- * by Spotify and results may be inconsistent, but it can be used alongside (or instead of) `market` to try
- * to force names into a specific language.
- */
- locale?: string
rate?: {
/** max number of requests allowed during perTime unit of time
*
diff --git a/src/backend/tests/spotify/spotifyTransformer.test.ts b/src/backend/tests/spotify/spotifyTransformer.test.ts
index ffef3149..d1918ca1 100644
--- a/src/backend/tests/spotify/spotifyTransformer.test.ts
+++ b/src/backend/tests/spotify/spotifyTransformer.test.ts
@@ -13,7 +13,7 @@ import SpotifyTransformer, {
rankTracksBySimilarity,
type SpotifyTransformerDataStage,
} from '../../common/transforms/SpotifyTransformer.ts';
-import type { SpotifyTransformerConfig } from '../../common/transforms/spotify/SpotifyTransformerUtil.ts';
+import { DEFAULT_SPOTIFY_MISSING_TYPES, spotifyMissingTypes, type SpotifyTransformerConfig } from '../../common/transforms/spotify/SpotifyTransformerUtil.ts';
import { isCompilation, trackToPlay } from '../../common/vendor/spotify/SpotifyApiClient.ts';
chai.use(asPromised);
@@ -23,6 +23,7 @@ const basePlay = (data: Partial = {}, meta: Partial
Date: Thu, 24 Sep 2026 21:25:00 +0000
Subject: [PATCH 18/19] update envs
---
.../transforms/spotify/SpotifyTransformerUtil.ts | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/src/backend/common/transforms/spotify/SpotifyTransformerUtil.ts b/src/backend/common/transforms/spotify/SpotifyTransformerUtil.ts
index 099c6bef..0831447b 100644
--- a/src/backend/common/transforms/spotify/SpotifyTransformerUtil.ts
+++ b/src/backend/common/transforms/spotify/SpotifyTransformerUtil.ts
@@ -5,6 +5,7 @@ import type {
import type { SpotifyTransformerApiConfigData } from "../../vendor/spotify/SpotifyTypes.ts";
import { MaybeLogger } from "../../MaybeLogger.ts";
import * as z from 'zod';
+import { removeUndefinedKeys } from "../../../../core/DataUtils.ts";
export const spotifyMissingTypes = z.enum(['album','title','artists','duration','isrc','ids']);
export type SpotifyMissingType = z.infer;
@@ -63,7 +64,11 @@ export const configFromEnv = (logger: MaybeLogger = new MaybeLogger()): SpotifyT
return undefined;
}
- const deprioritizeCompilations = (process.env.SPOTIFY_TRANSFORM_DEPRIORITIZE_COMPILATIONS ?? '').trim().toLocaleLowerCase() === 'true';
+ const defaults = {
+ market: process.env.SPOTIFY_TRANSFORM_MARKET,
+ locale: process.env.SPOTIFY_TRANSFORM_LOCALE,
+ deprioritizeCompilations: process.env.SPOTIFY_TRANSFORM_DEPRIORITIZE_COMPILATIONS !== undefined ? process.env.SPOTIFY_TRANSFORM_DEPRIORITIZE_COMPILATIONS.trim().toLocaleLowerCase() === 'true' : undefined
+ }
return {
type: 'spotify',
@@ -71,11 +76,7 @@ export const configFromEnv = (logger: MaybeLogger = new MaybeLogger()): SpotifyT
data: {
clientId,
clientSecret,
- market: process.env.SPOTIFY_TRANSFORM_MARKET,
- locale: process.env.SPOTIFY_TRANSFORM_LOCALE
},
- defaults: {
- ...(deprioritizeCompilations ? { deprioritizeCompilations } : {})
- }
+ defaults: removeUndefinedKeys(defaults, false)
};
}
--
2.51.2
From ba56750963cfac39b6419072612cacb78767f931 Mon Sep 17 00:00:00 2001
From: FoxxMD
Date: Thu, 24 Sep 2026 21:27:13 +0000
Subject: [PATCH 19/19] fix missing import
---
src/backend/common/transforms/rocksky/RockskyTransformer.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/backend/common/transforms/rocksky/RockskyTransformer.ts b/src/backend/common/transforms/rocksky/RockskyTransformer.ts
index 3d40bd84..803562dd 100644
--- a/src/backend/common/transforms/rocksky/RockskyTransformer.ts
+++ b/src/backend/common/transforms/rocksky/RockskyTransformer.ts
@@ -1,4 +1,4 @@
-import { type ArtistCredit, DEFAULT_ROCKSKY_MISSING_TYPES, type LifecycleInput, type OptionalCacheUsage, type PlayObject, type RockskyMissingField, type TrackMeta, type ArtMeta } from "../../../../core/Atomic.ts";
+import { type ArtistCredit, DEFAULT_ROCKSKY_MISSING_TYPES, type LifecycleInput, type OptionalCacheUsage, type PlayObject, type RockskyMissingField, type ArtMeta, type TrackMetaIsrc } from "../../../../core/Atomic.ts";
import { isWhenCondition, testWhenConditions } from "../../../utils/PlayTransformUtils.ts";
import type {WebhookPayload} from "../../infrastructure/config/health/webhooks.ts";
import type {ExternalMetadataTerm, PlayTransformMetadataStage} from "../../../../core/Transform.ts";