music: self-hosted scrobbler (Spotify poller, Last.fm + streaming-history importers, /trackers/music, homepage listening card)
All checks were successful
Deploy production / deploy (push) Successful in 2m37s
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
This commit is contained in:
167
src/lib/music/music.test.ts
Normal file
167
src/lib/music/music.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
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'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user