import { creditId, creditIds, spotifyMeta, withImage, nameToCredit, creditsToNames, isrcMeta, creditIsrc } from "../../../core/MusicMetadata.ts"; 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 { initMemoryCache } from '../../common/Cache.ts'; import SpotifyTransformer, { COMPILATION_PENALTY, missingSpotifyTypes, parseStageConfig, rankTracksBySimilarity, type SpotifyTransformerDataStage, } from '../../common/transforms/SpotifyTransformer.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); const basePlay = (data: Partial = {}, meta: Partial = {}): PlayObject => ({ data: { track: nameToCredit('My Track', isrcMeta('1234')), artists: [{ name: 'My Artist' }], album: nameToCredit('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; }; 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 () { 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(DEFAULT_SPOTIFY_MISSING_TYPES); }); 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 fields exists', function () { const missing = missingSpotifyTypes({data: {}, meta: {}}); expect(missing).to.deep.include.members(spotifyMissingTypes.options); }); it('returns empty when all missing types are present', function () { const play = basePlay({ track: nameToCredit('My Track', spotifyMeta('t1', 'track'), isrcMeta('1234')), album: withImage(nameToCredit('My Album', spotifyMeta('a1', 'album')), 'https://example.com'), artists: [nameToCredit('My Artist', spotifyMeta('ar1', 'artist'))] }); 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({ track: nameToCredit('My Track', spotifyMeta('t1', 'track')), album: nameToCredit('My Album', spotifyMeta('a1', 'album')), artists: [nameToCredit('One', spotifyMeta('ar1', 'artist')), nameToCredit('Two')] }); const missing = missingSpotifyTypes(play); expect(missing).to.include('ids'); }); }); 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?.name).to.equal('My Track'); expect(play.data.album?.name).to.equal('My Album'); expect(creditIsrc(play.data.track)).to.equal('USRC17607839'); expect(play.data.duration).to.equal(180); expect(creditsToNames(play.data.artists)).to.deep.equal(['My Artist']); expect(creditIds(play.data.artists, 'spotify', 'artist')).to.deep.equal(['artist1']); expect(play.data.albumArtists).to.deep.equal([]); expect(creditId(play.data.track, 'spotify', 'track')).to.equal('track1'); expect(creditId(play.data.album, '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(creditsToNames(play.data.albumArtists)).to.deep.equal(['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: nameToCredit('Little Joe and Mary'), artists: [{ name: 'Khruangbin' }], album: nameToCredit('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: nameToCredit('Little Joe and Mary'), artists: [{ name: 'Khruangbin' }], album: nameToCredit('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); }); }); 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: nameToCredit('KAISEI:Movie Edition from Project SEKAI', isrcMeta('JPPO02201234')), artists: [{ name: 'Project SEKAI' }] }); 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?.name).to.equal('快晴「劇場版プロジェクトセカイ」ver.'); }); it('still filters a basic-search match by the minimum score threshold', async function () { const play = basePlay({ track: nameToCredit('KAISEI:Movie Edition from Project SEKAI'), artists: [{ name: 'Project SEKAI' }], album: nameToCredit('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: nameToCredit('My Track', isrcMeta('USRC17607839')), artists: [{ name: 'My Artist' }], album: nameToCredit('The Real Album') }); 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(creditId(result.data.track, 'spotify', 'track')).to.equal('right'); }); }); });