From c792ed3d4453aaa5e6e6d8a4a9fb3a41fc407261 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 13 Oct 2026 13:54:36 +0000 Subject: [PATCH 1/3] fix(lz): Only use original artist_name in naive play if no artists credits were extracted Prevents single artist string with joiner slipping through uniqueNormalizedStrArr filter on artists --- .../common/vendor/ListenbrainzApiClient.ts | 5 +++- .../tests/listenbrainz/listenbrainz.test.ts | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/backend/common/vendor/ListenbrainzApiClient.ts b/src/backend/common/vendor/ListenbrainzApiClient.ts index 158b2ea5..b3048baf 100644 --- a/src/backend/common/vendor/ListenbrainzApiClient.ts +++ b/src/backend/common/vendor/ListenbrainzApiClient.ts @@ -656,7 +656,6 @@ export const listenToNaivePlay = (listen: ListenResponse): PlayObject => { if(artist_names.length > 0) { artists = artist_names; } else if(typeof artist_name === 'string') { - artists = [artist_name]; // since we aren't using MB mappings we should be conservative and assume artist string with & are proper names (not joiner) const parsedArtists = parseCredits(artist_name, [',', '/', '\\']); @@ -673,6 +672,10 @@ export const listenToNaivePlay = (listen: ListenResponse): PlayObject => { normalTrackName = parsedTrackArtists.primary; artists = artists.concat(parsedTrackArtists.secondary ?? []) } + if(artists.length === 0) { + // fallback to original artist_name string if we weren't able to parse anything from artists/track credits + artists.push(artist_name); + } artists = uniqueNormalizedStrArr(artists); } diff --git a/src/backend/tests/listenbrainz/listenbrainz.test.ts b/src/backend/tests/listenbrainz/listenbrainz.test.ts index f2e563a5..c829df05 100644 --- a/src/backend/tests/listenbrainz/listenbrainz.test.ts +++ b/src/backend/tests/listenbrainz/listenbrainz.test.ts @@ -25,6 +25,7 @@ import incorrectMultiArtistsTrackName from './incorrectlyMapped/multiArtistsInTr import veryWrong from './incorrectlyMapped/veryWrong.json' with { type: "json" }; import { generatePlay } from "../../../core/tests/utils/PlayTestUtils.ts"; import { nameToCredit, creditsToNames, namesToCredits } from "../../../core/MusicMetadata.ts"; +import { playStateFromRequest } from "../../sources/EndpointListenbrainzSource.ts"; interface LZTestFixture { data: ListenResponse @@ -371,6 +372,32 @@ describe('Listenbrainz Endpoint Behavior', function() { }); + it('does not include artist_name if artists were extracted from string', function() { + const payload = { + "listened_at": 1791895981, + "track_metadata": { + "artist_name": "Alohaii feat. Rachie", + "track_name": "Heart Of Glass", + "release_name": "SUMMERTIDE", + "additional_info": { + "artist_mbids": [ + "a611433b-c2bb-4ba2-be28-775a079e93c6", + "745a0c93-a487-4650-ab05-07c4eb9234a2" + ], + "release_mbid": "560db4c2-5af5-4ef0-a9e0-b56fca3d8bea", + "release_group_mbid": "e5a74679-1883-45cb-b776-8b7aee879760", + "recording_mbid": "3d4a73ff-3956-4d6b-b423-b1c079775dcf", + "duration_ms": 217433, + "track_mbid": "c3fda34b-31e1-4e9a-af76-85e3dd08b09f", + "tracknumber": 4 + } + } + } + const state = playStateFromRequest({listen_type: 'single', payload: [payload]}); + expect(state[0].play.data.artists?.some(x => x.name === 'Alohaii')).is.true; + expect(state[0].play.data.artists?.some(x => x.name === 'Rachie')).is.true; + }) + }); -- 2.54.0 From 598d2595e5541c62acb8553274b52320707d0e88 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 13 Oct 2026 15:12:05 +0000 Subject: [PATCH 2/3] feat(endpointlz): Allow disabling artist extraction from ingress payload --- .../sources/_env_configs/_endpointlz.md | 3 +- .../sources/listenbrainz-endpoint.mdx | 5 ++ .../components/snippets/_lz_artist_match.mdx | 47 ++++++++++++++++ .../config/source/endpointlz.ts | 26 +++++++-- .../common/vendor/ListenbrainzApiClient.ts | 55 ++++++++++++------- .../server/endpointListenbrainzRoutes.ts | 2 +- .../sources/EndpointListenbrainzSource.ts | 8 +-- 7 files changed, 115 insertions(+), 31 deletions(-) create mode 100644 docsite/src/components/snippets/_lz_artist_match.mdx diff --git a/docsite/docs/configuration/sources/_env_configs/_endpointlz.md b/docsite/docs/configuration/sources/_env_configs/_endpointlz.md index 4ddca042..0ce66fe7 100644 --- a/docsite/docs/configuration/sources/_env_configs/_endpointlz.md +++ b/docsite/docs/configuration/sources/_env_configs/_endpointlz.md @@ -5,4 +5,5 @@ | `LZE_ENABLE` | boolean | true | Should this component be used? | | `LZE_SLUG` | string | | When using **multiple sources without tokens**, or **not** using a standard base URL, this is the URL base path that should be used to identify scrobbles for this source | | `LZE_TOKEN` | string | | If an LZ submission request contains this token in the Authorization Header it will be used to match the submission with this Source | -| `LZE_USERNAME` | string | | The listenbrainz "username" to associate with this Source | \ No newline at end of file +| `LZE_USERNAME` | string | | The listenbrainz "username" to associate with this Source | +| `LZE_EXTRACT_ARTISTS` | boolean | true | Tries to extract and match artists from comma-delimited listen data. Set to `false` to use listen data as-is (will likely lose fidelity!) | \ No newline at end of file diff --git a/docsite/docs/configuration/sources/listenbrainz-endpoint.mdx b/docsite/docs/configuration/sources/listenbrainz-endpoint.mdx index adced22d..b292210f 100644 --- a/docsite/docs/configuration/sources/listenbrainz-endpoint.mdx +++ b/docsite/docs/configuration/sources/listenbrainz-endpoint.mdx @@ -9,6 +9,7 @@ import TabItem from '@theme/TabItem'; import CodeBlock from '@theme/CodeBlock'; import ListenbrainzEndpointConfig from '!!raw-loader!@site/../config/endpointlz.json.example'; import ENVConfig from "@site/docs/configuration/sources/_env_configs/_endpointlz.md" +import ArtistMatch from "@site/src/components/snippets/_lz_artist_match.mdx" This Source enables multi-scrobbler to accept scrobbles from outside applications **as if it was a Listenbrainz server.** @@ -256,6 +257,10 @@ If you decide to report this issue, please include this logging in your issue. ## Configuration + + + + \ No newline at end of file diff --git a/docsite/src/components/snippets/_lz_artist_match.mdx b/docsite/src/components/snippets/_lz_artist_match.mdx new file mode 100644 index 00000000..827ba9a6 --- /dev/null +++ b/docsite/src/components/snippets/_lz_artist_match.mdx @@ -0,0 +1,47 @@ +import CodeInline from '@theme/CodeInline'; + +The Listenbrainz `listens` payload has many fields of varying granularity: (See [OpenAPI Spec](https://rain0r.github.io/listenbrainz-openapi) `listensForUser_payload_listens_inner` schema). + +Relevant to MS, an LZ listen can contain one or more, different, **artist fields** that contain varying amounts of rich data, not all of which are guaranteed to be reliable. + +
+ +Artist Fields and Assumptions + +* Reported by the LZ *Client* + * `track_metadata.artist_name` - a single string with all artists like `Foo and Bar feat. Cool Guy` + * `track_metadata.additional_info.artist_names` - a list of artist names like `['Foo','Bar','Cool Guy']` + * `track_metadata.additional_info.artist_mbids` - a list of artist [MBIDs](https://musicbrainz.org/doc/MusicBrainz_Identifier) +* Reported by the LZ Client **or** LZ Server + * `track_metadata.mbid_mapping.artist_mbids` - a list of artist MBIDs + * `track_metadata.mbid_mapping.artists` - a list of artist objects like `{artist_credit_name: 'Cool Guy', join_phrase: 'feat.', artist_mbid: '...'}` + +All of these fields, other than `track_metadata.artist_name`, are **optional.** Also, critically, these two assumptions apply: + +* `mbid_mapping.*` is *usually* only reported by a Listenbrainz Server but can *also* be provided by a LZ Client. +* When LZ reports `mbid_mapping.*` this is because it "matched" your payload data `artist_name` etc... with a Musicbrainz recording. + * LZ **does not** provide a confidence metric to tell you how likely this mapping is actually correct + +
+ +___ + +For MS to be as accurate as possible for things like [deduplicating scrobbles](/configuration/duplicates/) and providing [good data to scrobble enhancements](/configuration/transforms/) it needs **artist** data to be as granular as possible since different Clients and Sources can report artists in various forms. + +Because the LZ payload data can be so ambiguous MS processes LZ payload data through a more rigorous form of the [Native Stage](/configuration/transforms/native/) to try to extract, and match, artist data from all the fields mentioned above. In this case, more rigor => MS only modifies artist data when it has very high confidence it matches the original data well, backed by [25+ tests](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/backend/tests/listenbrainz/listenbrainz.test.ts). + +It also enables "smarter" decisions about LZ **Source** data that includes `mbid_mapping` because it's possible the matched mappings *do not* actually align with the data you submitted: MS does string comparisons against these and if they are not highly aligned it falls back to the less-rich `additional_info` and `artist_name` fields instead of using the *mismatched* `mbid_mappings`. + +This additional processing is **enabled by default** because most users benefit from this strict matching and corrections. +___ + +However, there are usecases where you may not want this additional processing to occur such as: + +* You know your Listenbrainz Endpoint/Source has high quality matches/submission data that must stay byte-equivalent or contains many already-corrected for edge cases you don't want MS to modify +* You are using MS to forward Now Playing data with `artist_name` fields formatted exactly as you already want them, which MS should not change +* You are transferring scrobbles between LZ servers and want byte-equivalant scrobbles for data preservation/archiving + +If your usecase meets this scenario, or you just generally don't want MS doing anything with artists, you can **disable artist extraction** using one of these options in your config: + +* File/AIO - use `"options": {"extractArtists": false }` +* ENV - use {props.name}_EXTRACT_ARTISTS=false \ No newline at end of file diff --git a/src/backend/common/infrastructure/config/source/endpointlz.ts b/src/backend/common/infrastructure/config/source/endpointlz.ts index 1a1203ce..8418b45d 100644 --- a/src/backend/common/infrastructure/config/source/endpointlz.ts +++ b/src/backend/common/infrastructure/config/source/endpointlz.ts @@ -1,5 +1,6 @@ import * as z from "zod"; -import {commonSourceConfigSchema, commonSourceDataSchema, type EnvSourceSchema} from "./index.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema, type EnvSourceSchema} from "./index.ts"; +import { envMetaNormalize } from "../../../../utils/ZodUtils.ts"; export const listenbrainzEndpointDataSchema = z.object({ ...commonSourceDataSchema.shape, @@ -40,12 +41,27 @@ export const listenbrainzEndpointDataSchema = z.object({ }), }); +export const listenbrainzEndpointOptionsSchema = z.object({ + extractArtists: z.boolean().optional().default(true).meta({ + description: 'Tries to extract and match artists from listen data. Set to `false` to use listen data as-is (will likely lose fidelity!)' + }), + ...commonSourceOptionsSchema.shape, +}); + +export const listenbrainzEndpointSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: listenbrainzEndpointDataSchema.optional(), + options: listenbrainzEndpointOptionsSchema.optional() +}); + + export type ListenbrainzEndpointData = z.infer; const envDataSchema = z.object({ LZE_SLUG: listenbrainzEndpointDataSchema.shape.slug, LZE_TOKEN: listenbrainzEndpointDataSchema.shape.token, LZE_USERNAME: listenbrainzEndpointDataSchema.shape.username, + LZE_EXTRACT_ARTISTS: z.stringbool().optional().default(true).meta(envMetaNormalize(listenbrainzEndpointOptionsSchema.shape.extractArtists.meta())) }); export const envSchemas: EnvSourceSchema = { @@ -56,15 +72,13 @@ export const envSchemas: EnvSourceSchema; export const listenbrainzEndpointSourceAIOConfigSchema = z.object({ diff --git a/src/backend/common/vendor/ListenbrainzApiClient.ts b/src/backend/common/vendor/ListenbrainzApiClient.ts index b3048baf..4e95ac96 100644 --- a/src/backend/common/vendor/ListenbrainzApiClient.ts +++ b/src/backend/common/vendor/ListenbrainzApiClient.ts @@ -325,8 +325,16 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless } } -export const listenPayloadToPlay = (payload: ListenPayload, nowPlaying: boolean = false): PlayObject => { +export interface ListenPayloadParseOptions { + extractArtists?: boolean + nowPlaying?: boolean +} + +export const listenPayloadToPlay = (payload: ListenPayload, opts: ListenPayloadParseOptions = {}): PlayObject => { + const { + nowPlaying = false + } = opts; const listened = payload.listened_at ?? dayjs().unix(); const listenedAt = typeof listened === 'number' ? dayjs.unix(listened) : dayjs(listened); @@ -343,14 +351,14 @@ export const listenPayloadToPlay = (payload: ListenPayload, nowPlaying: boolean additional_info, }, listened_at: listenedAt.unix() - }); + }, opts); play.meta.nowPlaying = nowPlaying; return play; } -export const listenResponseToPlay = (listen: ListenResponse): PlayObject => { +export const listenResponseToPlay = (listen: ListenResponse, opts?: ListenPayloadParseOptions): PlayObject => { const { listened_at, track_metadata: { @@ -369,13 +377,17 @@ export const listenResponseToPlay = (listen: ListenResponse): PlayObject => { } = {} } = listen; - const naivePlay = listenToNaivePlay(listen); + const naivePlay = listenToNaivePlay(listen, opts); if(artistMappings.length === 0 || track_name === undefined || artist_name === undefined) { // if there are no artist mappings its likely MB doesn't have info on this track so just use our internally derived attempt // (and track/artist name are required by LZ so without them we can't do any better than naive) return naivePlay; } + if(opts?.extractArtists === false) { + // user wants data as-is + return naivePlay; + } const mappedArtists = artistMappings.length > 0 ? artistMappings.map(x => x.artist_credit_name) : []; const mappedArtistCredit = (name: string): Credit => nameToCredit(name, mbMeta(artistMappings.find(y => y.artist_credit_name === name)?.artist_mbid, 'artist')); @@ -609,7 +621,7 @@ export const listenResponseToPlay = (listen: ListenResponse): PlayObject => { /** * Try to parse true artists and track name without using MB information * */ -export const listenToNaivePlay = (listen: ListenResponse): PlayObject => { +export const listenToNaivePlay = (listen: ListenResponse, opts?: ListenPayloadParseOptions): PlayObject => { const { listened_at, recording_msid, @@ -643,6 +655,10 @@ export const listenToNaivePlay = (listen: ListenResponse): PlayObject => { } = {} } = listen; + const { + extractArtists = true + } = opts ?? {}; + const playId = recording_msid ?? aRecordingMsid; const trackId = aRecordingMbid ?? mRecordingMbid; let dur = aDuration; @@ -656,21 +672,22 @@ export const listenToNaivePlay = (listen: ListenResponse): PlayObject => { if(artist_names.length > 0) { artists = artist_names; } else if(typeof artist_name === 'string') { - - // since we aren't using MB mappings we should be conservative and assume artist string with & are proper names (not joiner) - const parsedArtists = parseCredits(artist_name, [',', '/', '\\']); - if (parsedArtists !== undefined) { - if (parsedArtists.primary !== undefined) { - artists.push(parsedArtists.primary); + if(extractArtists) { + // since we aren't using MB mappings we should be conservative and assume artist string with & are proper names (not joiner) + const parsedArtists = parseCredits(artist_name, [',', '/', '\\']); + if (parsedArtists !== undefined) { + if (parsedArtists.primary !== undefined) { + artists.push(parsedArtists.primary); + } + artists = artists.concat(parsedArtists.secondary ?? []); + } + // use all delimiters when trying to find artists in track name + const parsedTrackArtists = track_name !== undefined ? parseCredits(track_name) : undefined; + if (parsedTrackArtists !== undefined) { + // if we found "ft. something" in track string then we now have a "real" track name and more artists + normalTrackName = parsedTrackArtists.primary; + artists = artists.concat(parsedTrackArtists.secondary ?? []) } - artists = artists.concat(parsedArtists.secondary ?? []); - } - // use all delimiters when trying to find artists in track name - const parsedTrackArtists = track_name !== undefined ? parseCredits(track_name) : undefined; - if (parsedTrackArtists !== undefined) { - // if we found "ft. something" in track string then we now have a "real" track name and more artists - normalTrackName = parsedTrackArtists.primary; - artists = artists.concat(parsedTrackArtists.secondary ?? []) } if(artists.length === 0) { // fallback to original artist_name string if we weren't able to parse anything from artists/track credits diff --git a/src/backend/server/endpointListenbrainzRoutes.ts b/src/backend/server/endpointListenbrainzRoutes.ts index 30bb4ea0..05afacf4 100644 --- a/src/backend/server/endpointListenbrainzRoutes.ts +++ b/src/backend/server/endpointListenbrainzRoutes.ts @@ -77,7 +77,7 @@ export const setupLZEndpointRoutes = (app: Express, router: ReturnType new NowPlayingPlayerState(logger, id, opts); } -export const playStateFromRequest = (obj: SubmitPayload): PlayerStateData[] => { +export const playStateFromRequest = (obj: SubmitPayload, opts?: ListenPayloadParseOptions): PlayerStateData[] => { const { listen_type, payload, } = obj; const playStates: PlayerStateData[] = payload.map((x) => { - const play = listenPayloadToPlay(x, listen_type === 'playing_now'); + const play = listenPayloadToPlay(x, {nowPlaying: listen_type === 'playing_now', ...opts}); play.meta.sourceSOT = SOURCE_SOT.INGRESS; return { platformId: [play.meta.deviceId ?? NO_DEVICE, NO_USER], -- 2.54.0 From ea24d311488b1b370d078f5f0b293cc9face8cd5 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 13 Oct 2026 15:19:48 +0000 Subject: [PATCH 3/3] fix missing ts-expect-error --- src/backend/sources/ExampleSource.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/backend/sources/ExampleSource.ts b/src/backend/sources/ExampleSource.ts index 391b7bf7..00aba087 100644 --- a/src/backend/sources/ExampleSource.ts +++ b/src/backend/sources/ExampleSource.ts @@ -20,6 +20,7 @@ import { UpstreamError } from "../common/errors/UpstreamError.ts"; export class CoolPlayerSource extends MemorySource { // must explicitly declare this here so we override the base config type + // @ts-expect-error its not included as an actual Source because its an example declare config: CoolPlayerSourceConfig; // this source actively makes requests to an external service to parse -- 2.54.0