All checks were successful
Deploy production / deploy (push) Successful in 2m37s
- music schema: artists/albums/tracks (+album art), plays with source + dedupe, now_playing, spotify_auth, import_state; plays_deduped view hides Last.fm scrobbles Spotify also has - in-app Spotify poller (60s, leased) started from hooks init; no new role or env needed - /api/spotify/connect + callback (owner only), /trackers/music public stats - scripts: music-poll, music-import-lastfm (resumable), music-import-history - deploy/music-collector: optional host timer with its own role
168 lines
5.5 KiB
TypeScript
168 lines
5.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { parseHistoryEntry } from './history';
|
|
import { parseRecentTracks } from './lastfm-parse';
|
|
import { matchKey } from './normalize';
|
|
import { parsePlayer, parseRecentlyPlayed, parseTrack, pickImages } from './spotify-parse';
|
|
|
|
const ID = '4uLU6hMCjMI75M1A2tKUQC';
|
|
const spotifyTrack = {
|
|
type: 'track',
|
|
id: ID,
|
|
name: 'Never Gonna Give You Up',
|
|
duration_ms: 213573,
|
|
explicit: false,
|
|
artists: [{ id: '0gxyHStUsqpMadRV0Di1Qt', name: 'Rick Astley' }],
|
|
album: {
|
|
id: '6N9PS4QXF1D0OWPk0Sxtb4',
|
|
name: 'Whenever You Need Somebody',
|
|
release_date: '1987-11-12',
|
|
images: [
|
|
{ url: 'https://i.scdn.co/image/640', width: 640, height: 640 },
|
|
{ url: 'https://i.scdn.co/image/300', width: 300, height: 300 },
|
|
{ url: 'https://i.scdn.co/image/64', width: 64, height: 64 }
|
|
]
|
|
}
|
|
};
|
|
|
|
describe('spotify parsing', () => {
|
|
it('parses a track with album art', () => {
|
|
const t = parseTrack(spotifyTrack)!;
|
|
expect(t.spotifyId).toBe(ID);
|
|
expect(t.artists).toEqual([{ spotifyId: '0gxyHStUsqpMadRV0Di1Qt', name: 'Rick Astley' }]);
|
|
expect(t.album?.imageUrl).toBe('https://i.scdn.co/image/640');
|
|
expect(t.album?.imageUrlSmall).toBe('https://i.scdn.co/image/300');
|
|
});
|
|
it('skips local files, episodes and nameless items', () => {
|
|
expect(parseTrack({ ...spotifyTrack, is_local: true })).toBeNull();
|
|
expect(parseTrack({ ...spotifyTrack, type: 'episode' })).toBeNull();
|
|
expect(parseTrack({ ...spotifyTrack, name: ' ' })).toBeNull();
|
|
});
|
|
it('keeps only https images', () => {
|
|
expect(pickImages([{ url: 'http://x/1', width: 640 }])).toEqual({ large: null, small: null });
|
|
});
|
|
it('sorts recently played oldest first', () => {
|
|
const plays = parseRecentlyPlayed({
|
|
items: [
|
|
{
|
|
track: spotifyTrack,
|
|
played_at: '2026-10-24T15:00:00.123Z',
|
|
context: { type: 'playlist', uri: 'spotify:playlist:x' }
|
|
},
|
|
{ track: spotifyTrack, played_at: '2026-10-24T14:00:00.000Z', context: null },
|
|
{ track: null, played_at: '2026-10-24T13:00:00.000Z' }
|
|
]
|
|
});
|
|
expect(plays.map((p) => p.playedAt.toISOString())).toEqual([
|
|
'2026-10-24T14:00:00.000Z',
|
|
'2026-10-24T15:00:00.123Z'
|
|
]);
|
|
expect(plays[1].contextType).toBe('playlist');
|
|
});
|
|
it('parses the player and ignores podcasts', () => {
|
|
expect(parsePlayer(null)).toBeNull();
|
|
const p = parsePlayer({
|
|
is_playing: true,
|
|
progress_ms: 1000,
|
|
currently_playing_type: 'track',
|
|
item: spotifyTrack,
|
|
device: { name: 'Mac', type: 'Computer' }
|
|
})!;
|
|
expect(p).toMatchObject({ isPlaying: true, deviceName: 'Mac' });
|
|
expect(p.track?.name).toBe('Never Gonna Give You Up');
|
|
expect(
|
|
parsePlayer({ is_playing: true, currently_playing_type: 'episode', item: {} })!.track
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('history export parsing', () => {
|
|
it('parses extended streaming history and skips short plays and podcasts', () => {
|
|
const play = parseHistoryEntry({
|
|
ts: '2024-01-02T03:04:05Z',
|
|
ms_played: 200000,
|
|
platform: 'ios',
|
|
master_metadata_track_name: 'Song',
|
|
master_metadata_album_artist_name: 'Artist',
|
|
master_metadata_album_album_name: 'Album',
|
|
spotify_track_uri: `spotify:track:${ID}`
|
|
})!;
|
|
expect(play.track.spotifyId).toBe(ID);
|
|
expect(play.playedAt.toISOString()).toBe('2024-01-02T03:04:05.000Z');
|
|
expect(
|
|
parseHistoryEntry({
|
|
ts: '2024-01-02T03:04:05Z',
|
|
ms_played: 5000,
|
|
master_metadata_track_name: 'S',
|
|
master_metadata_album_artist_name: 'A'
|
|
})
|
|
).toBeNull();
|
|
expect(
|
|
parseHistoryEntry({
|
|
ts: '2024-01-02T03:04:05Z',
|
|
ms_played: 900000,
|
|
master_metadata_track_name: null,
|
|
episode_name: 'Pod'
|
|
})
|
|
).toBeNull();
|
|
});
|
|
it('parses the account-data export (UTC endTime)', () => {
|
|
const play = parseHistoryEntry({
|
|
endTime: '2023-05-06 07:08',
|
|
artistName: 'A',
|
|
trackName: 'T',
|
|
msPlayed: 60000
|
|
})!;
|
|
expect(play.playedAt.toISOString()).toBe('2023-05-06T07:08:00.000Z');
|
|
expect(play.track.spotifyId).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('last.fm parsing', () => {
|
|
it('skips the now-playing item and placeholder covers', () => {
|
|
const page = parseRecentTracks({
|
|
recenttracks: {
|
|
'@attr': { page: '1', totalPages: '3', total: '500' },
|
|
track: [
|
|
{ '@attr': { nowplaying: 'true' }, name: 'Now', artist: { '#text': 'A' } },
|
|
{
|
|
name: 'Song',
|
|
artist: { '#text': 'Artist' },
|
|
album: { '#text': 'Album' },
|
|
date: { uts: '1700000000' },
|
|
image: [
|
|
{ size: 'large', '#text': 'https://lastfm.freetls.fastly.net/i/u/174s/abc.png' },
|
|
{
|
|
size: 'extralarge',
|
|
'#text': 'https://lastfm.freetls.fastly.net/i/u/300x300/abc.png'
|
|
}
|
|
]
|
|
},
|
|
{
|
|
name: 'Starless',
|
|
artist: { '#text': 'B' },
|
|
date: { uts: '1690000000' },
|
|
image: [{ size: 'large', '#text': 'https://x/2a96cbd8b46e442fc41c2b86b821562f.png' }]
|
|
}
|
|
]
|
|
}
|
|
});
|
|
expect(page.totalPages).toBe(3);
|
|
expect(page.plays).toHaveLength(2);
|
|
expect(page.plays[0].playedAt.toISOString()).toBe('2023-11-14T22:13:20.000Z');
|
|
expect(page.plays[0].track.album?.imageUrl).toContain('300x300');
|
|
expect(page.plays[1].track.album).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('matchKey', () => {
|
|
it('ignores remaster/feat/accents/punctuation differences', () => {
|
|
const base = matchKey('Beyoncé', 'Crazy in Love');
|
|
expect(matchKey('Beyonce', 'Crazy In Love (feat. Jay-Z)')).toBe(base);
|
|
expect(matchKey('BEYONCÉ', 'Crazy in Love - 2003 Remaster')).toBe(base);
|
|
expect(matchKey('Simon & Garfunkel', 'The Boxer')).toBe(
|
|
matchKey('Simon and Garfunkel', 'The Boxer')
|
|
);
|
|
expect(matchKey('A', 'Song - Part 2')).not.toBe(matchKey('A', 'Song'));
|
|
});
|
|
});
|