diff --git a/src/components/bulk-musicbrainz-modal/helpers.test.ts b/src/components/bulk-musicbrainz-modal/helpers.test.ts new file mode 100644 index 0000000..f9ef054 --- /dev/null +++ b/src/components/bulk-musicbrainz-modal/helpers.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import type { BulkMusicBrainzSong } from '@/features/musicbrainz/bulk-lookup' +import { initialReleaseSearch, releaseArtist, VARIOUS_ARTISTS } from './helpers' + +function song(artist: string | null, albumArtist: string | null = null, album = 'Album'): BulkMusicBrainzSong { + return { artist, albumArtist, album } as BulkMusicBrainzSong +} + +describe('releaseArtist', () => { + it('uses the album artist when the files carry one', () => { + expect(releaseArtist([song('Artist A', 'Various Artists'), song('Artist B', 'Various Artists')])).toBe( + 'Various Artists' + ) + expect(releaseArtist([song('Queen'), song('Queen & David Bowie', 'Queen')])).toBe('Queen') + }) + + it('uses the track artist for a regular album', () => { + expect(releaseArtist([song('Daft Punk'), song('Daft Punk'), song('Daft Punk')])).toBe('Daft Punk') + }) + + it('searches for Various Artists when every track has a different artist', () => { + expect(releaseArtist([song('Madonna'), song('Prince'), song('Blondie'), song('Toto')])).toBe(VARIOUS_ARTISTS) + }) + + it('searches for Various Artists when no artist has more than half of the tracks', () => { + expect(releaseArtist([song('Madonna'), song('Madonna'), song('Prince'), song('Blondie')])).toBe(VARIOUS_ARTISTS) + }) + + it('does not treat guest appearances as different artists', () => { + expect( + releaseArtist([ + song('Kendrick Lamar'), + song('Kendrick Lamar feat. SZA'), + song('Kendrick Lamar & Rihanna'), + song('Kendrick Lamar ft. Drake'), + song('Kendrick Lamar') + ]) + ).toBe('Kendrick Lamar') + }) + + it('keeps a single song and empty selections as they are', () => { + expect(releaseArtist([song('Prince')])).toBe('Prince') + expect(releaseArtist([song(null), song(' ')])).toBe('') + }) +}) + +describe('initialReleaseSearch', () => { + it('fills album and artist for a compilation', () => { + expect(initialReleaseSearch([song('Madonna', null, 'Bravo Hits 1'), song('Prince', null, 'Bravo Hits 1')])).toEqual( + { + album: 'Bravo Hits 1', + artist: VARIOUS_ARTISTS + } + ) + }) +}) diff --git a/src/components/bulk-musicbrainz-modal/helpers.ts b/src/components/bulk-musicbrainz-modal/helpers.ts index b3cd70a..b8e6cab 100644 --- a/src/components/bulk-musicbrainz-modal/helpers.ts +++ b/src/components/bulk-musicbrainz-modal/helpers.ts @@ -16,10 +16,37 @@ export function mostCommon(values: Array): string { return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? '' } +/** MusicBrainz' own name for releases by several artists (its special purpose artist). */ +export const VARIOUS_ARTISTS = 'Various Artists' + +/** "Kendrick Lamar feat. SZA" -> "Kendrick Lamar", so guest appearances don't look like different artists. */ +function primaryArtist(artist: string): string { + return artist + .split(/\s+(?:feat\.?|ft\.?|featuring|with|&|x|vs\.?)\s+|\s*[,;/]\s*/i)[0] + .trim() + .toLowerCase() +} + +/** + * The artist to search the release by: the album artist when the files carry one, otherwise the track artist - + * unless no artist (guests aside) appears on more than half of the songs, which makes it a compilation. + */ +export function releaseArtist(songs: BulkMusicBrainzSong[]): string { + const albumArtist = mostCommon(songs.map(song => song.albumArtist)) + if (albumArtist) return albumArtist + + const artists = songs.map(song => song.artist?.trim()).filter((artist): artist is string => !!artist) + const primaries = artists.map(primaryArtist) + const top = mostCommon(primaries) + const topCount = primaries.filter(primary => primary === top).length + if (artists.length > 1 && topCount * 2 <= artists.length) return VARIOUS_ARTISTS + return mostCommon(artists.filter(artist => primaryArtist(artist) === top)) +} + export function initialReleaseSearch(songs: BulkMusicBrainzSong[]) { return { album: mostCommon(songs.map(song => song.album)), - artist: mostCommon(songs.map(song => song.albumArtist || song.artist)) + artist: releaseArtist(songs) } }