music: Last.fm-style stats with 7d/30d/1y/all ranges and genres
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
- Range picker (?range=7d|30d|1y|all) drives every section; the hours chart covers the whole range (daily for 7d/30d, weekly for 1y, monthly for all). - Genres from Last.fm artist top tags (music.artist_tags, normalised and filtered), split by artist listening time, with a per-bucket stacked chart. - Listening clock, new artists, comparison with the previous period and top movers, streaks, milestones, top albums with art, rank changes. - music.play_facts materialized view (refreshed by the app when plays arrive) plus a per-range cache keep the page fast. - scripts/music-fetch-tags.ts backfills tags, most-played artists first.
This commit is contained in:
157
src/lib/music/HoursChart.svelte
Normal file
157
src/lib/music/HoursChart.svelte
Normal file
@@ -0,0 +1,157 @@
|
||||
<script lang="ts" module>
|
||||
export type ChartSegment = { name: string; hours: number; color: string };
|
||||
export type ChartBar = {
|
||||
key: string;
|
||||
/** Tooltip title, e.g. the date or "week of 19 Oct 2026". */
|
||||
title: string;
|
||||
hours: number;
|
||||
plays?: number;
|
||||
/** Stacked parts (bottom first); the rest of `hours` is drawn in the base colour. */
|
||||
segments?: ChartSegment[];
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// Bar chart of listening hours with a styled tooltip on hover, keyboard focus or tap
|
||||
// (roving tabindex: arrow keys / Home / End move between bars, Escape closes).
|
||||
let {
|
||||
bars,
|
||||
label,
|
||||
startLabel = '',
|
||||
endLabel = '',
|
||||
baseColor = '#1db954',
|
||||
height = 'h-32'
|
||||
}: {
|
||||
bars: ChartBar[];
|
||||
label: string;
|
||||
startLabel?: string;
|
||||
endLabel?: string;
|
||||
baseColor?: string;
|
||||
height?: string;
|
||||
} = $props();
|
||||
|
||||
let active = $state<number | null>(null);
|
||||
let focused = $state<number | null>(null);
|
||||
let buttons = $state<HTMLButtonElement[]>([]);
|
||||
// Whether the tapped bar was already open before this tap (focus opens it first).
|
||||
let wasOpen = false;
|
||||
const max = $derived(Math.max(0.01, ...bars.map((b) => b.hours)));
|
||||
const tabIndexAt = $derived(focused ?? bars.length - 1);
|
||||
const tip = $derived(active === null ? null : bars[active]);
|
||||
const exactHours = (h: number) =>
|
||||
`${h.toLocaleString('en-GB', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} h`;
|
||||
const shift = (pos: number) => (pos < 0.15 ? '-10%' : pos > 0.85 ? '-90%' : '-50%');
|
||||
const pct = (h: number) => (h / max) * 100;
|
||||
|
||||
function move(event: KeyboardEvent, i: number) {
|
||||
if (event.key === 'Escape') {
|
||||
active = null;
|
||||
return;
|
||||
}
|
||||
const last = bars.length - 1;
|
||||
const next: Record<string, number> = {
|
||||
ArrowLeft: Math.max(0, i - 1),
|
||||
ArrowRight: Math.min(last, i + 1),
|
||||
Home: 0,
|
||||
End: last
|
||||
};
|
||||
if (!(event.key in next)) return;
|
||||
event.preventDefault();
|
||||
focused = next[event.key];
|
||||
buttons[focused]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex {height} items-end gap-px rounded-md border border-[var(--site-border)] p-2"
|
||||
role="group"
|
||||
aria-label={label}
|
||||
onpointerleave={(e) => {
|
||||
if (e.pointerType === 'mouse') active = null;
|
||||
}}
|
||||
>
|
||||
{#each bars as bar, i (bar.key)}
|
||||
{@const segs = bar.segments ?? []}
|
||||
{@const rest = Math.max(0, bar.hours - segs.reduce((sum, s) => sum + s.hours, 0))}
|
||||
<button
|
||||
type="button"
|
||||
bind:this={buttons[i]}
|
||||
class="group flex h-full min-w-0 flex-1 cursor-default flex-col-reverse rounded-sm focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--site-text)]"
|
||||
tabindex={i === tabIndexAt ? 0 : -1}
|
||||
aria-label="{bar.title}: {exactHours(bar.hours)}{bar.plays != null
|
||||
? `, ${bar.plays} plays`
|
||||
: ''}"
|
||||
onpointerenter={(e) => {
|
||||
if (e.pointerType === 'mouse') active = i;
|
||||
}}
|
||||
onpointerdown={(e) => {
|
||||
wasOpen = e.pointerType !== 'mouse' && active === i;
|
||||
}}
|
||||
onclick={() => {
|
||||
active = wasOpen ? null : i;
|
||||
wasOpen = false;
|
||||
}}
|
||||
onfocus={() => {
|
||||
focused = i;
|
||||
active = i;
|
||||
}}
|
||||
onblur={() => {
|
||||
if (active === i) active = null;
|
||||
}}
|
||||
onkeydown={(e) => move(e, i)}
|
||||
>
|
||||
<span
|
||||
class="flex w-full flex-col-reverse overflow-hidden rounded-sm transition-opacity {active ===
|
||||
i
|
||||
? 'opacity-100'
|
||||
: 'opacity-80 group-hover:opacity-100'}"
|
||||
style="height: {Math.max(bar.hours > 0 ? 3 : 0, pct(bar.hours))}%"
|
||||
>
|
||||
{#each segs as seg (seg.name)}
|
||||
<span
|
||||
class="block w-full shrink-0"
|
||||
style="height: {(seg.hours / Math.max(bar.hours, 0.0001)) *
|
||||
100}%; background: {seg.color}"
|
||||
></span>
|
||||
{/each}
|
||||
{#if rest > 0}
|
||||
<span class="block w-full flex-1" style="background: {baseColor}"></span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if tip && active !== null}
|
||||
{@const pos = (active + 0.5) / bars.length}
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-full z-10 mb-2 rounded-md border border-[var(--site-border)] bg-[var(--site-surface)] px-3 py-1.5 text-sm whitespace-nowrap shadow-lg"
|
||||
style="left: calc(0.5rem + {pos} * (100% - 1rem)); transform: translateX({shift(pos)})"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="block font-semibold tabular-nums">{exactHours(tip.hours)}</span>
|
||||
<span class="block text-xs text-[var(--site-muted)]"
|
||||
>{tip.title}{#if tip.plays != null}
|
||||
· {tip.plays.toLocaleString('en-GB')} {tip.plays === 1 ? 'play' : 'plays'}{/if}</span
|
||||
>
|
||||
{#if tip.segments?.length}
|
||||
<ul class="mt-1 space-y-0.5 text-xs">
|
||||
{#each [...tip.segments].reverse() as seg (seg.name)}
|
||||
<li class="flex items-center gap-1.5">
|
||||
<span class="size-2 rounded-full" style="background: {seg.color}"></span>
|
||||
{seg.name}
|
||||
<span class="ml-auto pl-3 tabular-nums text-[var(--site-muted)]"
|
||||
>{exactHours(seg.hours)}</span
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if startLabel || endLabel}
|
||||
<div class="mt-1 flex justify-between text-xs text-[var(--site-muted)]">
|
||||
<span>{startLabel}</span><span>{endLabel}</span>
|
||||
</div>
|
||||
{/if}
|
||||
51
src/lib/music/tags.test.ts
Normal file
51
src/lib/music/tags.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeTag, pickTags } from './tags';
|
||||
|
||||
describe('normalizeTag', () => {
|
||||
it('merges spelling variants', () => {
|
||||
expect(normalizeTag('Hip-Hop')).toBe('hip-hop');
|
||||
expect(normalizeTag('hiphop')).toBe('hip-hop');
|
||||
expect(normalizeTag('Hip Hop')).toBe('hip-hop');
|
||||
expect(normalizeTag('Nu Metal')).toBe('nu metal');
|
||||
expect(normalizeTag('nu-metal')).toBe('nu metal');
|
||||
expect(normalizeTag('Post Rock')).toBe('post-rock');
|
||||
expect(normalizeTag('post - rock')).toBe('post-rock');
|
||||
expect(normalizeTag('Drum n Bass')).toBe('drum and bass');
|
||||
expect(normalizeTag(' Progressive Metal ')).toBe('progressive metal');
|
||||
});
|
||||
|
||||
it('drops junk tags', () => {
|
||||
for (const junk of [
|
||||
'seen live',
|
||||
'favorites',
|
||||
'My Favourite',
|
||||
'albums I own',
|
||||
'80s',
|
||||
'2010s',
|
||||
'british',
|
||||
'female vocalists',
|
||||
'spotify'
|
||||
])
|
||||
expect(normalizeTag(junk)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickTags', () => {
|
||||
it('keeps the top genres above the minimum count, merged', () => {
|
||||
expect(
|
||||
pickTags([
|
||||
{ name: 'nu metal', count: 100 },
|
||||
{ name: 'seen live', count: 80 },
|
||||
{ name: 'Nu-Metal', count: 60 },
|
||||
{ name: 'metal', count: 55 },
|
||||
{ name: 'alternative metal', count: 40 },
|
||||
{ name: 'american', count: 30 },
|
||||
{ name: 'rock', count: 5 }
|
||||
])
|
||||
).toEqual([
|
||||
{ tag: 'nu metal', weight: 100 },
|
||||
{ tag: 'metal', weight: 55 },
|
||||
{ tag: 'alternative metal', weight: 40 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
124
src/lib/music/tags.ts
Normal file
124
src/lib/music/tags.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// Last.fm artist tags → genres: normalise spelling variants and drop tags that are not a
|
||||
// genre (seen live, favourites, nationalities, decades, ...). Pure, shared by
|
||||
// scripts/music-fetch-tags.ts and the tests.
|
||||
|
||||
const ALIASES: Record<string, string> = {
|
||||
hiphop: 'hip-hop',
|
||||
'hip hop': 'hip-hop',
|
||||
rap: 'hip-hop',
|
||||
rnb: 'r&b',
|
||||
'r and b': 'r&b',
|
||||
'rhythm and blues': 'r&b',
|
||||
dnb: 'drum and bass',
|
||||
'drum n bass': 'drum and bass',
|
||||
'drum & bass': 'drum and bass',
|
||||
'j pop': 'j-pop',
|
||||
jpop: 'j-pop',
|
||||
'k pop': 'k-pop',
|
||||
kpop: 'k-pop',
|
||||
'j rock': 'j-rock',
|
||||
'lo fi': 'lo-fi',
|
||||
lofi: 'lo-fi',
|
||||
'alt rock': 'alternative rock',
|
||||
alternative: 'alternative rock',
|
||||
'nu-metal': 'nu metal',
|
||||
numetal: 'nu metal',
|
||||
electronica: 'electronic',
|
||||
electro: 'electronic',
|
||||
'video game': 'video game music',
|
||||
'video games': 'video game music',
|
||||
'videogame music': 'video game music',
|
||||
'game music': 'video game music',
|
||||
vgm: 'video game music',
|
||||
ost: 'soundtrack',
|
||||
'original soundtrack': 'soundtrack',
|
||||
'singer songwriter': 'singer-songwriter',
|
||||
'post rock': 'post-rock',
|
||||
'post punk': 'post-punk',
|
||||
'post hardcore': 'post-hardcore',
|
||||
'pop punk': 'pop punk',
|
||||
'synth pop': 'synthpop',
|
||||
'synth-pop': 'synthpop',
|
||||
'trip hop': 'trip-hop'
|
||||
};
|
||||
|
||||
const JUNK = new Set([
|
||||
'seen live',
|
||||
'live',
|
||||
'albums i own',
|
||||
'under 2000 listeners',
|
||||
'spotify',
|
||||
'love',
|
||||
'loved',
|
||||
'beautiful',
|
||||
'awesome',
|
||||
'amazing',
|
||||
'cool',
|
||||
'good',
|
||||
'great',
|
||||
'best',
|
||||
'epic',
|
||||
'fun',
|
||||
'sexy',
|
||||
'genius',
|
||||
'legend',
|
||||
'legends',
|
||||
'overrated',
|
||||
'underrated',
|
||||
'check out',
|
||||
'to listen',
|
||||
'male vocalists',
|
||||
'female vocalists',
|
||||
'male vocalist',
|
||||
'female vocalist',
|
||||
'male vocals',
|
||||
'female vocals',
|
||||
'vocal',
|
||||
'vocals',
|
||||
'all',
|
||||
'music',
|
||||
'other',
|
||||
'misc',
|
||||
'unknown',
|
||||
'instrumental music',
|
||||
'band',
|
||||
'bands',
|
||||
'artist',
|
||||
'composer',
|
||||
'dj',
|
||||
'producer'
|
||||
]);
|
||||
|
||||
const PLACES =
|
||||
/^(american|british|english|scottish|irish|welsh|uk|usa|us|german|deutsch|japanese|japan|french|swedish|swede|dutch|nederlands|belgian|canadian|australian|finnish|norwegian|danish|polish|russian|korean|italian|spanish|brazilian|mexican|icelandic|chinese|ukrainian|austrian|swiss|new zealand|los angeles|california|london|seattle)$/;
|
||||
const JUNK_PATTERN =
|
||||
/favou?rite|favs?\b|seen live|\bi own\b|i like|my |listened|wishlist|check out|youtube|spotify|soundcloud|^\d{2,4}s?$|^(19|20)\d\d\b|\bnoughties\b|best of|top \d/;
|
||||
|
||||
/** Canonical genre name, or null when the tag is not a genre. */
|
||||
export function normalizeTag(raw: string): string | null {
|
||||
let tag = raw
|
||||
.toLowerCase()
|
||||
.normalize('NFKC')
|
||||
.replace(/[_]+/g, ' ')
|
||||
.replace(/\s*-\s*/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!tag || tag.length > 40 || tag.length < 2) return null;
|
||||
tag = ALIASES[tag] ?? ALIASES[tag.replace(/-/g, ' ')] ?? tag;
|
||||
if (JUNK.has(tag) || PLACES.test(tag) || JUNK_PATTERN.test(tag)) return null;
|
||||
return tag;
|
||||
}
|
||||
|
||||
/** Top genres from Last.fm's [{ name, count }] (count 0–100): merged variants, best first. */
|
||||
export function pickTags(raw: { name: string; count: number }[], max = 5, minCount = 10) {
|
||||
const best = new Map<string, number>();
|
||||
for (const { name, count } of raw) {
|
||||
const tag = normalizeTag(name);
|
||||
if (!tag || !(count >= minCount)) continue;
|
||||
best.set(tag, Math.max(best.get(tag) ?? 0, Math.min(100, Math.round(count))));
|
||||
}
|
||||
return [...best]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, max)
|
||||
.map(([tag, weight]) => ({ tag, weight }));
|
||||
}
|
||||
@@ -190,3 +190,28 @@ export const musicImportState = musicSchema.table('import_state', {
|
||||
data: jsonb('data').notNull(),
|
||||
updatedAt: timestamptz('updated_at').notNull().defaultNow()
|
||||
});
|
||||
|
||||
/**
|
||||
* Genres: Last.fm artist.getTopTags per artist, normalised and filtered for junk tags
|
||||
* (scripts/music-fetch-tags.ts). Weight is Last.fm's tag count (100 = the artist's top tag).
|
||||
*/
|
||||
export const musicArtistTags = musicSchema.table(
|
||||
'artist_tags',
|
||||
{
|
||||
artistId: integer('artist_id')
|
||||
.notNull()
|
||||
.references(() => musicArtists.id, { onDelete: 'cascade' }),
|
||||
tag: text('tag').notNull(),
|
||||
weight: smallint('weight').notNull()
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.artistId, t.tag] }), index('artist_tags_tag_idx').on(t.tag)]
|
||||
);
|
||||
|
||||
/** When tags were last fetched per artist: 'ok', 'no_tags' or 'not_found' (retry later). */
|
||||
export const musicArtistTagFetch = musicSchema.table('artist_tag_fetch', {
|
||||
artistId: integer('artist_id')
|
||||
.primaryKey()
|
||||
.references(() => musicArtists.id, { onDelete: 'cascade' }),
|
||||
status: text('status').notNull(),
|
||||
fetchedAt: timestamptz('fetched_at').notNull().defaultNow()
|
||||
});
|
||||
|
||||
@@ -7,12 +7,14 @@ import type { LiveListening } from '#lib/music/now-playing.js';
|
||||
// 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 const RANGES = { '7d': 7, '30d': 30, '1y': 365, 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';
|
||||
/** Chart bucket per range: days for 7d/30d, weeks for a year, months for all time. */
|
||||
export const BUCKETS = { '7d': 'day', '30d': 'day', '1y': 'week', all: 'month' } as const;
|
||||
export type Bucket = (typeof BUCKETS)[Range];
|
||||
|
||||
export type Listening = {
|
||||
nowPlaying: boolean;
|
||||
@@ -108,98 +110,310 @@ export async function getLiveFromDb(): Promise<LiveListening | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// The heavy aggregates read music.play_facts (a materialized copy of plays_deduped with
|
||||
// artist, album, listening ms and local day/hour/weekday). The app refreshes it when new
|
||||
// plays arrive (a definer function; REFRESH needs the owner) and caches results per range
|
||||
// until the next refresh or for five minutes. Recent plays always read the live view.
|
||||
const CACHE_MS = 5 * 60_000;
|
||||
const MILESTONES = [
|
||||
1, 1_000, 10_000, 25_000, 50_000, 100_000, 150_000, 200_000, 250_000, 300_000, 400_000, 500_000
|
||||
];
|
||||
let factsVersion = 0;
|
||||
let factsWatermark: number | null = null;
|
||||
let refreshing: Promise<void> | null = null;
|
||||
const statsCache = new Map<Range, { at: number; version: number; data: Promise<MusicStats> }>();
|
||||
|
||||
/** Re-materialize music.play_facts (single flight). */
|
||||
export function refreshPlayFacts(): Promise<void> {
|
||||
refreshing ??= (async () => {
|
||||
const [row] = await sql`select coalesce(max(id), 0)::int as id from music.plays`;
|
||||
await sql`select music.refresh_play_facts()`;
|
||||
factsWatermark = row.id as number;
|
||||
factsVersion++;
|
||||
})()
|
||||
.catch((e) =>
|
||||
console.error(`[music] play_facts refresh failed: ${e instanceof Error ? e.message : e}`)
|
||||
)
|
||||
.finally(() => (refreshing = null));
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
/** Kick off a refresh when plays were added since the last one (never blocks the page). */
|
||||
async function checkFacts() {
|
||||
const [row] = await sql`select coalesce(max(id), 0)::int as id from music.plays`;
|
||||
if (row.id !== factsWatermark) void refreshPlayFacts();
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const isoDay = (ms: number) => new Date(ms).toISOString().slice(0, 10);
|
||||
const dayMs = (day: string) => Date.parse(`${day}T00:00:00Z`);
|
||||
/** Today's date in Amsterdam, YYYY-MM-DD. */
|
||||
const localToday = () =>
|
||||
new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TZ,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).format(new Date());
|
||||
|
||||
const hoursOf = (v: unknown) => Number(v ?? 0) / 3_600_000;
|
||||
|
||||
export async function getMusicStats(range: Range) {
|
||||
const [, stats, recent] = await Promise.all([
|
||||
checkFacts().catch(() => {}),
|
||||
cachedStats(range),
|
||||
getRecentPlays()
|
||||
]);
|
||||
return { ...stats, recent };
|
||||
}
|
||||
|
||||
function cachedStats(range: Range): Promise<MusicStats> {
|
||||
const hit = statsCache.get(range);
|
||||
if (hit && hit.version === factsVersion && Date.now() - hit.at < CACHE_MS) return hit.data;
|
||||
const data = computeStats(range);
|
||||
statsCache.set(range, { at: Date.now(), version: factsVersion, data });
|
||||
data.catch(() => statsCache.delete(range));
|
||||
return data;
|
||||
}
|
||||
|
||||
/** The latest plays, live (not cached, not from play_facts). */
|
||||
async function getRecentPlays() {
|
||||
const rows = await 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 30`;
|
||||
return rows.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
|
||||
}));
|
||||
}
|
||||
|
||||
async function computeStats(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([
|
||||
const unit: Bucket = BUCKETS[range];
|
||||
const today = localToday();
|
||||
const from = days ? isoDay(dayMs(today) - (days - 1) * DAY_MS) : null;
|
||||
const prevFrom = days && from ? isoDay(dayMs(from) - days * DAY_MS) : null;
|
||||
// Fragments are single-use: build one per query.
|
||||
const cur = () => (from ? sql`f.local_day >= ${from}::date` : sql`true`);
|
||||
const prev = () => sql`f.local_day >= ${prevFrom}::date and f.local_day < ${from}::date`;
|
||||
const step = `1 ${unit}`;
|
||||
const tagShare =
|
||||
() => sql`select artist_id, tag, weight::float8 / sum(weight) over (partition by artist_id) as share
|
||||
from music.artist_tags`;
|
||||
const totalsOf = (where: typeof cur) => sql`
|
||||
select count(*)::int as plays, coalesce(sum(f.ms), 0)::float8 as ms,
|
||||
count(distinct f.track_id)::int as tracks, count(distinct f.artist_id)::int as artists,
|
||||
count(distinct f.album_id)::int as albums, count(distinct f.local_day)::int as days,
|
||||
min(f.local_day)::text as first
|
||||
from music.play_facts f where ${where()}`;
|
||||
const ranksOf = (where: typeof cur, limit: number) => sql`
|
||||
select f.artist_id as id, count(*)::int as plays,
|
||||
(row_number() over (order by count(*) desc, f.artist_id))::int as rank
|
||||
from music.play_facts f where ${where()} and f.artist_id is not null
|
||||
group by f.artist_id order by plays desc, f.artist_id limit ${limit}`;
|
||||
const [
|
||||
totals,
|
||||
prevTotals,
|
||||
artists,
|
||||
curRanks,
|
||||
prevRanks,
|
||||
tracks,
|
||||
albums,
|
||||
chart,
|
||||
clock,
|
||||
newArtists,
|
||||
discovered,
|
||||
streak,
|
||||
milestones,
|
||||
genres,
|
||||
tagged,
|
||||
genreTimeline,
|
||||
sources
|
||||
] = await Promise.all([
|
||||
totalsOf(cur),
|
||||
prevFrom ? totalsOf(prev) : Promise.resolve(null),
|
||||
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,
|
||||
select a.id, a.name, a.spotify_id, x.plays, x.ms,
|
||||
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`,
|
||||
from (select f.artist_id, count(*)::int as plays, sum(f.ms)::float8 as ms
|
||||
from music.play_facts f where ${cur()} and f.artist_id is not null
|
||||
group by f.artist_id order by plays desc limit 10) x
|
||||
join music.artists a on a.id = x.artist_id
|
||||
order by x.plays desc, a.name`,
|
||||
prevFrom ? ranksOf(cur, 100) : Promise.resolve([]),
|
||||
prevFrom ? ranksOf(prev, 1000) : Promise.resolve([]),
|
||||
sql`
|
||||
select t.id, t.name, t.spotify_id, ${artistsOf()} as artists, count(*)::int as plays,
|
||||
select t.id, t.name, t.spotify_id, ${artistsOf()} as artists, x.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
|
||||
from (select f.track_id, count(*)::int as plays from music.play_facts f where ${cur()}
|
||||
group by f.track_id order by plays desc limit 10) x
|
||||
join music.tracks t on t.id = x.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`,
|
||||
order by x.plays desc, t.name`,
|
||||
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
|
||||
select al.id, al.name, al.spotify_id, a.name as artist, x.plays, x.ms,
|
||||
coalesce(al.image_url, al.image_url_small) as image
|
||||
from (select f.album_id, count(*)::int as plays, sum(f.ms)::float8 as ms
|
||||
from music.play_facts f where ${cur()} and f.album_id is not null
|
||||
group by f.album_id order by plays desc limit 12) x
|
||||
join music.albums al on al.id = x.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`,
|
||||
order by x.plays desc, al.name`,
|
||||
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`,
|
||||
with b as (
|
||||
select generate_series(
|
||||
date_trunc(${unit}, coalesce(${from}::date, (select min(local_day) from music.play_facts), ${today}::date)),
|
||||
date_trunc(${unit}, ${today}::date), ${step}::interval)::date as bucket)
|
||||
select b.bucket::text as bucket, coalesce(x.plays, 0)::int as plays, coalesce(x.ms, 0)::float8 as ms
|
||||
from b left join (
|
||||
select date_trunc(${unit}, f.local_day)::date as bucket, count(*) as plays, sum(f.ms) as ms
|
||||
from music.play_facts f where ${cur()} group by 1) x using (bucket)
|
||||
order by b.bucket`,
|
||||
sql`
|
||||
with d as (select distinct (played_at at time zone ${TZ})::date as day from music.plays_deduped),
|
||||
select f.dow, f.hour, count(*)::int as plays, sum(f.ms)::float8 as ms
|
||||
from music.play_facts f where ${cur()} group by 1, 2`,
|
||||
from
|
||||
? sql`
|
||||
with firsts as (
|
||||
select artist_id, min(played_at) as first from music.play_facts
|
||||
where artist_id is not null group by artist_id having min(local_day) >= ${from}::date)
|
||||
select a.name, a.spotify_id, fi.first, count(*)::int as plays, (count(*) over ())::int as total
|
||||
from firsts fi
|
||||
join music.play_facts f on f.artist_id = fi.artist_id
|
||||
join music.artists a on a.id = fi.artist_id
|
||||
group by a.id, fi.first order by plays desc, a.name limit 10`
|
||||
: Promise.resolve([]),
|
||||
sql`
|
||||
select extract(year from first)::int as year, count(*)::int as artists
|
||||
from (select min(local_day) as first from music.play_facts where artist_id is not null
|
||||
group by artist_id) x group by 1 order by 1`,
|
||||
sql`
|
||||
with d as (select distinct local_day as day from music.play_facts),
|
||||
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
|
||||
coalesce((select len from s where end_day >= ${today}::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
|
||||
select x.n::int as n, x.played_at, t.name as track, t.spotify_id, a.name as artist
|
||||
from (select track_id, played_at, row_number() over (order by played_at, id) as n
|
||||
from music.play_facts) x
|
||||
join music.tracks t on t.id = x.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`
|
||||
where x.n = any(${MILESTONES}::int[]) order by x.n`,
|
||||
sql`
|
||||
with ah as (select f.artist_id, sum(f.ms)::float8 as ms from music.play_facts f
|
||||
where ${cur()} and f.artist_id is not null group by 1),
|
||||
tw as (${tagShare()})
|
||||
select tw.tag, sum(ah.ms * tw.share) as ms, count(*)::int as artists
|
||||
from ah join tw using (artist_id) group by tw.tag order by ms desc limit 20`,
|
||||
sql`
|
||||
select coalesce(sum(f.ms) filter (where exists (
|
||||
select 1 from music.artist_tags at where at.artist_id = f.artist_id)), 0)::float8 as tagged,
|
||||
coalesce(sum(f.ms), 0)::float8 as total
|
||||
from music.play_facts f where ${cur()}`,
|
||||
sql`
|
||||
with ah as (select f.artist_id, sum(f.ms)::float8 as ms from music.play_facts f
|
||||
where ${cur()} and f.artist_id is not null group by 1),
|
||||
tw as (${tagShare()}),
|
||||
top as (select tw.tag from ah join tw using (artist_id) group by tw.tag
|
||||
order by sum(ah.ms * tw.share) desc limit 6),
|
||||
bx as (select date_trunc(${unit}, f.local_day)::date as bucket, f.artist_id, sum(f.ms)::float8 as ms
|
||||
from music.play_facts f where ${cur()} and f.artist_id is not null group by 1, 2)
|
||||
select bx.bucket::text as bucket, tw.tag, sum(bx.ms * tw.share) as ms
|
||||
from bx join (${tagShare()}) tw using (artist_id)
|
||||
where tw.tag in (select tag from top) group by 1, 2`,
|
||||
sql`select f.source, count(*)::int as plays from music.play_facts f where ${cur()}
|
||||
group by f.source order by plays desc`
|
||||
]);
|
||||
|
||||
const t = totals[0];
|
||||
const pt = prevTotals?.[0];
|
||||
const spanDays =
|
||||
days ?? (t.first ? Math.round((dayMs(today) - dayMs(t.first as string)) / DAY_MS) + 1 : 1);
|
||||
const prevRank = new Map(prevRanks.map((r) => [r.id as number, r]));
|
||||
const curRank = new Map(curRanks.map((r) => [r.id as number, r]));
|
||||
const names = new Map(artists.map((a) => [a.id as number, a.name as string]));
|
||||
const topGenres = genres.slice(0, 6).map((g) => g.tag as string);
|
||||
const timeline = new Map<string, Record<string, number>>();
|
||||
for (const r of genreTimeline) {
|
||||
const row = timeline.get(r.bucket as string) ?? {};
|
||||
row[r.tag as string] = hoursOf(r.ms);
|
||||
timeline.set(r.bucket as string, row);
|
||||
}
|
||||
// Movers: artists with the biggest gain in plays over the previous period.
|
||||
const movers = curRanks
|
||||
.map((r) => {
|
||||
const p = prevRank.get(r.id as number);
|
||||
return {
|
||||
id: r.id as number,
|
||||
rank: r.rank as number,
|
||||
prevRank: (p?.rank as number) ?? null,
|
||||
plays: r.plays as number,
|
||||
prevPlays: (p?.plays as number) ?? 0
|
||||
};
|
||||
})
|
||||
.filter((m) => m.plays > m.prevPlays)
|
||||
.sort((a, b) => b.plays - b.prevPlays - (a.plays - a.prevPlays))
|
||||
.slice(0, 5);
|
||||
const moverIds = movers.map((m) => m.id).filter((id) => !names.has(id));
|
||||
if (moverIds.length)
|
||||
for (const r of await sql`select id, name from music.artists where id = any(${moverIds}::int[])`)
|
||||
names.set(r.id as number, r.name as string);
|
||||
|
||||
return {
|
||||
range,
|
||||
unit,
|
||||
from,
|
||||
today,
|
||||
totals: {
|
||||
plays: t.plays as number,
|
||||
hours: Number(t.hours),
|
||||
hours: hoursOf(t.ms),
|
||||
tracks: t.tracks as number,
|
||||
artists: t.artists as number,
|
||||
first: t.first ? new Date(t.first).toISOString() : null
|
||||
albums: t.albums as number,
|
||||
daysListened: t.days as number,
|
||||
spanDays,
|
||||
first: (t.first as string) ?? 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
|
||||
})),
|
||||
previous: pt
|
||||
? {
|
||||
plays: pt.plays as number,
|
||||
hours: hoursOf(pt.ms),
|
||||
artists: pt.artists as number,
|
||||
tracks: pt.tracks as number
|
||||
}
|
||||
: null,
|
||||
topArtists: artists.map((r) => {
|
||||
const p = prevRank.get(r.id as number);
|
||||
return {
|
||||
name: r.name as string,
|
||||
plays: r.plays as number,
|
||||
hours: hoursOf(r.ms),
|
||||
image: (r.image as string) ?? null,
|
||||
url: r.spotify_id ? `https://open.spotify.com/artist/${r.spotify_id}` : null,
|
||||
rank: (curRank.get(r.id as number)?.rank as number) ?? null,
|
||||
prevRank: prevFrom ? ((p?.rank as number) ?? null) : undefined
|
||||
};
|
||||
}),
|
||||
movers: movers.map((m) => ({ ...m, name: names.get(m.id) ?? '' })),
|
||||
topTracks: tracks.map((r) => ({
|
||||
name: r.name as string,
|
||||
artists: r.artists as string,
|
||||
@@ -211,14 +425,48 @@ export async function getMusicStats(range: Range) {
|
||||
name: r.name as string,
|
||||
artist: (r.artist as string) ?? '',
|
||||
plays: r.plays as number,
|
||||
hours: hoursOf(r.ms),
|
||||
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,
|
||||
chart: chart.map((r) => ({
|
||||
bucket: r.bucket as string,
|
||||
plays: r.plays as number,
|
||||
hours: Number(r.hours)
|
||||
hours: hoursOf(r.ms)
|
||||
})),
|
||||
clock: clock.map((r) => ({
|
||||
dow: r.dow as number,
|
||||
hour: r.hour as number,
|
||||
plays: r.plays as number,
|
||||
hours: hoursOf(r.ms)
|
||||
})),
|
||||
newArtists: {
|
||||
total: (newArtists[0]?.total as number) ?? 0,
|
||||
top: newArtists.map((r) => ({
|
||||
name: r.name as string,
|
||||
plays: r.plays as number,
|
||||
first: new Date(r.first).toISOString(),
|
||||
url: r.spotify_id ? `https://open.spotify.com/artist/${r.spotify_id}` : null
|
||||
}))
|
||||
},
|
||||
discoveredByYear: discovered.map((r) => ({
|
||||
year: r.year as number,
|
||||
artists: r.artists as number
|
||||
})),
|
||||
genres: {
|
||||
top: genres.map((g) => ({
|
||||
tag: g.tag as string,
|
||||
hours: hoursOf(g.ms),
|
||||
artists: g.artists as number
|
||||
})),
|
||||
coverage: Number(tagged[0].total) ? Number(tagged[0].tagged) / Number(tagged[0].total) : 0,
|
||||
series: topGenres,
|
||||
timeline: chart.map((r) => ({
|
||||
bucket: r.bucket as string,
|
||||
hours: hoursOf(r.ms),
|
||||
tags: timeline.get(r.bucket as string) ?? {}
|
||||
}))
|
||||
},
|
||||
streak: {
|
||||
current: Number(streak[0].current),
|
||||
longest: Number(streak[0].longest),
|
||||
@@ -226,19 +474,19 @@ export async function getMusicStats(range: Range) {
|
||||
longestEnd: (streak[0].longest_end as string) ?? null,
|
||||
days: Number(streak[0].days)
|
||||
},
|
||||
recent: recent.map((r) => ({
|
||||
id: r.id as number,
|
||||
milestones: milestones.map((r) => ({
|
||||
n: r.n 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,
|
||||
artist: (r.artist as string) ?? '',
|
||||
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 }))
|
||||
};
|
||||
}
|
||||
|
||||
export type MusicStats = Awaited<ReturnType<typeof computeStats>>;
|
||||
|
||||
/** Owner-only connection status for /trackers/music. Never includes tokens. */
|
||||
export async function getSpotifyConnection() {
|
||||
const [row] = await sql`
|
||||
|
||||
Reference in New Issue
Block a user