From 8331b2a7633ea1ed85b81c48f08090fe511b0244 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 25 Sep 2026 11:50:23 -0400 Subject: [PATCH] fix: Add context for artist parsing where artist string has parenthesis --- src/backend/tests/plays/playParsing.test.ts | 59 ++++++++++++++++++++- src/backend/utils/StringUtils.ts | 24 +++++++-- src/core/tests/utils/PlayTestUtils.ts | 24 +++++---- 3 files changed, 92 insertions(+), 15 deletions(-) diff --git a/src/backend/tests/plays/playParsing.test.ts b/src/backend/tests/plays/playParsing.test.ts index 04f3a1a9..42ef2fc7 100644 --- a/src/backend/tests/plays/playParsing.test.ts +++ b/src/backend/tests/plays/playParsing.test.ts @@ -116,7 +116,7 @@ Found => ${parsed.join(' || ')}`) describe('When joiner is known', function () { it('Parses many primary artists', function () { - for(const i of Array(10)) { + for(const i of Array(40)) { const [str, primaries, secondaries] = generateArtistsStr({primary: {max: 3, joiner: '/'}, secondary: 0}); const credits = parseArtistCredits(str, ['/']); const allArtists = primaries.concat(secondaries); @@ -131,7 +131,7 @@ Found => ${parsed.join(' || ')}`) it('Parses many secondary artists', function () { // fails on -- Peso Pluma / Lil Baby / R. Kelly (featuring TOMORROW X TOGETHER / AC/DC / DaVido) - for(const i of Array(10)) { + for(const i of Array(40)) { const [str, primaries, secondaries] = generateArtistsStr({primary: {max: 3, joiner: '/'}, secondary: {joiner: '/', finalJoiner: false}}); const credits = parseArtistCredits(str, ['/']); const allArtists = primaries.concat(secondaries); @@ -139,6 +139,55 @@ Found => ${parsed.join(' || ')}`) expect(primaries.concat(secondaries),` '${str}' Expected => ${allArtists.join(' || ')} +Found => ${parsed.join(' || ')} +Primaries => ${primaries.join(' || ')} +Secondaries => ${secondaries.join(' || ')} +`) + .eql(parsed) + } + }); + + it('Parses edge case artists formats', function () { + const edgeCases: ReturnType[] = [ + [ + 'Paul Whiteman / BROCKHAMPTON / The Rolling Stones featuring Sonu Nigam / Supertramp / Cliff Edwards (Ukelele Ike)', + ['Paul Whiteman','BROCKHAMPTON','The Rolling Stones'], + ['Sonu Nigam','Supertramp','Cliff Edwards (Ukelele Ike)'] + ], + [ + 'Stray Kids / Mario Lanza vs Cliff Edwards (Ukelele Ike)', + ['Stray Kids','Mario Lanza'], + ['Cliff Edwards (Ukelele Ike)'] + ], + [ + 'Peso Pluma / Lil Baby / R. Kelly (featuring TOMORROW X TOGETHER / AC/DC / DaVido)', + ['Peso Pluma','Lil Baby', 'R. Kelly'], + ['TOMORROW X TOGETHER', 'AC/DC','DaVido'] + ], + [ + 'Roy Orbison featuring Snow Patrol / The Detroit Spinners / Cliff Edwards (Ukelele Ike)', + ['Roy Orbison'], + ['Snow Patrol','The Detroit Spinners','Cliff Edwards (Ukelele Ike)'] + ], + [ + 'Stray Kids / Mario Lanza (featuring Snow Patrol / Cliff Edwards (Ukelele Ike))', + ['Stray Kids','Mario Lanza'], + ['Snow Patrol','Cliff Edwards (Ukelele Ike)'] + ], + [ + 'Cliff Edwards (Ukelele Ike) / Stray Kids feat. Snow Patrol', + ['Cliff Edwards (Ukelele Ike)','Stray Kids'], + ['Snow Patrol'] + ], + ]; + for(const edge of edgeCases) { + const [str, primaries, secondaries] = edge; + const credits = parseArtistCredits(str, ['/']); + const allArtists = primaries.concat(secondaries); + const parsed = [credits!.primary].concat(credits!.secondary ?? []) + expect(primaries.concat(secondaries),` +'${str}' +Expected => ${allArtists.join(' || ')} Found => ${parsed.join(' || ')}`) .eql(parsed) } @@ -152,6 +201,12 @@ Found => ${parsed.join(' || ')}`) describe('Play Track Strings',function () { + it('should keep unwrapped trailing parenthetical as track suffix', function() { + const res = parseTrackCredits('Criminal mind Ft Akon (Remix Braquer vos têtes)')!; + expect(res.secondary).eql(['Akon']); + expect(res.primaryComposite).eq('Criminal mind (Remix Braquer vos têtes)'); + }); + const testFixtures = testData as unknown as PlayTestFixture[]; const joinerData = testFixtures.filter(x => intersect(['joiner','track'], x.caseHints).length === 2); diff --git a/src/backend/utils/StringUtils.ts b/src/backend/utils/StringUtils.ts index 4652402c..1eddfe0b 100644 --- a/src/backend/utils/StringUtils.ts +++ b/src/backend/utils/StringUtils.ts @@ -129,7 +129,19 @@ export const PRIMARY_SECONDARY_SECTIONS_REGEX = new RegExp(/^(?.+?)(? May have closing character ) ] * */ // export const SECONDARY_ARTISTS_REGEX = new RegExp(//ig); -export const parseCredits = (str: string, delimiters?: boolean | string[]): PlayCredits | undefined => { +export interface ParseCreditsOptions { + /** + * When secondary credits are NOT wrapped (SECONDARY_FREE_REGEX) a trailing parenthetical is normally treated as a suffix + * + * EX Criminal mind Ft Akon (Remix Braquer vos têtes) => suffix = (Remix Braquer vos têtes) + * + * For artist strings a trailing parenthetical is instead part of the last artist's name, so it should stay with the credits + * + * EX Roy Orbison featuring Snow Patrol / Cliff Edwards (Ukelele Ike) => last credit = Cliff Edwards (Ukelele Ike) + * */ + wrappedSuffixIsCredit?: boolean +} +export const parseCredits = (str: string, delimiters?: boolean | string[], opts: ParseCreditsOptions = {}): PlayCredits | undefined => { if (str.trim() === '') { return undefined; } @@ -150,8 +162,14 @@ export const parseCredits = (str: string, delimiters?: boolean | string[]): Play for(const strat of SECONDARY_REGEX_STRATS) { const secCredits = parseRegexSingle(strat, results.named.secondary); if(secCredits !== undefined) { - secondary = parseContextAwareStringList(secCredits.named.credits as string, delims) + let credits = secCredits.named.credits as string; suffix = secCredits.named.creditsSuffix; + if(opts.wrappedSuffixIsCredit && strat === SECONDARY_FREE_REGEX && suffix !== undefined && /^\s*[([].+[)\]]\s*$/.test(suffix)) { + // move the parenthetical "suffix" back onto the credits so it is kept as part of the last credit + credits = `${credits}${suffix}`; + suffix = undefined; + } + secondary = parseContextAwareStringList(credits, delims) break; } } @@ -178,7 +196,7 @@ export const parseArtistCredits = (str: string, delimiters?: boolean | string[], } else if (delimiters === false) { delims = []; } - const withJoiner = parseCredits(str, delimiters); + const withJoiner = parseCredits(str, delimiters, {wrappedSuffixIsCredit: true}); if (withJoiner !== undefined) { // all this does is make sure and "ft" or parenthesis/brackets are separated -- // it doesn't also separate primary artists so do that now diff --git a/src/core/tests/utils/PlayTestUtils.ts b/src/core/tests/utils/PlayTestUtils.ts index 826604a7..d3a9736f 100644 --- a/src/core/tests/utils/PlayTestUtils.ts +++ b/src/core/tests/utils/PlayTestUtils.ts @@ -327,25 +327,29 @@ export const generateArtists = (num?: number, max: number = 3, opts: ArtistGener ambiguousJoinedNames = false } = opts; - if(!trailingAmpersand) { - // its really hard to parse an artist name that contains an '&' when it comes at the end of a list - // because its ambigious if the list is joining the list with & or if & is part of the artist name - // so by default don't generate these (we test for specific scenarios in playParsing.test.ts) - while(artists[artists.length - 1].includes('&')) { - artists = artists.slice(0, artists.length - 1).concat(faker.music.artist()); - } + const isAmbiguous = (a: string) => { + const foundDelims = findDelimiters(a); + return foundDelims !== undefined && foundDelims.length > 0 && !(foundDelims.length === 1 && foundDelims[0] === '&'); } + if(!ambiguousJoinedNames) { artists = artists.map(x => { let a = x; - let foundDelims = findDelimiters(a); - while(foundDelims !== undefined && foundDelims.length > 0 && !(foundDelims.length === 1 && foundDelims[0] === '&')) { + while(isAmbiguous(a)) { a = faker.music.artist(); - foundDelims = findDelimiters(a); } return a; }); } + // must run AFTER ambiguous name replacement, otherwise a replacement could re-introduce a trailing '&' name + if(!trailingAmpersand) { + // its really hard to parse an artist name that contains an '&' when it comes at the end of a list + // because its ambigious if the list is joining the list with & or if & is part of the artist name + // so by default don't generate these (we test for specific scenarios in playParsing.test.ts) + while(artists[artists.length - 1].includes('&') || (!ambiguousJoinedNames && isAmbiguous(artists[artists.length - 1]))) { + artists = artists.slice(0, artists.length - 1).concat(faker.music.artist()); + } + } return artists; } -- 2.51.2