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