[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: Improve artist string parsing

* Don't split artist with joiner when only one joiner is present
* Override naive parsing if metabrainz mapped artists fit with provided joiners

FoxxMD (Aug 7, 2025, 7:32 PM UTC) 33fda80d a3d8372f

+63 -23
+6 -6
src/backend/utils/StringUtils.ts
··· 134 134 } 135 135 return undefined; 136 136 } 137 - export const parseArtistCredits = (str: string, delimiters?: boolean | string[]): PlayCredits | undefined => { 137 + export const parseArtistCredits = (str: string, delimiters?: boolean | string[], ignoreGlobalAmpersand?: boolean): PlayCredits | undefined => { 138 138 if (str.trim() === '') { 139 139 return undefined; 140 140 } ··· 148 148 if (withJoiner !== undefined) { 149 149 // all this does is make sure and "ft" or parenthesis/brackets are separated -- 150 150 // it doesn't also separate primary artists so do that now 151 - const primaries = parseContextAwareStringList(withJoiner.primary, delims); 151 + const primaries = parseContextAwareStringList(withJoiner.primary, delims, {ignoreGlobalAmpersand: ignoreGlobalAmpersand ?? false}); 152 152 if (primaries.length > 1) { 153 153 return { 154 154 primary: primaries[0], ··· 159 159 return withJoiner; 160 160 } 161 161 // likely this is a plain string with just delims 162 - const artists = parseStringList(str, delims); 162 + const artists = parseContextAwareStringList(str, delims, {ignoreGlobalAmpersand: ignoreGlobalAmpersand ?? true}); 163 163 if (artists.length > 1) { 164 164 return { 165 165 primary: artists[0], ··· 173 173 } 174 174 } 175 175 export const parseTrackCredits = (str: string, delimiters?: boolean | string[]): PlayCredits | undefined => parseCredits(str, delimiters); 176 - export const parseStringList = (str: string, delimiters: string[] = [',', '&', '/', '\\']): string[] => { 176 + export const parseStringList = (str: string, delimiters: string[] = DELIMITERS): string[] => { 177 177 if (delimiters.length === 0) { 178 178 return [str]; 179 179 } ··· 227 227 return bypassed; 228 228 } 229 229 export const containsDelimiters = (str: string) => null !== str.match(/[,&/\\]+/i) 230 - export const findDelimiters = (str: string) => { 230 + export const findDelimiters = (str: string, delimiters = DELIMITERS) => { 231 231 const found: string[] = []; 232 - for (const d of DELIMITERS) { 232 + for (const d of delimiters) { 233 233 if (str.indexOf(d) !== -1) { 234 234 found.push(d); 235 235 }
+11 -3
src/backend/common/vendor/ListenbrainzApiClient.ts
··· 14 14 } from "../../utils/StringUtils.js"; 15 15 import { getScrobbleTsSOCDate } from "../../utils/TimeUtils.js"; 16 16 import { UpstreamError } from "../errors/UpstreamError.js"; 17 - import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js"; 17 + import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, DELIMITERS, FormatPlayObjectOptions } from "../infrastructure/Atomic.js"; 18 18 import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz.js"; 19 19 import AbstractApiClient from "./AbstractApiClient.js"; 20 20 import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from '../../utils/NetworkUtils.js'; 21 - import { removeUndefinedKeys } from '../../utils.js'; 21 + import { removeUndefinedKeys, unique } from '../../utils.js'; 22 22 import {ListensResponse as KoitoListensResponse} from '../infrastructure/config/client/koito.js' 23 23 import { listenObjectResponseToPlay } from './koito/KoitoApiClient.js'; 24 24 import { version } from '../../ioc.js'; ··· 393 393 } 394 394 395 395 // now try to extract any remaining artists from filtered artist/name values 396 - const parsedArtists = parseArtistCredits(filteredSubmittedArtistName); 396 + const splitAmpersand = artistsWithJoiners.length === 0 && artistMappings.some(x => x.join_phrase.includes('&')); 397 + let nonProperJoinedDelims = undefined; 398 + if(artistsWithJoiners.length === 0) { 399 + nonProperJoinedDelims = unique(artistMappings.filter(x => DELIMITERS.includes(x.join_phrase.trim())).map(x => x.join_phrase.trim())); 400 + if(nonProperJoinedDelims.length === 0) { 401 + nonProperJoinedDelims = undefined; 402 + } 403 + } 404 + const parsedArtists = parseArtistCredits(filteredSubmittedArtistName, nonProperJoinedDelims); 397 405 if (parsedArtists !== undefined) { 398 406 if (parsedArtists.primary !== undefined) { 399 407 artistsFromUserValues.push(parsedArtists.primary);
+2 -2
src/backend/tests/listenbrainz/listenbrainz.test.ts
··· 27 27 data: ListenResponse 28 28 expected: ExpectedResults 29 29 } 30 - describe('Listenbrainz Listen Parsing', function () { 30 + describe('#PlayParse Listenbrainz Listen Parsing', function () { 31 31 32 32 describe('When user-submitted artist/track do NOT match MB mappings', function() { 33 33 it('Uses user submitted values when no artist mappings', async function () { ··· 56 56 }) 57 57 58 58 59 - describe('When user-submitted artist/track matches a MB mapped value', function() { 59 + describe('#PlayParse When user-submitted artist/track matches a MB mapped value', function() { 60 60 61 61 it('Detects slightly different track names as equal', async function () { 62 62 for(const test of slightlyDifferentNames as unknown as LZTestFixture[]) {
+33 -7
src/backend/tests/plays/playParsing.test.ts
··· 6 6 import { asPlays, generateArtistsStr, generatePlay, normalizePlays } from "../utils/PlayTestUtils.js"; 7 7 import { parseArtistCredits, parseContextAwareStringList, parseCredits } from "../../utils/StringUtils.js"; 8 8 9 - describe('Parsing Artists from String', function() { 9 + describe('#PlayParse Parsing Artists from String', function() { 10 10 11 11 it('Parses Artists from an Artist-like string', function () { 12 - for(const i of Array(20)) { 13 - const [str, primaries, secondaries] = generateArtistsStr(); 12 + for(const i of Array(40)) { 13 + const [str, primaries, secondaries] = generateArtistsStr({primary: {max: 3, ambiguousJoinedNames: true, trailingAmpersand: true, finalJoiner: false}}); 14 14 const credits = parseArtistCredits(str); 15 15 const allArtists = primaries.concat(secondaries); 16 - const parsed = [credits.primary].concat(credits.secondary ?? []) 16 + const parsed = [credits.primary].concat(credits.secondary ?? []); 17 17 expect(primaries.concat(secondaries),` 18 18 '${str}' 19 19 Expected => ${allArtists.join(' || ')} ··· 25 25 26 26 it('Parses & as "local" joiner when other delimiters present', function () { 27 27 28 - const data = [{ 28 + const data = [ 29 + { 29 30 str: `Melendi \\ Ryan Lewis \\ The Righteous Brothers (featuring Joan Jett & The Blackhearts \\ Robin Schulz)`, 30 31 expected: ['Melendi', 'Ryan Lewis', 'The Righteous Brothers', 'Joan Jett & The Blackhearts', 'Robin Schulz'] 31 - }, { 32 + }, 33 + { 32 34 str: `Gigi D'Agostino \\ YOASOBI (vs Sam Hunt, Lisa Loeb & Booba)`, 33 35 expected: [`Gigi D'Agostino`, 'YOASOBI', 'Sam Hunt', 'Lisa Loeb', 'Booba'] 34 - }]; 36 + }, 37 + { 38 + str: `Wham!, Hillsong Worship & Bruce Channel feat. I Prevail`, 39 + expected: ['Wham!', 'Hillsong Worship & Bruce Channel', 'I Prevail'] 40 + } 41 + ]; 35 42 36 43 for(const d of data) { 37 44 const credits = parseArtistCredits(d.str); ··· 47 54 str: `Melendi & Ryan Lewis & The Righteous Brothers (featuring The Blackhearts \\ Robin Schulz)`, 48 55 expected: ['Melendi', 'Ryan Lewis', 'The Righteous Brothers', 'The Blackhearts', 'Robin Schulz'] 49 56 }]; 57 + 58 + for(const d of data) { 59 + const credits = parseArtistCredits(d.str); 60 + const parsed = [credits.primary].concat(credits.secondary ?? []) 61 + expect(d.expected).eql(parsed) 62 + } 63 + }); 64 + 65 + it('Does not split artist name when only one joiner is present', function () { 66 + 67 + const data = [ 68 + { 69 + str: `Melendi & Ryan Lewis`, 70 + expected: ['Melendi & Ryan Lewis'] 71 + },{ 72 + str: `Melendi and Ryan Lewis`, 73 + expected: ['Melendi and Ryan Lewis'] 74 + }, 75 + ]; 50 76 51 77 for(const d of data) { 52 78 const credits = parseArtistCredits(d.str);
+8 -2
src/backend/tests/utils/PlayTestUtils.ts
··· 181 181 182 182 export const generateArtist = () => faker.music.artist; 183 183 184 - export const generateArtists = (num?: number, max: number = 3, opts: {ambiguousJoinedNames?: boolean, trailingAmpersand?: boolean} = {}) => { 184 + export interface ArtistGenerationOptions 185 + { 186 + ambiguousJoinedNames?: boolean, 187 + trailingAmpersand?: boolean 188 + } 189 + 190 + export const generateArtists = (num?: number, max: number = 3, opts: ArtistGenerationOptions = {}) => { 185 191 if(num === 0 || max === 0) { 186 192 return []; 187 193 } ··· 214 220 return artists; 215 221 } 216 222 217 - export interface ArtistGenerateOptions { 223 + export interface ArtistGenerateOptions extends ArtistGenerationOptions { 218 224 num?: number 219 225 max?: number 220 226 joiner?: string
+1 -1
src/backend/tests/utils/strings.test.ts
··· 9 9 uniqueNormalizedStrArr 10 10 } from "../../utils/StringUtils.js"; 11 11 import { ExpectedResults } from "./interfaces.js"; 12 - import testData from './playTestData.json'; 12 + import testData from './playTestData.json' with { type: "json" }; 13 13 import { splitByFirstFound } from '../../../core/StringUtils.js'; 14 14 15 15 interface PlayTestFixture {
+2 -2
src/backend/tests/listenbrainz/correctlyMapped/multiArtistInArtistName.json
··· 74 74 { 75 75 "artist_credit_name": "Metro Boomin", 76 76 "artist_mbid": "59db3d82-86ea-451f-881f-dffc8ec387c9", 77 - "join_phrase": " & " 77 + "join_phrase": " , " 78 78 }, 79 79 { 80 80 "artist_credit_name": "James Blake", ··· 116 116 { 117 117 "artist_credit_name": "Childish Gambino", 118 118 "artist_mbid": "7fb57fba-a6ef-44c2-abab-2fa3bdee607e", 119 - "join_phrase": " & " 119 + "join_phrase": " , " 120 120 }, 121 121 { 122 122 "artist_credit_name": "Ariana Grande",