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:
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()
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user