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:
90
src/lib/music/ListeningCard.svelte
Normal file
90
src/lib/music/ListeningCard.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { relativeTime } from '#lib/relative-time.js';
|
||||
import Music from 'lucide-svelte/icons/music';
|
||||
|
||||
type Listening = {
|
||||
nowPlaying: boolean;
|
||||
at: string;
|
||||
track: string;
|
||||
artists: string;
|
||||
album: string | null;
|
||||
image: string | null;
|
||||
url: string | null;
|
||||
};
|
||||
let { listening, href = '/trackers/music' }: { listening: Listening; href?: string } = $props();
|
||||
|
||||
let now = $state(Date.now());
|
||||
$effect(() => {
|
||||
const timer = setInterval(() => (now = Date.now()), 30_000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<a
|
||||
{href}
|
||||
class="flex items-center gap-4 rounded-md border border-[var(--site-border)] bg-[var(--site-surface)] p-3 hover:border-[var(--site-text)]"
|
||||
aria-label="{listening.nowPlaying
|
||||
? 'Listening to'
|
||||
: 'Last played'} {listening.track} by {listening.artists}"
|
||||
>
|
||||
{#if listening.image}
|
||||
<img
|
||||
src={listening.image}
|
||||
alt=""
|
||||
width="64"
|
||||
height="64"
|
||||
loading="lazy"
|
||||
class="size-16 shrink-0 rounded object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<span class="flex size-16 shrink-0 items-center justify-center rounded bg-[var(--site-border)]"
|
||||
><Music size={24} aria-hidden="true" /></span
|
||||
>
|
||||
{/if}
|
||||
<span class="min-w-0">
|
||||
<span class="flex items-center gap-2 text-sm text-[var(--site-muted)]">
|
||||
{#if listening.nowPlaying}
|
||||
<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span> listening now
|
||||
{:else}
|
||||
last played {relativeTime(listening.at, now)}
|
||||
{/if}
|
||||
</span>
|
||||
<span class="block truncate font-medium">{listening.track}</span>
|
||||
<span class="block truncate text-sm text-[var(--site-muted)]">{listening.artists}</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.eq {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 12px;
|
||||
}
|
||||
.eq i {
|
||||
width: 3px;
|
||||
background: #1db954;
|
||||
animation: eq 0.9s ease-in-out infinite;
|
||||
}
|
||||
.eq i:nth-child(2) {
|
||||
animation-delay: -0.3s;
|
||||
}
|
||||
.eq i:nth-child(3) {
|
||||
animation-delay: -0.6s;
|
||||
}
|
||||
@keyframes eq {
|
||||
0%,
|
||||
100% {
|
||||
height: 3px;
|
||||
}
|
||||
50% {
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.eq i {
|
||||
animation: none;
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
177
src/lib/music/collector.server.ts
Normal file
177
src/lib/music/collector.server.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
// Spotify poller: one call does what a scrobbler does every minute.
|
||||
// 1. GET /me/player → music.now_playing (row 1)
|
||||
// 2. GET /me/player/recently-played?after=<cursor> → music.plays (Spotify lists plays of
|
||||
// 30 s or more, max 50 per call, so polling every minute never misses one)
|
||||
// The authorization (refresh token) lives in music.spotify_auth, written by
|
||||
// /api/spotify/callback. A lease column keeps two app containers (e.g. mid-deploy) or the
|
||||
// optional host timer (scripts/music-poll.ts) from polling at the same time.
|
||||
import { newCache, upsertTrack, insertPlay, type Db } from './store.server';
|
||||
import { parsePlayer, parseRecentlyPlayed } from './spotify-parse';
|
||||
|
||||
export type SpotifyCredentials = { clientId: string; clientSecret: string };
|
||||
type Fetch = typeof fetch;
|
||||
|
||||
export const SPOTIFY_SCOPES = [
|
||||
'user-read-currently-playing',
|
||||
'user-read-recently-played',
|
||||
'user-read-playback-state',
|
||||
'user-top-read'
|
||||
] as const;
|
||||
|
||||
export class SpotifyError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
readonly code?: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type TokenResponse = {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
async function token(
|
||||
fetchFn: Fetch,
|
||||
creds: SpotifyCredentials,
|
||||
body: Record<string, string>
|
||||
): Promise<TokenResponse> {
|
||||
const response = await fetchFn('https://accounts.spotify.com/api/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${btoa(`${creds.clientId}:${creds.clientSecret}`)}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams(body),
|
||||
signal: AbortSignal.timeout(15_000)
|
||||
});
|
||||
const json = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
if (!response.ok || typeof json.access_token !== 'string')
|
||||
throw new SpotifyError(
|
||||
response.status,
|
||||
`Spotify token request failed (${response.status} ${String(json.error ?? '')})`,
|
||||
typeof json.error === 'string' ? json.error : undefined
|
||||
);
|
||||
return json as unknown as TokenResponse;
|
||||
}
|
||||
|
||||
export const exchangeCode = (
|
||||
fetchFn: Fetch,
|
||||
creds: SpotifyCredentials,
|
||||
code: string,
|
||||
redirectUri: string
|
||||
) => token(fetchFn, creds, { grant_type: 'authorization_code', code, redirect_uri: redirectUri });
|
||||
|
||||
export const refreshAccessToken = (fetchFn: Fetch, creds: SpotifyCredentials, refresh: string) =>
|
||||
token(fetchFn, creds, { grant_type: 'refresh_token', refresh_token: refresh });
|
||||
|
||||
/** GET a Web API path; null for 204 No Content. */
|
||||
export async function spotifyGet(fetchFn: Fetch, access: string, path: string) {
|
||||
const response = await fetchFn(`https://api.spotify.com/v1/${path}`, {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
signal: AbortSignal.timeout(15_000)
|
||||
});
|
||||
if (response.status === 204) return null;
|
||||
if (!response.ok)
|
||||
throw new SpotifyError(
|
||||
response.status,
|
||||
`Spotify GET /${path.split('?')[0]} → ${response.status}`
|
||||
);
|
||||
const text = await response.text();
|
||||
return text ? (JSON.parse(text) as unknown) : null;
|
||||
}
|
||||
|
||||
export type PollResult =
|
||||
| { status: 'idle' }
|
||||
| { status: 'ok'; playing: boolean; newPlays: number }
|
||||
| { status: 'error'; error: string };
|
||||
|
||||
export async function pollSpotify(
|
||||
sql: Db,
|
||||
creds: SpotifyCredentials,
|
||||
fetchFn: Fetch = fetch
|
||||
): Promise<PollResult> {
|
||||
const [auth] = await sql`
|
||||
update music.spotify_auth set lease_until = now() + interval '55 seconds'
|
||||
where id = 1 and not needs_reauth and (lease_until is null or lease_until < now())
|
||||
returning refresh_token, access_token, access_expires_at, recent_cursor`;
|
||||
if (!auth) return { status: 'idle' };
|
||||
let error: string | null = null;
|
||||
let result: PollResult;
|
||||
try {
|
||||
let access: string | null = auth.access_token;
|
||||
const expires = auth.access_expires_at ? new Date(auth.access_expires_at).getTime() : 0;
|
||||
if (!access || expires < Date.now() + 60_000) {
|
||||
const fresh = await refreshAccessToken(fetchFn, creds, auth.refresh_token);
|
||||
access = fresh.access_token;
|
||||
await sql`
|
||||
update music.spotify_auth set access_token = ${access},
|
||||
access_expires_at = now() + make_interval(secs => ${fresh.expires_in}),
|
||||
refresh_token = coalesce(${fresh.refresh_token ?? null}, refresh_token),
|
||||
scope = coalesce(${fresh.scope ?? null}, scope), updated_at = now()
|
||||
where id = 1`;
|
||||
}
|
||||
const cache = newCache();
|
||||
|
||||
const player = parsePlayer(
|
||||
await spotifyGet(fetchFn, access!, 'me/player?additional_types=episode')
|
||||
);
|
||||
const trackId = player?.track ? await upsertTrack(sql, player.track, true, cache) : null;
|
||||
await sql`
|
||||
insert into music.now_playing (id, track_id, is_playing, progress_ms, device_name, device_type, context_uri, updated_at)
|
||||
values (1, ${trackId}, ${!!(player?.isPlaying && trackId)}, ${player?.progressMs ?? null},
|
||||
${player?.deviceName ?? null}, ${player?.deviceType ?? null}, ${player?.contextUri ?? null}, now())
|
||||
on conflict (id) do update set
|
||||
track_id = coalesce(excluded.track_id, music.now_playing.track_id),
|
||||
is_playing = excluded.is_playing, progress_ms = excluded.progress_ms,
|
||||
device_name = excluded.device_name, device_type = excluded.device_type,
|
||||
context_uri = excluded.context_uri, updated_at = now()`;
|
||||
|
||||
const cursor: number | null = auth.recent_cursor === null ? null : Number(auth.recent_cursor);
|
||||
const recent = parseRecentlyPlayed(
|
||||
await spotifyGet(
|
||||
fetchFn,
|
||||
access!,
|
||||
`me/player/recently-played?limit=50${cursor ? `&after=${cursor}` : ''}`
|
||||
)
|
||||
);
|
||||
let newPlays = 0;
|
||||
let newest = cursor ?? 0;
|
||||
for (const play of recent) {
|
||||
const at = play.playedAt.getTime();
|
||||
if (cursor && at <= cursor) continue;
|
||||
const id = await upsertTrack(sql, play.track, true, cache);
|
||||
if (
|
||||
await insertPlay(sql, {
|
||||
trackId: id,
|
||||
playedAt: play.playedAt,
|
||||
source: 'spotify',
|
||||
origin: 'api',
|
||||
contextType: play.contextType,
|
||||
contextUri: play.contextUri
|
||||
})
|
||||
)
|
||||
newPlays++;
|
||||
newest = Math.max(newest, at);
|
||||
}
|
||||
if (newest && newest !== cursor)
|
||||
await sql`update music.spotify_auth set recent_cursor = ${newest} where id = 1`;
|
||||
result = { status: 'ok', playing: !!player?.isPlaying, newPlays };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
result = { status: 'error', error };
|
||||
if (e instanceof SpotifyError && e.code === 'invalid_grant')
|
||||
// The refresh token was revoked: stop polling until /api/spotify/connect is used again.
|
||||
await sql`update music.spotify_auth set needs_reauth = true where id = 1`;
|
||||
else if (e instanceof SpotifyError && e.status === 401)
|
||||
await sql`update music.spotify_auth set access_token = null where id = 1`;
|
||||
} finally {
|
||||
await sql`update music.spotify_auth set lease_until = null, last_poll_at = now(),
|
||||
last_error = ${error} where id = 1`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
55
src/lib/music/history.ts
Normal file
55
src/lib/music/history.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Pure parser for Spotify data exports, used by scripts/music-import-history.ts:
|
||||
// - Extended Streaming History (Streaming_History_Audio_*.json, older endsong_*.json):
|
||||
// { ts, ms_played, master_metadata_track_name, master_metadata_album_artist_name,
|
||||
// master_metadata_album_album_name, spotify_track_uri, platform, ... }
|
||||
// - Account data (StreamingHistory_music_*.json / StreamingHistory*.json):
|
||||
// { endTime: 'YYYY-MM-DD HH:mm' (UTC), artistName, trackName, msPlayed }
|
||||
// Podcast episodes, audiobooks and plays shorter than minMs are skipped (Spotify's own
|
||||
// recently-played list only counts plays of 30 s or more, so that is the default).
|
||||
import type { ParsedTrack } from './spotify-parse';
|
||||
|
||||
export type HistoryPlay = {
|
||||
playedAt: Date;
|
||||
msPlayed: number;
|
||||
platform: string | null;
|
||||
track: ParsedTrack;
|
||||
};
|
||||
|
||||
export const DEFAULT_MIN_MS = 30_000;
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
const str = (value: unknown) => (typeof value === 'string' && value.trim() ? value.trim() : null);
|
||||
|
||||
export function parseHistoryEntry(value: unknown, minMs = DEFAULT_MIN_MS): HistoryPlay | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const e = value as Rec;
|
||||
const extended = 'ts' in e;
|
||||
const name = str(extended ? e.master_metadata_track_name : e.trackName);
|
||||
const artist = str(extended ? e.master_metadata_album_artist_name : e.artistName);
|
||||
const msPlayed = Number(extended ? e.ms_played : e.msPlayed);
|
||||
if (!name || !artist || !Number.isFinite(msPlayed) || msPlayed < minMs) return null;
|
||||
const rawTime = str(extended ? e.ts : e.endTime);
|
||||
if (!rawTime) return null;
|
||||
const playedAt = new Date(
|
||||
extended ? rawTime : `${rawTime.replace(' ', 'T')}${rawTime.length <= 16 ? ':00' : ''}Z`
|
||||
);
|
||||
if (Number.isNaN(playedAt.getTime())) return null;
|
||||
const uri = str(e.spotify_track_uri);
|
||||
const id = uri?.match(/^spotify:track:([A-Za-z0-9]{22})$/)?.[1] ?? null;
|
||||
const album = extended ? str(e.master_metadata_album_album_name) : null;
|
||||
return {
|
||||
playedAt,
|
||||
msPlayed: Math.round(msPlayed),
|
||||
platform: extended ? str(e.platform) : null,
|
||||
track: {
|
||||
spotifyId: id,
|
||||
name,
|
||||
durationMs: null,
|
||||
explicit: null,
|
||||
artists: [{ spotifyId: null, name: artist }],
|
||||
album: album
|
||||
? { spotifyId: null, name: album, imageUrl: null, imageUrlSmall: null, releaseDate: null }
|
||||
: null
|
||||
}
|
||||
};
|
||||
}
|
||||
66
src/lib/music/lastfm-parse.ts
Normal file
66
src/lib/music/lastfm-parse.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// Pure parser for Last.fm user.getRecentTracks (format=json, extended=0).
|
||||
import type { ParsedTrack } from './spotify-parse';
|
||||
|
||||
export type LastfmPlay = { playedAt: Date; uts: number; track: ParsedTrack };
|
||||
export type LastfmPage = { plays: LastfmPlay[]; page: number; totalPages: number; total: number };
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
const rec = (value: unknown): Rec =>
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? (value as Rec) : {};
|
||||
const text = (value: unknown) => {
|
||||
const raw = typeof value === 'string' ? value : (rec(value)['#text'] ?? rec(value).name);
|
||||
return typeof raw === 'string' && raw.trim() ? raw.trim() : null;
|
||||
};
|
||||
|
||||
export function parseRecentTracks(value: unknown): LastfmPage {
|
||||
const body = rec(rec(value).recenttracks);
|
||||
const attr = rec(body['@attr']);
|
||||
const raw = body.track;
|
||||
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||
const plays: LastfmPlay[] = [];
|
||||
for (const entry of list) {
|
||||
const t = rec(entry);
|
||||
if (rec(t['@attr']).nowplaying === 'true') continue; // not scrobbled yet
|
||||
const uts = Number(rec(t.date).uts);
|
||||
const name = text(t.name);
|
||||
const artist = text(t.artist);
|
||||
if (!name || !artist || !Number.isFinite(uts) || uts <= 0) continue;
|
||||
const album = text(t.album);
|
||||
const images = (Array.isArray(t.image) ? t.image : []).map(rec);
|
||||
const pick = (size: string) => {
|
||||
const url = images.find((i) => i.size === size)?.['#text'];
|
||||
// Last.fm's grey star placeholder is not a cover.
|
||||
return typeof url === 'string' &&
|
||||
url.startsWith('https://') &&
|
||||
!url.includes('2a96cbd8b46e442fc41c2b86b821562f')
|
||||
? url
|
||||
: null;
|
||||
};
|
||||
plays.push({
|
||||
uts,
|
||||
playedAt: new Date(uts * 1000),
|
||||
track: {
|
||||
spotifyId: null,
|
||||
name,
|
||||
durationMs: null,
|
||||
explicit: null,
|
||||
artists: [{ spotifyId: null, name: artist }],
|
||||
album: album
|
||||
? {
|
||||
spotifyId: null,
|
||||
name: album,
|
||||
imageUrl: pick('extralarge') ?? pick('large'),
|
||||
imageUrlSmall: pick('large') ?? pick('medium'),
|
||||
releaseDate: null
|
||||
}
|
||||
: null
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
plays,
|
||||
page: Number(attr.page) || 1,
|
||||
totalPages: Number(attr.totalPages) || 0,
|
||||
total: Number(attr.total) || 0
|
||||
};
|
||||
}
|
||||
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'));
|
||||
});
|
||||
});
|
||||
28
src/lib/music/normalize.ts
Normal file
28
src/lib/music/normalize.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Cross-source matching key: the same play reported by Spotify (live API or the streaming
|
||||
// history export) and Last.fm often differs in spelling ("Song - 2011 Remaster",
|
||||
// "Song (feat. X)", "&" vs "and", accents). Both sides reduce to "artist|title".
|
||||
|
||||
const VERSION_WORDS = /\b(remaster(ed)?|version|edit|mono|stereo|mix|live|deluxe|bonus|single)\b/;
|
||||
|
||||
function clean(value: string) {
|
||||
let s = value
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
// "(feat. X)", "[with Y]", trailing " feat. Z"
|
||||
s = s.replace(/\s*[([](feat\.?|ft\.?|featuring|with)\s[^)\]]*[)\]]/g, '');
|
||||
s = s.replace(/\s+(feat\.?|ft\.?|featuring)\s.*$/, '');
|
||||
// "(2011 Remaster)", "[Live]" ... and " - 2011 Remaster", " - Radio Edit"
|
||||
s = s.replace(/\s*[([]([^)\]]*)[)\]]/g, (whole, inner: string) =>
|
||||
VERSION_WORDS.test(inner) ? '' : whole
|
||||
);
|
||||
s = s.replace(/\s+-\s+(.*)$/, (whole, tail: string) => (VERSION_WORDS.test(tail) ? '' : whole));
|
||||
return s
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function matchKey(artist: string, title: string) {
|
||||
return `${clean(artist)}|${clean(title)}`;
|
||||
}
|
||||
122
src/lib/music/spotify-parse.ts
Normal file
122
src/lib/music/spotify-parse.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// Pure parsers for Spotify Web API responses (no I/O, no SvelteKit imports), shared by the
|
||||
// in-app poller, scripts/music-poll.ts and the tests. Spotify trims fields over time (see the
|
||||
// February 2026 dev-mode changes), so everything is optional and checked.
|
||||
|
||||
export type ParsedArtist = { spotifyId: string | null; name: string };
|
||||
export type ParsedAlbum = {
|
||||
spotifyId: string | null;
|
||||
name: string;
|
||||
imageUrl: string | null;
|
||||
imageUrlSmall: string | null;
|
||||
releaseDate: string | null;
|
||||
};
|
||||
export type ParsedTrack = {
|
||||
spotifyId: string | null;
|
||||
name: string;
|
||||
durationMs: number | null;
|
||||
explicit: boolean | null;
|
||||
artists: ParsedArtist[];
|
||||
album: ParsedAlbum | null;
|
||||
};
|
||||
export type ParsedRecentPlay = {
|
||||
track: ParsedTrack;
|
||||
playedAt: Date;
|
||||
contextType: string | null;
|
||||
contextUri: string | null;
|
||||
};
|
||||
export type ParsedPlayer = {
|
||||
isPlaying: boolean;
|
||||
progressMs: number | null;
|
||||
deviceName: string | null;
|
||||
deviceType: string | null;
|
||||
contextUri: string | null;
|
||||
/** Null for podcasts, ads and unknown items: only music tracks are stored. */
|
||||
track: ParsedTrack | null;
|
||||
};
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
const rec = (value: unknown): Rec =>
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? (value as Rec) : {};
|
||||
const str = (value: unknown) => (typeof value === 'string' && value.trim() ? value.trim() : null);
|
||||
const int = (value: unknown) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null;
|
||||
const SPOTIFY_ID = /^[A-Za-z0-9]{22}$/;
|
||||
export const spotifyId = (value: unknown) => {
|
||||
const id = str(value);
|
||||
return id && SPOTIFY_ID.test(id) ? id : null;
|
||||
};
|
||||
|
||||
/** Spotify lists images largest first; keep the largest and the smallest of at least 64 px. */
|
||||
export function pickImages(images: unknown): { large: string | null; small: string | null } {
|
||||
const list = (Array.isArray(images) ? images : [])
|
||||
.map(rec)
|
||||
.filter((i) => typeof i.url === 'string' && (i.url as string).startsWith('https://'))
|
||||
.map((i) => ({ url: i.url as string, width: int(i.width) ?? 0 }))
|
||||
.sort((a, b) => b.width - a.width);
|
||||
if (!list.length) return { large: null, small: null };
|
||||
const small = [...list].reverse().find((i) => i.width >= 160) ?? list[list.length - 1];
|
||||
return { large: list[0].url, small: small.url };
|
||||
}
|
||||
|
||||
export function parseTrack(value: unknown): ParsedTrack | null {
|
||||
const track = rec(value);
|
||||
if (track.type !== undefined && track.type !== 'track') return null;
|
||||
if (track.is_local === true) return null;
|
||||
const name = str(track.name);
|
||||
if (!name) return null;
|
||||
const artists = (Array.isArray(track.artists) ? track.artists : [])
|
||||
.map(rec)
|
||||
.map((a) => ({ spotifyId: spotifyId(a.id), name: str(a.name) }))
|
||||
.filter((a): a is ParsedArtist => !!a.name);
|
||||
if (!artists.length) return null;
|
||||
const album = rec(track.album);
|
||||
const albumName = str(album.name);
|
||||
const images = pickImages(album.images);
|
||||
return {
|
||||
spotifyId: spotifyId(track.id),
|
||||
name,
|
||||
durationMs: int(track.duration_ms),
|
||||
explicit: typeof track.explicit === 'boolean' ? track.explicit : null,
|
||||
artists,
|
||||
album: albumName
|
||||
? {
|
||||
spotifyId: spotifyId(album.id),
|
||||
name: albumName,
|
||||
imageUrl: images.large,
|
||||
imageUrlSmall: images.small,
|
||||
releaseDate: str(album.release_date)
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /me/player/recently-played → plays, oldest first. */
|
||||
export function parseRecentlyPlayed(value: unknown): ParsedRecentPlay[] {
|
||||
const items = rec(value).items;
|
||||
const plays: ParsedRecentPlay[] = [];
|
||||
for (const entry of Array.isArray(items) ? items : []) {
|
||||
const item = rec(entry);
|
||||
const track = parseTrack(item.track);
|
||||
const playedAt = str(item.played_at) ? new Date(item.played_at as string) : null;
|
||||
if (!track || !playedAt || Number.isNaN(playedAt.getTime())) continue;
|
||||
const context = rec(item.context);
|
||||
plays.push({ track, playedAt, contextType: str(context.type), contextUri: str(context.uri) });
|
||||
}
|
||||
return plays.sort((a, b) => a.playedAt.getTime() - b.playedAt.getTime());
|
||||
}
|
||||
|
||||
/** GET /me/player (null body = 204, nothing active). */
|
||||
export function parsePlayer(value: unknown): ParsedPlayer | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const player = rec(value);
|
||||
const device = rec(player.device);
|
||||
const type = str(player.currently_playing_type);
|
||||
return {
|
||||
isPlaying: player.is_playing === true,
|
||||
progressMs: int(player.progress_ms),
|
||||
deviceName: str(device.name),
|
||||
deviceType: str(device.type),
|
||||
contextUri: str(rec(player.context).uri),
|
||||
track: type === null || type === 'track' ? parseTrack(player.item) : null
|
||||
};
|
||||
}
|
||||
224
src/lib/music/store.server.ts
Normal file
224
src/lib/music/store.server.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
// Writes to the music schema with postgres.js. No SvelteKit imports (relative imports only),
|
||||
// so the app, scripts/music-*.ts and the image's `bun scripts/...` runs all share it.
|
||||
import type { Sql } from 'postgres';
|
||||
import { matchKey } from './normalize';
|
||||
import type { ParsedAlbum, ParsedArtist, ParsedTrack } from './spotify-parse';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type Db = Sql<any>;
|
||||
export type PlaySource = 'spotify' | 'lastfm' | 'youtube' | 'local';
|
||||
export type PlayOrigin = 'api' | 'export' | 'import' | 'manual';
|
||||
|
||||
/** Per-run cache so a poll or import does not look up the same artist/album/track twice. */
|
||||
export type StoreCache = Map<string, number>;
|
||||
export const newCache = (): StoreCache => new Map();
|
||||
|
||||
export async function upsertArtist(sql: Db, artist: ParsedArtist, cache = newCache()) {
|
||||
const cacheKey = `artist:${artist.spotifyId ?? `name:${artist.name.toLowerCase()}`}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
let id: number | undefined;
|
||||
if (artist.spotifyId) {
|
||||
const [found] =
|
||||
await sql`select id, name from music.artists where spotify_id = ${artist.spotifyId}`;
|
||||
if (found) {
|
||||
id = found.id;
|
||||
if (found.name !== artist.name)
|
||||
await sql`update music.artists set name = ${artist.name} where id = ${found.id}`;
|
||||
} else {
|
||||
// Adopt a row created by an import (name only) so its plays merge with live ones.
|
||||
const [adopted] = await sql`
|
||||
update music.artists set spotify_id = ${artist.spotifyId}, name = ${artist.name}
|
||||
where id = (select id from music.artists where spotify_id is null
|
||||
and lower(name) = lower(${artist.name}) order by id limit 1)
|
||||
returning id`;
|
||||
id =
|
||||
adopted?.id ??
|
||||
(
|
||||
await sql`
|
||||
insert into music.artists (spotify_id, name) values (${artist.spotifyId}, ${artist.name})
|
||||
on conflict (spotify_id) where spotify_id is not null
|
||||
do update set name = excluded.name returning id`
|
||||
)[0].id;
|
||||
}
|
||||
} else {
|
||||
const [found] = await sql`
|
||||
select id from music.artists where lower(name) = lower(${artist.name})
|
||||
order by (spotify_id is null), id limit 1`;
|
||||
id =
|
||||
found?.id ??
|
||||
(await sql`insert into music.artists (name) values (${artist.name}) returning id`)[0].id;
|
||||
}
|
||||
cache.set(cacheKey, id!);
|
||||
return id!;
|
||||
}
|
||||
|
||||
export async function upsertAlbum(
|
||||
sql: Db,
|
||||
album: ParsedAlbum,
|
||||
artistId: number | null,
|
||||
cache = newCache()
|
||||
) {
|
||||
const cacheKey = `album:${album.spotifyId ?? `name:${artistId}:${album.name.toLowerCase()}`}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
let row: { id: number; image_url: string | null; image_url_small: string | null } | undefined;
|
||||
if (album.spotifyId) {
|
||||
[row] =
|
||||
await sql`select id, image_url, image_url_small from music.albums where spotify_id = ${album.spotifyId}`;
|
||||
if (!row)
|
||||
[row] = await sql`
|
||||
update music.albums set spotify_id = ${album.spotifyId}, name = ${album.name}
|
||||
where id = (select id from music.albums where spotify_id is null
|
||||
and lower(name) = lower(${album.name})
|
||||
and (artist_id is not distinct from ${artistId} or artist_id is null)
|
||||
order by id limit 1)
|
||||
returning id, image_url, image_url_small`;
|
||||
if (!row)
|
||||
[row] = await sql`
|
||||
insert into music.albums (spotify_id, name, artist_id, image_url, image_url_small, release_date)
|
||||
values (${album.spotifyId}, ${album.name}, ${artistId}, ${album.imageUrl},
|
||||
${album.imageUrlSmall}, ${album.releaseDate})
|
||||
on conflict (spotify_id) where spotify_id is not null
|
||||
do update set name = excluded.name returning id, image_url, image_url_small`;
|
||||
} else {
|
||||
[row] = await sql`
|
||||
select id, image_url, image_url_small from music.albums
|
||||
where lower(name) = lower(${album.name}) and artist_id is not distinct from ${artistId}
|
||||
order by (spotify_id is null), id limit 1`;
|
||||
if (!row)
|
||||
[row] = await sql`
|
||||
insert into music.albums (name, artist_id, image_url, image_url_small)
|
||||
values (${album.name}, ${artistId}, ${album.imageUrl}, ${album.imageUrlSmall})
|
||||
returning id, image_url, image_url_small`;
|
||||
}
|
||||
// Spotify covers win; an import only fills a missing cover.
|
||||
const better = album.spotifyId
|
||||
? album.imageUrl && album.imageUrl !== row!.image_url
|
||||
: album.imageUrl && !row!.image_url;
|
||||
if (better)
|
||||
await sql`update music.albums set image_url = ${album.imageUrl},
|
||||
image_url_small = ${album.imageUrlSmall ?? album.imageUrl},
|
||||
release_date = coalesce(${album.releaseDate}, release_date) where id = ${row!.id}`;
|
||||
cache.set(cacheKey, row!.id);
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create a track. `full` = complete Spotify API data (all artists, album, duration):
|
||||
* it may correct an existing row; partial import data never overwrites anything.
|
||||
*/
|
||||
export async function upsertTrack(sql: Db, track: ParsedTrack, full: boolean, cache = newCache()) {
|
||||
const artistIds: number[] = [];
|
||||
for (const artist of track.artists) artistIds.push(await upsertArtist(sql, artist, cache));
|
||||
const artistId = artistIds[0];
|
||||
const cacheKey = `track:${track.spotifyId ?? `name:${artistId}:${track.name.toLowerCase()}`}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const albumId = track.album ? await upsertAlbum(sql, track.album, artistId, cache) : null;
|
||||
const key = matchKey(track.artists[0].name, track.name);
|
||||
let id: number | undefined;
|
||||
if (track.spotifyId) {
|
||||
const [found] = await sql`
|
||||
select id, album_id, artist_id, duration_ms from music.tracks where spotify_id = ${track.spotifyId}`;
|
||||
if (found) {
|
||||
id = found.id;
|
||||
if (
|
||||
full &&
|
||||
(found.album_id !== albumId ||
|
||||
found.artist_id !== artistId ||
|
||||
found.duration_ms !== track.durationMs)
|
||||
)
|
||||
await sql`update music.tracks set name = ${track.name}, album_id = ${albumId},
|
||||
artist_id = ${artistId}, duration_ms = ${track.durationMs},
|
||||
explicit = ${track.explicit}, match_key = ${key} where id = ${id}`;
|
||||
else if (!found.album_id && albumId)
|
||||
await sql`update music.tracks set album_id = ${albumId} where id = ${id}`;
|
||||
} else {
|
||||
const [adopted] = await sql`
|
||||
update music.tracks set spotify_id = ${track.spotifyId},
|
||||
album_id = coalesce(${albumId}, album_id),
|
||||
duration_ms = coalesce(${track.durationMs}, duration_ms),
|
||||
explicit = coalesce(${track.explicit}, explicit)
|
||||
where id = (select id from music.tracks where spotify_id is null
|
||||
and lower(name) = lower(${track.name}) and artist_id = ${artistId}
|
||||
order by id limit 1)
|
||||
returning id`;
|
||||
id =
|
||||
adopted?.id ??
|
||||
(
|
||||
await sql`
|
||||
insert into music.tracks (spotify_id, name, album_id, artist_id, duration_ms, explicit, match_key)
|
||||
values (${track.spotifyId}, ${track.name}, ${albumId}, ${artistId},
|
||||
${track.durationMs}, ${track.explicit}, ${key})
|
||||
on conflict (spotify_id) where spotify_id is not null
|
||||
do update set name = excluded.name returning id`
|
||||
)[0].id;
|
||||
}
|
||||
} else {
|
||||
const [found] = await sql`
|
||||
select id, album_id from music.tracks
|
||||
where lower(name) = lower(${track.name}) and artist_id = ${artistId}
|
||||
order by (spotify_id is null), id limit 1`;
|
||||
if (found) {
|
||||
id = found.id;
|
||||
if (!found.album_id && albumId)
|
||||
await sql`update music.tracks set album_id = ${albumId} where id = ${id}`;
|
||||
} else
|
||||
id = (
|
||||
await sql`
|
||||
insert into music.tracks (name, album_id, artist_id, duration_ms, match_key)
|
||||
values (${track.name}, ${albumId}, ${artistId}, ${track.durationMs}, ${key})
|
||||
returning id`
|
||||
)[0].id;
|
||||
}
|
||||
for (const [position, aid] of artistIds.entries())
|
||||
await sql`insert into music.track_artists (track_id, artist_id, position)
|
||||
values (${id!}, ${aid}, ${position}) on conflict do nothing`;
|
||||
cache.set(cacheKey, id!);
|
||||
return id!;
|
||||
}
|
||||
|
||||
export type PlayInput = {
|
||||
trackId: number;
|
||||
playedAt: Date;
|
||||
source: PlaySource;
|
||||
origin: PlayOrigin;
|
||||
msPlayed?: number | null;
|
||||
contextType?: string | null;
|
||||
contextUri?: string | null;
|
||||
platform?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Insert a play unless the same source already has it: same normalised artist+title within
|
||||
* a minute (the live API and the Spotify export report the same end time to within seconds;
|
||||
* Last.fm re-imports have the exact same timestamp). Cross-source overlap (Last.fm vs
|
||||
* Spotify) is resolved at read time by the music.plays_deduped view. Returns true if stored.
|
||||
*/
|
||||
export async function insertPlay(sql: Db, play: PlayInput) {
|
||||
const at = play.playedAt.toISOString();
|
||||
const rows = await sql`
|
||||
insert into music.plays (track_id, played_at, source, origin, ms_played, context_type, context_uri, platform)
|
||||
select ${play.trackId}, ${at}::timestamptz, ${play.source}, ${play.origin}, ${play.msPlayed ?? null},
|
||||
${play.contextType ?? null}, ${play.contextUri ?? null}, ${play.platform ?? null}
|
||||
where not exists (
|
||||
select 1 from music.plays p join music.tracks t on t.id = p.track_id
|
||||
where p.source = ${play.source}
|
||||
and p.played_at between ${at}::timestamptz - interval '60 seconds'
|
||||
and ${at}::timestamptz + interval '60 seconds'
|
||||
and t.match_key = (select match_key from music.tracks where id = ${play.trackId}))
|
||||
on conflict do nothing
|
||||
returning id`;
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export async function getImportState<T>(sql: Db, key: string): Promise<T | null> {
|
||||
const [row] = await sql`select data from music.import_state where key = ${key}`;
|
||||
return (row?.data as T) ?? null;
|
||||
}
|
||||
|
||||
export async function setImportState(sql: Db, key: string, data: unknown) {
|
||||
await sql`insert into music.import_state (key, data) values (${key}, ${sql.json(data as never)})
|
||||
on conflict (key) do update set data = excluded.data, updated_at = now()`;
|
||||
}
|
||||
@@ -65,6 +65,13 @@ const allTrackers = [
|
||||
external: false,
|
||||
private: false
|
||||
},
|
||||
{
|
||||
name: 'music',
|
||||
subtitle: 'everything I listen to: top artists, tracks, albums and streaks',
|
||||
href: '/trackers/music',
|
||||
external: false,
|
||||
private: false
|
||||
},
|
||||
{
|
||||
name: 'health',
|
||||
subtitle: 'private: body, workouts, sleep, meds and vaccinations',
|
||||
|
||||
@@ -3,7 +3,8 @@ import { relations } from './relations';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const client = postgres(DB_URL, { prepare: false });
|
||||
/** Raw postgres.js client (same pool), for hand-written SQL such as the music stats. */
|
||||
export const client = postgres(DB_URL, { prepare: false });
|
||||
|
||||
export const db = drizzle({
|
||||
relations,
|
||||
|
||||
@@ -18,3 +18,4 @@ export * from './tasks';
|
||||
export * from './groceries';
|
||||
export * from './home';
|
||||
export * from './cars';
|
||||
export * from './music';
|
||||
|
||||
180
src/lib/server/db/schema/music.ts
Normal file
180
src/lib/server/db/schema/music.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
primaryKey,
|
||||
smallint,
|
||||
text,
|
||||
uniqueIndex
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { musicSchema } from './schemas';
|
||||
import { id, timestamptz } from './helpers';
|
||||
|
||||
// Listening history (a self-hosted Last.fm): every play from Spotify (polled every minute,
|
||||
// see src/lib/server/music/collector.ts) and later YouTube / local files, plus the
|
||||
// Extended Streaming History import (scripts/music-import-history.ts). Public read
|
||||
// (personal_ro gets SELECT) except music.spotify_auth, which holds the refresh token.
|
||||
// Rows without a spotify_id (imports, YouTube, local files) are matched by name.
|
||||
|
||||
export const musicArtists = musicSchema.table(
|
||||
'artists',
|
||||
{
|
||||
id: id(),
|
||||
spotifyId: text('spotify_id'),
|
||||
name: text('name').notNull(),
|
||||
imageUrl: text('image_url'),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('artists_spotify_id_key')
|
||||
.on(t.spotifyId)
|
||||
.where(sql`${t.spotifyId} is not null`),
|
||||
index('artists_name_idx').on(sql`lower(${t.name})`)
|
||||
]
|
||||
);
|
||||
|
||||
export const musicAlbums = musicSchema.table(
|
||||
'albums',
|
||||
{
|
||||
id: id(),
|
||||
spotifyId: text('spotify_id'),
|
||||
name: text('name').notNull(),
|
||||
artistId: integer('artist_id').references(() => musicArtists.id, { onDelete: 'set null' }),
|
||||
/** Largest cover (640 px on Spotify) and a small one (64–300 px) for lists. */
|
||||
imageUrl: text('image_url'),
|
||||
imageUrlSmall: text('image_url_small'),
|
||||
releaseDate: text('release_date'),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('albums_spotify_id_key')
|
||||
.on(t.spotifyId)
|
||||
.where(sql`${t.spotifyId} is not null`),
|
||||
index('albums_name_idx').on(sql`lower(${t.name})`)
|
||||
]
|
||||
);
|
||||
|
||||
export const musicTracks = musicSchema.table(
|
||||
'tracks',
|
||||
{
|
||||
id: id(),
|
||||
spotifyId: text('spotify_id'),
|
||||
name: text('name').notNull(),
|
||||
albumId: integer('album_id').references(() => musicAlbums.id, { onDelete: 'set null' }),
|
||||
/** Primary (first credited) artist, also in track_artists at position 0. */
|
||||
artistId: integer('artist_id').references(() => musicArtists.id, { onDelete: 'set null' }),
|
||||
durationMs: integer('duration_ms'),
|
||||
explicit: boolean('explicit'),
|
||||
/** normalised "primary artist|title" (src/lib/music/normalize.ts), for cross-source dedupe. */
|
||||
matchKey: text('match_key').notNull(),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('tracks_spotify_id_key')
|
||||
.on(t.spotifyId)
|
||||
.where(sql`${t.spotifyId} is not null`),
|
||||
index('tracks_name_idx').on(sql`lower(${t.name})`),
|
||||
index('tracks_match_key_idx').on(t.matchKey)
|
||||
]
|
||||
);
|
||||
|
||||
export const musicTrackArtists = musicSchema.table(
|
||||
'track_artists',
|
||||
{
|
||||
trackId: integer('track_id')
|
||||
.notNull()
|
||||
.references(() => musicTracks.id, { onDelete: 'cascade' }),
|
||||
artistId: integer('artist_id')
|
||||
.notNull()
|
||||
.references(() => musicArtists.id, { onDelete: 'cascade' }),
|
||||
position: smallint('position').notNull().default(0)
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.trackId, t.artistId] }),
|
||||
index('track_artists_artist_idx').on(t.artistId)
|
||||
]
|
||||
);
|
||||
|
||||
export const musicPlays = musicSchema.table(
|
||||
'plays',
|
||||
{
|
||||
id: id(),
|
||||
trackId: integer('track_id')
|
||||
.notNull()
|
||||
.references(() => musicTracks.id, { onDelete: 'cascade' }),
|
||||
/** Spotify: when the play ended (played_at / export ts). Last.fm: the scrobble time,
|
||||
* usually when the track started. See the music.plays_deduped view. */
|
||||
playedAt: timestamptz('played_at').notNull(),
|
||||
source: text('source').notNull().default('spotify'),
|
||||
/** How the row got here: 'api' (poller), 'export' (Spotify streaming history),
|
||||
* 'import' (Last.fm and other backfills), 'manual'. */
|
||||
origin: text('origin').notNull().default('api'),
|
||||
/** Known for imports; null for API plays (Spotify only reports plays of 30 s or more). */
|
||||
msPlayed: integer('ms_played'),
|
||||
contextType: text('context_type'),
|
||||
contextUri: text('context_uri'),
|
||||
platform: text('platform'),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('plays_dedupe_key').on(t.source, t.trackId, t.playedAt),
|
||||
index('plays_played_at_idx').on(t.playedAt),
|
||||
index('plays_source_played_idx').on(t.source, t.playedAt),
|
||||
index('plays_track_played_idx').on(t.trackId, t.playedAt),
|
||||
check('plays_source_check', sql`${t.source} in ('spotify', 'lastfm', 'youtube', 'local')`),
|
||||
check('plays_origin_check', sql`${t.origin} in ('api', 'export', 'import', 'manual')`)
|
||||
]
|
||||
);
|
||||
|
||||
/** One row (id = 1): what is playing right now, refreshed by the poller every minute. */
|
||||
export const musicNowPlaying = musicSchema.table(
|
||||
'now_playing',
|
||||
{
|
||||
id: smallint('id').primaryKey().default(1),
|
||||
trackId: integer('track_id').references(() => musicTracks.id, { onDelete: 'set null' }),
|
||||
isPlaying: boolean('is_playing').notNull().default(false),
|
||||
progressMs: integer('progress_ms'),
|
||||
deviceName: text('device_name'),
|
||||
deviceType: text('device_type'),
|
||||
contextUri: text('context_uri'),
|
||||
updatedAt: timestamptz('updated_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [check('now_playing_single_row', sql`${t.id} = 1`)]
|
||||
);
|
||||
|
||||
/**
|
||||
* One row (id = 1): the owner's Spotify authorization (Authorization Code flow, stored by
|
||||
* /api/spotify/callback) and the poller's state. Never granted to personal_ro.
|
||||
*/
|
||||
export const musicSpotifyAuth = musicSchema.table(
|
||||
'spotify_auth',
|
||||
{
|
||||
id: smallint('id').primaryKey().default(1),
|
||||
spotifyUserId: text('spotify_user_id').notNull(),
|
||||
displayName: text('display_name'),
|
||||
refreshToken: text('refresh_token').notNull(),
|
||||
accessToken: text('access_token'),
|
||||
accessExpiresAt: timestamptz('access_expires_at'),
|
||||
scope: text('scope'),
|
||||
/** recently-played `after` cursor: played_at (ms since epoch) of the newest stored play. */
|
||||
recentCursor: bigint('recent_cursor', { mode: 'number' }),
|
||||
needsReauth: boolean('needs_reauth').notNull().default(false),
|
||||
leaseUntil: timestamptz('lease_until'),
|
||||
lastPollAt: timestamptz('last_poll_at'),
|
||||
lastError: text('last_error'),
|
||||
connectedAt: timestamptz('connected_at').notNull().defaultNow(),
|
||||
updatedAt: timestamptz('updated_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [check('spotify_auth_single_row', sql`${t.id} = 1`)]
|
||||
);
|
||||
|
||||
/** Resumable backfill state, e.g. key 'lastfm:stanrunge' (see scripts/music-import-lastfm.ts). */
|
||||
export const musicImportState = musicSchema.table('import_state', {
|
||||
key: text('key').primaryKey(),
|
||||
data: jsonb('data').notNull(),
|
||||
updatedAt: timestamptz('updated_at').notNull().defaultNow()
|
||||
});
|
||||
@@ -18,3 +18,4 @@ export const tasksSchema = pgSchema('tasks');
|
||||
export const groceriesSchema = pgSchema('groceries');
|
||||
export const homeSchema = pgSchema('home');
|
||||
export const carsSchema = pgSchema('cars');
|
||||
export const musicSchema = pgSchema('music');
|
||||
|
||||
33
src/lib/server/music/oauth.ts
Normal file
33
src/lib/server/music/oauth.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { SPOTIFY_SCOPES } from '#lib/music/collector.server.js';
|
||||
|
||||
export const STATE_COOKIE = 'spotify_oauth_state';
|
||||
export const CALLBACK_PATH = '/api/spotify/callback';
|
||||
|
||||
/**
|
||||
* The redirect URI registered in the Spotify app: https://stanrunge.dev/api/spotify/callback
|
||||
* (prod) or https://dev.stanrunge.dev/api/spotify/callback (dev server). Caddy terminates TLS,
|
||||
* so the request URL may say http; the public hosts are always https.
|
||||
*/
|
||||
export function publicOrigin(url: URL) {
|
||||
if (url.hostname === 'stanrunge.dev' || url.hostname.endsWith('.stanrunge.dev'))
|
||||
return `https://${url.hostname}`;
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
export function authorizeUrl(clientId: string, redirectUri: string, state: string) {
|
||||
return `https://accounts.spotify.com/authorize?${new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
scope: SPOTIFY_SCOPES.join(' '),
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
show_dialog: 'true'
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function safeEqual(a: string, b: string) {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
43
src/lib/server/music/poller.ts
Normal file
43
src/lib/server/music/poller.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET } from '$app/env/private';
|
||||
import { client } from '#lib/server/db/index.js';
|
||||
import { pollSpotify } from '#lib/music/collector.server.js';
|
||||
|
||||
// The Spotify poller runs inside the app (started from hooks.server.ts `init`): the app
|
||||
// already has the database role (personal_app) and the Spotify client credentials, so
|
||||
// no extra role, secret file or host timer is needed. It does nothing until the owner has
|
||||
// connected Spotify at /api/spotify/connect. MUSIC_POLLER=off disables it (e.g. when the
|
||||
// optional host timer, deploy/music-collector, is used instead).
|
||||
const INTERVAL_MS = 60_000;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let running = false;
|
||||
let lastLogged = '';
|
||||
|
||||
export const spotifyCredentials = () => ({
|
||||
clientId: SPOTIFY_CLIENT_ID,
|
||||
clientSecret: SPOTIFY_CLIENT_SECRET
|
||||
});
|
||||
|
||||
export async function pollNow() {
|
||||
if (running || !SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET) return;
|
||||
running = true;
|
||||
try {
|
||||
const result = await pollSpotify(client, spotifyCredentials());
|
||||
const message = result.status === 'error' ? result.error : '';
|
||||
if (message && message !== lastLogged) console.error(`[music] poll failed: ${message}`);
|
||||
lastLogged = message;
|
||||
} catch (e) {
|
||||
// e.g. the music schema is not migrated on this database yet; log once, keep trying.
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
if (message !== lastLogged) console.error(`[music] poll failed: ${message}`);
|
||||
lastLogged = message;
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startMusicPoller() {
|
||||
if (timer || process.env.MUSIC_POLLER === 'off') return;
|
||||
if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET) return;
|
||||
timer = setInterval(pollNow, INTERVAL_MS);
|
||||
setTimeout(pollNow, 5_000);
|
||||
}
|
||||
212
src/lib/server/music/stats.ts
Normal file
212
src/lib/server/music/stats.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { client as sql } from '#lib/server/db/index.js';
|
||||
|
||||
// Read side of the listening tracker. Everything reads music.plays_deduped, which hides a
|
||||
// Last.fm scrobble when Spotify (live API or export) has the same play, so nothing is
|
||||
// counted twice. Listening time uses ms_played when known (imports) and otherwise the
|
||||
// track length (the API only reports plays of 30 s or more, not how long).
|
||||
const TZ = 'Europe/Amsterdam';
|
||||
// Fragments are built per query (postgres.js queries are single-use objects).
|
||||
const playMs = () => sql`coalesce(p.ms_played, t.duration_ms, 210000)`;
|
||||
|
||||
export const RANGES = { '7d': 7, '30d': 30, all: null } as const;
|
||||
export type Range = keyof typeof RANGES;
|
||||
export const parseRange = (value: string | null): Range =>
|
||||
value && value in RANGES ? (value as Range) : '30d';
|
||||
|
||||
export type Listening = {
|
||||
nowPlaying: boolean;
|
||||
at: string;
|
||||
track: string;
|
||||
artists: string;
|
||||
album: string | null;
|
||||
image: string | null;
|
||||
url: string | null;
|
||||
device: string | null;
|
||||
};
|
||||
|
||||
const artistsOf = () => sql`coalesce((select string_agg(ar.name, ', ' order by ta.position)
|
||||
from music.track_artists ta join music.artists ar on ar.id = ta.artist_id
|
||||
where ta.track_id = t.id), a.name, '')`;
|
||||
|
||||
const toListening = (row: Record<string, unknown>, nowPlaying: boolean): Listening => ({
|
||||
nowPlaying,
|
||||
at: new Date(row.at as string).toISOString(),
|
||||
track: row.track as string,
|
||||
artists: row.artists as string,
|
||||
album: (row.album as string) ?? null,
|
||||
image: (row.image as string) ?? null,
|
||||
url: row.spotify_id ? `https://open.spotify.com/track/${row.spotify_id}` : null,
|
||||
device: (row.device as string) ?? null
|
||||
});
|
||||
|
||||
/** Homepage card: what is playing now (fresh within 3 min), else the last play. Never throws. */
|
||||
export async function getListening(): Promise<Listening | null> {
|
||||
try {
|
||||
const [now] = await sql`
|
||||
select np.updated_at as at, t.name as track, t.spotify_id, ${artistsOf()} as artists,
|
||||
al.name as album, coalesce(al.image_url_small, al.image_url) as image, np.device_name as device
|
||||
from music.now_playing np
|
||||
join music.tracks t on t.id = np.track_id
|
||||
left join music.artists a on a.id = t.artist_id
|
||||
left join music.albums al on al.id = t.album_id
|
||||
where np.id = 1 and np.is_playing and np.updated_at > now() - interval '3 minutes'`;
|
||||
if (now) return toListening(now, true);
|
||||
const [last] = await sql`
|
||||
select p.played_at as at, t.name as track, t.spotify_id, ${artistsOf()} as artists,
|
||||
al.name as album, coalesce(al.image_url_small, al.image_url) as image, null as device
|
||||
from music.plays p
|
||||
join music.tracks t on t.id = p.track_id
|
||||
left join music.artists a on a.id = t.artist_id
|
||||
left join music.albums al on al.id = t.album_id
|
||||
order by p.played_at desc limit 1`;
|
||||
return last ? toListening(last, false) : null;
|
||||
} catch {
|
||||
return null; // e.g. not migrated on this database: the homepage must still render
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMusicStats(range: Range) {
|
||||
const days = RANGES[range];
|
||||
const since = (days ? new Date(Date.now() - days * 86_400_000) : new Date(0)).toISOString();
|
||||
const chartDays = days ?? 90;
|
||||
const [totals, artists, tracks, albums, daily, streak, recent, sources] = await Promise.all([
|
||||
sql`
|
||||
select count(*)::int as plays,
|
||||
coalesce(sum(${playMs()}), 0)::float8 / 3600000 as hours,
|
||||
count(distinct p.track_id)::int as tracks,
|
||||
count(distinct t.artist_id)::int as artists,
|
||||
min(p.played_at) as first
|
||||
from music.plays_deduped p join music.tracks t on t.id = p.track_id
|
||||
where p.played_at >= ${since}::timestamptz`,
|
||||
sql`
|
||||
select a.id, a.name, a.spotify_id, count(*)::int as plays,
|
||||
coalesce(sum(${playMs()}), 0)::float8 / 3600000 as hours,
|
||||
coalesce(a.image_url, (select coalesce(al.image_url_small, al.image_url) from music.albums al
|
||||
where al.artist_id = a.id and al.image_url is not null
|
||||
order by al.spotify_id is null, al.id desc limit 1)) as image
|
||||
from music.plays_deduped p
|
||||
join music.tracks t on t.id = p.track_id
|
||||
join music.artists a on a.id = t.artist_id
|
||||
where p.played_at >= ${since}::timestamptz
|
||||
group by a.id order by plays desc, a.name limit 10`,
|
||||
sql`
|
||||
select t.id, t.name, t.spotify_id, ${artistsOf()} as artists, count(*)::int as plays,
|
||||
coalesce(al.image_url_small, al.image_url) as image
|
||||
from music.plays_deduped p
|
||||
join music.tracks t on t.id = p.track_id
|
||||
left join music.artists a on a.id = t.artist_id
|
||||
left join music.albums al on al.id = t.album_id
|
||||
where p.played_at >= ${since}::timestamptz
|
||||
group by t.id, a.name, al.id order by plays desc, t.name limit 10`,
|
||||
sql`
|
||||
select al.id, al.name, al.spotify_id, a.name as artist, count(*)::int as plays,
|
||||
coalesce(al.image_url_small, al.image_url) as image
|
||||
from music.plays_deduped p
|
||||
join music.tracks t on t.id = p.track_id
|
||||
join music.albums al on al.id = t.album_id
|
||||
left join music.artists a on a.id = al.artist_id
|
||||
where p.played_at >= ${since}::timestamptz
|
||||
group by al.id, a.name order by plays desc, al.name limit 10`,
|
||||
sql`
|
||||
with days as (
|
||||
select generate_series((now() at time zone ${TZ})::date - ${chartDays - 1}::int,
|
||||
(now() at time zone ${TZ})::date, interval '1 day')::date as day)
|
||||
select d.day::text as day, count(p.id)::int as plays,
|
||||
coalesce(sum(${playMs()}), 0)::float8 / 3600000 as hours
|
||||
from days d
|
||||
left join music.plays_deduped p
|
||||
on p.played_at >= now() - make_interval(days => ${chartDays + 1})
|
||||
and (p.played_at at time zone ${TZ})::date = d.day
|
||||
left join music.tracks t on t.id = p.track_id
|
||||
group by d.day order by d.day`,
|
||||
sql`
|
||||
with d as (select distinct (played_at at time zone ${TZ})::date as day from music.plays_deduped),
|
||||
g as (select day, day - (row_number() over (order by day))::int as grp from d),
|
||||
s as (select min(day) as start_day, max(day) as end_day, count(*)::int as len from g group by grp)
|
||||
select
|
||||
coalesce((select len from s where end_day >= (now() at time zone ${TZ})::date - 1
|
||||
order by end_day desc limit 1), 0) as current,
|
||||
coalesce((select len from s order by len desc, end_day desc limit 1), 0) as longest,
|
||||
(select start_day::text from s order by len desc, end_day desc limit 1) as longest_start,
|
||||
(select end_day::text from s order by len desc, end_day desc limit 1) as longest_end,
|
||||
(select count(*)::int from d) as days`,
|
||||
sql`
|
||||
select p.id, p.played_at, p.source, t.name as track, t.spotify_id, ${artistsOf()} as artists,
|
||||
coalesce(al.image_url_small, al.image_url) as image
|
||||
from music.plays_deduped p
|
||||
join music.tracks t on t.id = p.track_id
|
||||
left join music.artists a on a.id = t.artist_id
|
||||
left join music.albums al on al.id = t.album_id
|
||||
order by p.played_at desc limit 50`,
|
||||
sql`select source, count(*)::int as plays from music.plays_deduped group by source order by plays desc`
|
||||
]);
|
||||
const t = totals[0];
|
||||
return {
|
||||
range,
|
||||
totals: {
|
||||
plays: t.plays as number,
|
||||
hours: Number(t.hours),
|
||||
tracks: t.tracks as number,
|
||||
artists: t.artists as number,
|
||||
first: t.first ? new Date(t.first).toISOString() : null
|
||||
},
|
||||
topArtists: artists.map((r) => ({
|
||||
name: r.name as string,
|
||||
plays: r.plays as number,
|
||||
hours: Number(r.hours),
|
||||
image: (r.image as string) ?? null,
|
||||
url: r.spotify_id ? `https://open.spotify.com/artist/${r.spotify_id}` : null
|
||||
})),
|
||||
topTracks: tracks.map((r) => ({
|
||||
name: r.name as string,
|
||||
artists: r.artists as string,
|
||||
plays: r.plays as number,
|
||||
image: (r.image as string) ?? null,
|
||||
url: r.spotify_id ? `https://open.spotify.com/track/${r.spotify_id}` : null
|
||||
})),
|
||||
topAlbums: albums.map((r) => ({
|
||||
name: r.name as string,
|
||||
artist: (r.artist as string) ?? '',
|
||||
plays: r.plays as number,
|
||||
image: (r.image as string) ?? null,
|
||||
url: r.spotify_id ? `https://open.spotify.com/album/${r.spotify_id}` : null
|
||||
})),
|
||||
daily: daily.map((r) => ({
|
||||
day: r.day as string,
|
||||
plays: r.plays as number,
|
||||
hours: Number(r.hours)
|
||||
})),
|
||||
streak: {
|
||||
current: Number(streak[0].current),
|
||||
longest: Number(streak[0].longest),
|
||||
longestStart: (streak[0].longest_start as string) ?? null,
|
||||
longestEnd: (streak[0].longest_end as string) ?? null,
|
||||
days: Number(streak[0].days)
|
||||
},
|
||||
recent: recent.map((r) => ({
|
||||
id: r.id as number,
|
||||
at: new Date(r.played_at).toISOString(),
|
||||
source: r.source as string,
|
||||
track: r.track as string,
|
||||
artists: r.artists as string,
|
||||
image: (r.image as string) ?? null,
|
||||
url: r.spotify_id ? `https://open.spotify.com/track/${r.spotify_id}` : null
|
||||
})),
|
||||
sources: sources.map((r) => ({ source: r.source as string, plays: r.plays as number }))
|
||||
};
|
||||
}
|
||||
|
||||
/** Owner-only connection status for /trackers/music. Never includes tokens. */
|
||||
export async function getSpotifyConnection() {
|
||||
const [row] = await sql`
|
||||
select spotify_user_id, display_name, needs_reauth, last_poll_at, last_error, connected_at
|
||||
from music.spotify_auth where id = 1`;
|
||||
if (!row) return null;
|
||||
return {
|
||||
user: (row.display_name as string) || (row.spotify_user_id as string),
|
||||
needsReauth: row.needs_reauth as boolean,
|
||||
lastPollAt: row.last_poll_at ? new Date(row.last_poll_at).toISOString() : null,
|
||||
lastError: (row.last_error as string) ?? null,
|
||||
connectedAt: new Date(row.connected_at).toISOString()
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,9 @@ export const ownerOnlyPrefixes = [
|
||||
'/trackers/groceries',
|
||||
'/trackers/houses',
|
||||
'/trackers/books/files',
|
||||
'/lists/db'
|
||||
'/lists/db',
|
||||
// Spotify OAuth for the listening tracker (connect + callback 404 for everyone else).
|
||||
'/api/spotify'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -46,7 +48,8 @@ export const ownerWritePrefixes = [
|
||||
'/trackers/groceries',
|
||||
'/trackers/houses',
|
||||
'/trackers/books',
|
||||
'/trackers/cars'
|
||||
'/trackers/cars',
|
||||
'/trackers/music'
|
||||
] as const;
|
||||
|
||||
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');
|
||||
|
||||
Reference in New Issue
Block a user