Files
website/src/lib/server/music/stats.ts
Stan Runge b757269151
All checks were successful
Deploy production / deploy (push) Successful in 2m37s
music: self-hosted scrobbler (Spotify poller, Last.fm + streaming-history importers, /trackers/music, homepage listening card)
- 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
2026-10-24 15:57:40 +00:00

213 lines
9.1 KiB
TypeScript

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()
};
}