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:
57
drizzle/20261025135617_music_stats/migration.sql
Normal file
57
drizzle/20261025135617_music_stats/migration.sql
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
CREATE TABLE "music"."artist_tag_fetch" (
|
||||||
|
"artist_id" integer PRIMARY KEY,
|
||||||
|
"status" text NOT NULL,
|
||||||
|
"fetched_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "music"."artist_tags" (
|
||||||
|
"artist_id" integer,
|
||||||
|
"tag" text,
|
||||||
|
"weight" smallint NOT NULL,
|
||||||
|
CONSTRAINT "artist_tags_pkey" PRIMARY KEY("artist_id","tag")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "artist_tags_tag_idx" ON "music"."artist_tags" ("tag");--> statement-breakpoint
|
||||||
|
ALTER TABLE "music"."artist_tag_fetch" ADD CONSTRAINT "artist_tag_fetch_artist_id_artists_id_fkey" FOREIGN KEY ("artist_id") REFERENCES "music"."artists"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "music"."artist_tags" ADD CONSTRAINT "artist_tags_artist_id_artists_id_fkey" FOREIGN KEY ("artist_id") REFERENCES "music"."artists"("id") ON DELETE CASCADE;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Hand-written below. music.play_facts: plays_deduped flattened for the stats page (artist,
|
||||||
|
-- album, listening ms, local day/hour/weekday in Europe/Amsterdam). The dedupe view costs
|
||||||
|
-- ~300 ms per scan on 180k plays; the stats run ~15 aggregates over this narrow copy
|
||||||
|
-- instead. Refreshed by the app (music.refresh_play_facts()) when new plays arrive.
|
||||||
|
CREATE MATERIALIZED VIEW "music"."play_facts" AS
|
||||||
|
SELECT p.id, p.track_id, t.artist_id, t.album_id, p.played_at, p.source,
|
||||||
|
coalesce(p.ms_played, t.duration_ms, 210000)::int AS ms,
|
||||||
|
(p.played_at AT TIME ZONE 'Europe/Amsterdam')::date AS local_day,
|
||||||
|
extract(hour FROM p.played_at AT TIME ZONE 'Europe/Amsterdam')::smallint AS hour,
|
||||||
|
extract(isodow FROM p.played_at AT TIME ZONE 'Europe/Amsterdam')::smallint AS dow
|
||||||
|
FROM "music"."plays_deduped" p
|
||||||
|
JOIN "music"."tracks" t ON t.id = p.track_id;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "play_facts_id_key" ON "music"."play_facts" ("id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "play_facts_day_idx" ON "music"."play_facts" ("local_day");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "play_facts_artist_idx" ON "music"."play_facts" ("artist_id", "played_at");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "play_facts_played_idx" ON "music"."play_facts" ("played_at");
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- The app role may refresh it (REFRESH needs the owner): a definer function, nothing else.
|
||||||
|
CREATE FUNCTION "music"."refresh_play_facts"() RETURNS void
|
||||||
|
LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$
|
||||||
|
REFRESH MATERIALIZED VIEW CONCURRENTLY music.play_facts;
|
||||||
|
$$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
REVOKE ALL ON FUNCTION "music"."refresh_play_facts"() FROM PUBLIC;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT SELECT ON music.play_facts TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON music.artist_tags, music.artist_tag_fetch TO personal_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION music.refresh_play_facts() TO personal_app;
|
||||||
|
END IF;
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||||
|
GRANT SELECT ON music.play_facts, music.artist_tags, music.artist_tag_fetch TO personal_ro;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
26526
drizzle/20261025135617_music_stats/snapshot.json
Normal file
26526
drizzle/20261025135617_music_stats/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
85
scripts/music-fetch-tags.ts
Normal file
85
scripts/music-fetch-tags.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// Fetch Last.fm artist top tags into music.artist_tags (genres for /trackers/music).
|
||||||
|
//
|
||||||
|
// LASTFM_API_KEY=... bun scripts/music-fetch-tags.ts [--limit N] [--sleep 250] [--refetch-days 90]
|
||||||
|
//
|
||||||
|
// Most-played artists first (all time). Artists fetched within --refetch-days are skipped
|
||||||
|
// ('not_found' is retried after 30 days), so a re-run only picks up new artists. Tags are
|
||||||
|
// normalised and filtered (src/lib/music/tags.ts); the top 5 genres per artist are kept.
|
||||||
|
// Rate limit: one request per --sleep ms (Last.fm asks for at most 5/s), back-off on 429/5xx.
|
||||||
|
import { pickTags } from '../src/lib/music/tags';
|
||||||
|
import { arg, musicDb, sleep } from './music-db';
|
||||||
|
|
||||||
|
const apiKey = process.env.LASTFM_API_KEY;
|
||||||
|
if (!apiKey) throw new Error('Set LASTFM_API_KEY');
|
||||||
|
const limit = Number(arg('limit') ?? 100_000);
|
||||||
|
const pause = Number(arg('sleep') ?? 250);
|
||||||
|
const refetchDays = Number(arg('refetch-days') ?? 90);
|
||||||
|
|
||||||
|
type LastfmTags = { toptags?: { tag?: { name: string; count: number }[] }; error?: number };
|
||||||
|
|
||||||
|
async function topTags(name: string): Promise<LastfmTags> {
|
||||||
|
const url = `https://ws.audioscrobbler.com/2.0/?${new URLSearchParams({
|
||||||
|
method: 'artist.gettoptags',
|
||||||
|
artist: name,
|
||||||
|
autocorrect: '1',
|
||||||
|
api_key: apiKey!,
|
||||||
|
format: 'json'
|
||||||
|
})}`;
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(15_000) }).catch(() => null);
|
||||||
|
const json = response ? ((await response.json().catch(() => ({}))) as LastfmTags) : {};
|
||||||
|
const retry =
|
||||||
|
!response ||
|
||||||
|
response.status === 429 ||
|
||||||
|
response.status >= 500 ||
|
||||||
|
json.error === 29 ||
|
||||||
|
json.error === 8 ||
|
||||||
|
json.error === 11 ||
|
||||||
|
json.error === 16;
|
||||||
|
if (!retry || attempt >= 4) return json;
|
||||||
|
await sleep(2_000 * 2 ** attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = musicDb();
|
||||||
|
try {
|
||||||
|
const artists = await sql`
|
||||||
|
select a.id, a.name, count(*)::int as plays
|
||||||
|
from music.plays p join music.tracks t on t.id = p.track_id
|
||||||
|
join music.artists a on a.id = t.artist_id
|
||||||
|
left join music.artist_tag_fetch tf on tf.artist_id = a.id
|
||||||
|
where tf.artist_id is null
|
||||||
|
or (tf.status = 'ok' and tf.fetched_at < now() - make_interval(days => ${refetchDays}))
|
||||||
|
or tf.status like 'error%'
|
||||||
|
or (tf.status <> 'ok' and tf.fetched_at < now() - interval '30 days')
|
||||||
|
group by a.id order by plays desc, a.id limit ${limit}`;
|
||||||
|
console.log(`${artists.length} artists to fetch.`);
|
||||||
|
let tagged = 0;
|
||||||
|
for (const [i, artist] of artists.entries()) {
|
||||||
|
const json = await topTags(artist.name as string);
|
||||||
|
const tags = pickTags(json.toptags?.tag ?? []);
|
||||||
|
const status =
|
||||||
|
json.error === 6
|
||||||
|
? 'not_found'
|
||||||
|
: json.error
|
||||||
|
? `error ${json.error}`
|
||||||
|
: tags.length
|
||||||
|
? 'ok'
|
||||||
|
: 'no_tags';
|
||||||
|
await sql.begin(async (tx) => {
|
||||||
|
if (!json.error || json.error === 6) {
|
||||||
|
await tx`delete from music.artist_tags where artist_id = ${artist.id}`;
|
||||||
|
for (const t of tags)
|
||||||
|
await tx`insert into music.artist_tags (artist_id, tag, weight) values (${artist.id}, ${t.tag}, ${t.weight})`;
|
||||||
|
}
|
||||||
|
await tx`insert into music.artist_tag_fetch (artist_id, status) values (${artist.id}, ${status})
|
||||||
|
on conflict (artist_id) do update set status = excluded.status, fetched_at = now()`;
|
||||||
|
});
|
||||||
|
if (tags.length) tagged++;
|
||||||
|
if ((i + 1) % 250 === 0) console.log(` ${i + 1}/${artists.length} (${tagged} with genres)`);
|
||||||
|
await sleep(pause);
|
||||||
|
}
|
||||||
|
console.log(`Done: ${tagged} of ${artists.length} artists got genres.`);
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
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(),
|
data: jsonb('data').notNull(),
|
||||||
updatedAt: timestamptz('updated_at').notNull().defaultNow()
|
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).
|
// track length (the API only reports plays of 30 s or more, not how long).
|
||||||
const TZ = 'Europe/Amsterdam';
|
const TZ = 'Europe/Amsterdam';
|
||||||
// Fragments are built per query (postgres.js queries are single-use objects).
|
// 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 type Range = keyof typeof RANGES;
|
||||||
export const parseRange = (value: string | null): Range =>
|
export const parseRange = (value: string | null): Range =>
|
||||||
value && value in RANGES ? (value as Range) : '30d';
|
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 = {
|
export type Listening = {
|
||||||
nowPlaying: boolean;
|
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) {
|
export async function getMusicStats(range: Range) {
|
||||||
const days = RANGES[range];
|
const [, stats, recent] = await Promise.all([
|
||||||
const since = (days ? new Date(Date.now() - days * 86_400_000) : new Date(0)).toISOString();
|
checkFacts().catch(() => {}),
|
||||||
const chartDays = days ?? 90;
|
cachedStats(range),
|
||||||
const [totals, artists, tracks, albums, daily, streak, recent, sources] = await Promise.all([
|
getRecentPlays()
|
||||||
sql`
|
]);
|
||||||
select count(*)::int as plays,
|
return { ...stats, recent };
|
||||||
coalesce(sum(${playMs()}), 0)::float8 / 3600000 as hours,
|
}
|
||||||
count(distinct p.track_id)::int as tracks,
|
|
||||||
count(distinct t.artist_id)::int as artists,
|
function cachedStats(range: Range): Promise<MusicStats> {
|
||||||
min(p.played_at) as first
|
const hit = statsCache.get(range);
|
||||||
from music.plays_deduped p join music.tracks t on t.id = p.track_id
|
if (hit && hit.version === factsVersion && Date.now() - hit.at < CACHE_MS) return hit.data;
|
||||||
where p.played_at >= ${since}::timestamptz`,
|
const data = computeStats(range);
|
||||||
sql`
|
statsCache.set(range, { at: Date.now(), version: factsVersion, data });
|
||||||
select a.id, a.name, a.spotify_id, count(*)::int as plays,
|
data.catch(() => statsCache.delete(range));
|
||||||
coalesce(sum(${playMs()}), 0)::float8 / 3600000 as hours,
|
return data;
|
||||||
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
|
/** The latest plays, live (not cached, not from play_facts). */
|
||||||
from music.plays_deduped p
|
async function getRecentPlays() {
|
||||||
join music.tracks t on t.id = p.track_id
|
const rows = await sql`
|
||||||
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,
|
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
|
coalesce(al.image_url_small, al.image_url) as image
|
||||||
from music.plays_deduped p
|
from music.plays_deduped p
|
||||||
join music.tracks t on t.id = p.track_id
|
join music.tracks t on t.id = p.track_id
|
||||||
left join music.artists a on a.id = t.artist_id
|
left join music.artists a on a.id = t.artist_id
|
||||||
left join music.albums al on al.id = t.album_id
|
left join music.albums al on al.id = t.album_id
|
||||||
order by p.played_at desc limit 50`,
|
order by p.played_at desc limit 30`;
|
||||||
sql`select source, count(*)::int as plays from music.plays_deduped group by source order by plays desc`
|
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 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 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 (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, x.plays,
|
||||||
|
coalesce(al.image_url_small, al.image_url) as image
|
||||||
|
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
|
||||||
|
order by x.plays desc, t.name`,
|
||||||
|
sql`
|
||||||
|
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
|
||||||
|
order by x.plays desc, al.name`,
|
||||||
|
sql`
|
||||||
|
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`
|
||||||
|
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 >= ${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 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
|
||||||
|
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 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 {
|
return {
|
||||||
range,
|
range,
|
||||||
|
unit,
|
||||||
|
from,
|
||||||
|
today,
|
||||||
totals: {
|
totals: {
|
||||||
plays: t.plays as number,
|
plays: t.plays as number,
|
||||||
hours: Number(t.hours),
|
hours: hoursOf(t.ms),
|
||||||
tracks: t.tracks as number,
|
tracks: t.tracks as number,
|
||||||
artists: t.artists 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) => ({
|
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,
|
name: r.name as string,
|
||||||
plays: r.plays as number,
|
plays: r.plays as number,
|
||||||
hours: Number(r.hours),
|
hours: hoursOf(r.ms),
|
||||||
image: (r.image as string) ?? null,
|
image: (r.image as string) ?? null,
|
||||||
url: r.spotify_id ? `https://open.spotify.com/artist/${r.spotify_id}` : 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) => ({
|
topTracks: tracks.map((r) => ({
|
||||||
name: r.name as string,
|
name: r.name as string,
|
||||||
artists: r.artists as string,
|
artists: r.artists as string,
|
||||||
@@ -211,14 +425,48 @@ export async function getMusicStats(range: Range) {
|
|||||||
name: r.name as string,
|
name: r.name as string,
|
||||||
artist: (r.artist as string) ?? '',
|
artist: (r.artist as string) ?? '',
|
||||||
plays: r.plays as number,
|
plays: r.plays as number,
|
||||||
|
hours: hoursOf(r.ms),
|
||||||
image: (r.image as string) ?? null,
|
image: (r.image as string) ?? null,
|
||||||
url: r.spotify_id ? `https://open.spotify.com/album/${r.spotify_id}` : null
|
url: r.spotify_id ? `https://open.spotify.com/album/${r.spotify_id}` : null
|
||||||
})),
|
})),
|
||||||
daily: daily.map((r) => ({
|
chart: chart.map((r) => ({
|
||||||
day: r.day as string,
|
bucket: r.bucket as string,
|
||||||
plays: r.plays as number,
|
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: {
|
streak: {
|
||||||
current: Number(streak[0].current),
|
current: Number(streak[0].current),
|
||||||
longest: Number(streak[0].longest),
|
longest: Number(streak[0].longest),
|
||||||
@@ -226,19 +474,19 @@ export async function getMusicStats(range: Range) {
|
|||||||
longestEnd: (streak[0].longest_end as string) ?? null,
|
longestEnd: (streak[0].longest_end as string) ?? null,
|
||||||
days: Number(streak[0].days)
|
days: Number(streak[0].days)
|
||||||
},
|
},
|
||||||
recent: recent.map((r) => ({
|
milestones: milestones.map((r) => ({
|
||||||
id: r.id as number,
|
n: r.n as number,
|
||||||
at: new Date(r.played_at).toISOString(),
|
at: new Date(r.played_at).toISOString(),
|
||||||
source: r.source as string,
|
|
||||||
track: r.track as string,
|
track: r.track as string,
|
||||||
artists: r.artists as string,
|
artist: (r.artist as string) ?? '',
|
||||||
image: (r.image as string) ?? null,
|
|
||||||
url: r.spotify_id ? `https://open.spotify.com/track/${r.spotify_id}` : 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 }))
|
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. */
|
/** Owner-only connection status for /trackers/music. Never includes tokens. */
|
||||||
export async function getSpotifyConnection() {
|
export async function getSpotifyConnection() {
|
||||||
const [row] = await sql`
|
const [row] = await sql`
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import HoursChart, { type ChartBar } from '#lib/music/HoursChart.svelte';
|
||||||
import ListeningCard from '#lib/music/ListeningCard.svelte';
|
import ListeningCard from '#lib/music/ListeningCard.svelte';
|
||||||
import { relativeTime } from '#lib/relative-time.js';
|
import { relativeTime } from '#lib/relative-time.js';
|
||||||
import Music from 'lucide-svelte/icons/music';
|
import Music from 'lucide-svelte/icons/music';
|
||||||
@@ -9,15 +10,89 @@
|
|||||||
const ranges = [
|
const ranges = [
|
||||||
['7d', '7 days'],
|
['7d', '7 days'],
|
||||||
['30d', '30 days'],
|
['30d', '30 days'],
|
||||||
|
['1y', '1 year'],
|
||||||
['all', 'all time']
|
['all', 'all time']
|
||||||
] as const;
|
] as const;
|
||||||
const maxHours = $derived(Math.max(0.01, ...s.daily.map((d) => d.hours)));
|
const periodName = $derived(
|
||||||
|
{ '7d': '7 days', '30d': '30 days', '1y': 'year', all: '' }[s.range] ?? ''
|
||||||
|
);
|
||||||
|
const unitName = $derived({ day: 'day', week: 'week', month: 'month' }[s.unit]);
|
||||||
|
const GENRE_COLORS = ['#1db954', '#3b82f6', '#f59e0b', '#ef4444', '#a855f7', '#14b8a6'];
|
||||||
|
const OTHER_COLOR = 'color-mix(in srgb, var(--site-muted) 45%, transparent)';
|
||||||
|
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
|
|
||||||
|
const nf = (n: number) => n.toLocaleString('en-GB');
|
||||||
const fmtHours = (h: number) =>
|
const fmtHours = (h: number) =>
|
||||||
h >= 10
|
h >= 10 ? nf(Math.round(h)) : h.toLocaleString('en-GB', { maximumFractionDigits: 1 });
|
||||||
? Math.round(h).toLocaleString('en-GB')
|
const date = (day: string, opts: Intl.DateTimeFormatOptions) =>
|
||||||
: h.toLocaleString('en-GB', { maximumFractionDigits: 1 });
|
new Date(`${day.slice(0, 10)}T12:00:00Z`).toLocaleDateString('en-GB', {
|
||||||
const dayLabel = (day: string) =>
|
timeZone: 'UTC',
|
||||||
new Date(`${day}T12:00:00Z`).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
...opts
|
||||||
|
});
|
||||||
|
const shortDay = (day: string) => date(day, { day: 'numeric', month: 'short' });
|
||||||
|
const bucketTitle = (bucket: string) =>
|
||||||
|
s.unit === 'month'
|
||||||
|
? date(bucket, { month: 'long', year: 'numeric' })
|
||||||
|
: s.unit === 'week'
|
||||||
|
? `week of ${date(bucket, { day: 'numeric', month: 'short', year: 'numeric' })}`
|
||||||
|
: date(bucket, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
const bucketShort = (bucket: string) =>
|
||||||
|
s.unit === 'month' ? date(bucket, { month: 'short', year: 'numeric' }) : shortDay(bucket);
|
||||||
|
const change = (now: number, before: number) => {
|
||||||
|
if (!before) return now ? 'new' : '';
|
||||||
|
const pct = Math.round(((now - before) / before) * 100);
|
||||||
|
return `${pct > 0 ? '+' : ''}${pct}%`;
|
||||||
|
};
|
||||||
|
const changeClass = (now: number, before: number) =>
|
||||||
|
now >= before ? 'text-[#1db954]' : 'text-red-500';
|
||||||
|
|
||||||
|
const hoursBars = $derived<ChartBar[]>(
|
||||||
|
s.chart.map((b) => ({
|
||||||
|
key: b.bucket,
|
||||||
|
title: bucketTitle(b.bucket),
|
||||||
|
hours: b.hours,
|
||||||
|
plays: b.plays
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const genreColor = $derived(
|
||||||
|
Object.fromEntries(s.genres.series.map((g, i) => [g, GENRE_COLORS[i % GENRE_COLORS.length]]))
|
||||||
|
);
|
||||||
|
const genreBars = $derived<ChartBar[]>(
|
||||||
|
s.genres.timeline.map((b) => ({
|
||||||
|
key: b.bucket,
|
||||||
|
title: bucketTitle(b.bucket),
|
||||||
|
hours: b.hours,
|
||||||
|
segments: s.genres.series
|
||||||
|
.filter((g) => (b.tags[g] ?? 0) > 0)
|
||||||
|
.map((g) => ({ name: g, hours: b.tags[g], color: genreColor[g] }))
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const maxGenre = $derived(Math.max(0.01, ...s.genres.top.map((g) => g.hours)));
|
||||||
|
const clockCells = $derived.by(() => {
|
||||||
|
const grid = Array.from({ length: 7 }, () =>
|
||||||
|
Array.from({ length: 24 }, () => ({ hours: 0, plays: 0 }))
|
||||||
|
);
|
||||||
|
for (const c of s.clock) grid[c.dow - 1][c.hour] = { hours: c.hours, plays: c.plays };
|
||||||
|
return grid;
|
||||||
|
});
|
||||||
|
const maxCell = $derived(Math.max(0.01, ...s.clock.map((c) => c.hours)));
|
||||||
|
const peak = $derived(s.clock.reduce((a, b) => (b.hours > (a?.hours ?? 0) ? b : a), s.clock[0]));
|
||||||
|
let clockHover = $state<{ dow: number; hour: number } | null>(null);
|
||||||
|
const clockText = $derived.by(() => {
|
||||||
|
const c = clockHover ?? (peak ? { dow: peak.dow - 1, hour: peak.hour } : null);
|
||||||
|
if (!c) return '';
|
||||||
|
const cell = clockCells[c.dow][c.hour];
|
||||||
|
const label = `${DAYS[c.dow]} ${String(c.hour).padStart(2, '0')}:00–${String((c.hour + 1) % 24).padStart(2, '0')}:00`;
|
||||||
|
return `${clockHover ? '' : 'peak: '}${label} · ${fmtHours(cell.hours)} h · ${nf(cell.plays)} plays`;
|
||||||
|
});
|
||||||
|
function pickCell(event: PointerEvent) {
|
||||||
|
const cell = (event.target as HTMLElement).dataset?.cell;
|
||||||
|
if (!cell) return;
|
||||||
|
const [dow, hour] = cell.split(',').map(Number);
|
||||||
|
clockHover = { dow, hour };
|
||||||
|
}
|
||||||
|
const maxDiscovered = $derived(Math.max(1, ...s.discoveredByYear.map((d) => d.artists)));
|
||||||
|
const perDay = $derived(s.totals.hours / Math.max(1, s.totals.spanDays));
|
||||||
const sourceLabel: Record<string, string> = {
|
const sourceLabel: Record<string, string> = {
|
||||||
spotify: 'Spotify',
|
spotify: 'Spotify',
|
||||||
lastfm: 'Last.fm',
|
lastfm: 'Last.fm',
|
||||||
@@ -25,43 +100,6 @@
|
|||||||
local: 'local files'
|
local: 'local files'
|
||||||
};
|
};
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Hours-per-day chart: a styled tooltip on hover, keyboard focus or tap (roving tabindex,
|
|
||||||
// arrow keys move between days, Escape closes).
|
|
||||||
let activeDay = $state<number | null>(null);
|
|
||||||
let focusDay = $state<number | null>(null);
|
|
||||||
// Whether the tapped bar was already open before this tap (focus opens it first).
|
|
||||||
let wasOpen = false;
|
|
||||||
let bars = $state<HTMLButtonElement[]>([]);
|
|
||||||
const tabDay = $derived(focusDay ?? s.daily.length - 1);
|
|
||||||
const tipDay = $derived(activeDay === null ? null : s.daily[activeDay]);
|
|
||||||
const fullDayLabel = (day: string) =>
|
|
||||||
new Date(`${day}T12:00:00Z`).toLocaleDateString('en-GB', {
|
|
||||||
weekday: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric'
|
|
||||||
});
|
|
||||||
const exactHours = (h: number) =>
|
|
||||||
`${h.toLocaleString('en-GB', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} h`;
|
|
||||||
const tipShift = (pos: number) => (pos < 0.15 ? '-10%' : pos > 0.85 ? '-90%' : '-50%');
|
|
||||||
function moveDay(event: KeyboardEvent, i: number) {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
activeDay = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const last = s.daily.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();
|
|
||||||
focusDay = next[event.key];
|
|
||||||
bars[focusDay]?.focus();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
@@ -69,7 +107,7 @@
|
|||||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">music</h1>
|
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">music</h1>
|
||||||
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
|
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
|
||||||
Everything I listen to, scrobbled from Spotify every minute into my own database (a
|
Everything I listen to, scrobbled from Spotify every minute into my own database (a
|
||||||
self-hosted Last.fm), plus my older Last.fm history.
|
self-hosted Last.fm), with my full Spotify history since 2018 and my Last.fm scrobbles.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -122,123 +160,85 @@
|
|||||||
|
|
||||||
{#if s.totals.plays === 0}
|
{#if s.totals.plays === 0}
|
||||||
<div
|
<div
|
||||||
class="rounded-md border border-dashed border-[var(--site-border)] p-10 text-center text-[var(--site-muted)]"
|
class="mb-10 rounded-md border border-dashed border-[var(--site-border)] p-10 text-center text-[var(--site-muted)]"
|
||||||
>
|
>
|
||||||
<Music size={28} class="mx-auto mb-3" aria-hidden="true" />
|
<Music size={28} class="mx-auto mb-3" aria-hidden="true" />
|
||||||
No plays {s.range === 'all' ? 'yet' : 'in this period'}. Scrobbling starts as soon as Spotify
|
No plays {s.range === 'all' ? 'yet' : 'in this period'}.
|
||||||
is connected.
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<dl class="mb-10 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<dl class="mb-10 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<dt>plays</dt>
|
<dt>plays</dt>
|
||||||
<dd>{s.totals.plays.toLocaleString('en-GB')}</dd>
|
<dd>
|
||||||
|
{nf(s.totals.plays)}
|
||||||
|
{#if s.previous}<span class="delta {changeClass(s.totals.plays, s.previous.plays)}"
|
||||||
|
>{change(s.totals.plays, s.previous.plays)}</span
|
||||||
|
>{/if}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<dt>hours</dt>
|
<dt>hours</dt>
|
||||||
<dd>{fmtHours(s.totals.hours)}</dd>
|
<dd>
|
||||||
|
{fmtHours(s.totals.hours)}
|
||||||
|
{#if s.previous}<span class="delta {changeClass(s.totals.hours, s.previous.hours)}"
|
||||||
|
>{change(s.totals.hours, s.previous.hours)}</span
|
||||||
|
>{/if}
|
||||||
|
<span class="sub">{fmtHours(perDay)} h a day</span>
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<dt>artists</dt>
|
<dt>artists</dt>
|
||||||
<dd>{s.totals.artists.toLocaleString('en-GB')}</dd>
|
<dd>
|
||||||
|
{nf(s.totals.artists)}
|
||||||
|
{#if s.previous}<span class="delta {changeClass(s.totals.artists, s.previous.artists)}"
|
||||||
|
>{change(s.totals.artists, s.previous.artists)}</span
|
||||||
|
>{/if}
|
||||||
|
<span class="sub">{nf(s.totals.tracks)} tracks · {nf(s.totals.albums)} albums</span>
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<dt>streak</dt>
|
<dt>streak</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{s.streak.current} day{s.streak.current === 1 ? '' : 's'}
|
{s.streak.current} day{s.streak.current === 1 ? '' : 's'}
|
||||||
<span class="block text-xs font-normal text-[var(--site-muted)]"
|
<span class="sub"
|
||||||
>longest {s.streak.longest}{#if s.streak.longestStart && s.streak.longest > 1}
|
>longest {s.streak.longest}{#if s.streak.longestStart && s.streak.longest > 1}
|
||||||
({dayLabel(s.streak.longestStart)}–{dayLabel(s.streak.longestEnd ?? '')}){/if}</span
|
({shortDay(s.streak.longestStart)}
|
||||||
|
{date(s.streak.longestStart, {
|
||||||
|
year: 'numeric'
|
||||||
|
})}–{shortDay(s.streak.longestEnd ?? '')}
|
||||||
|
{date(s.streak.longestEnd ?? s.streak.longestStart, { year: 'numeric' })}){/if}
|
||||||
|
· listened {nf(s.totals.daysListened)} of {nf(s.totals.spanDays)} days</span
|
||||||
>
|
>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="mb-12" aria-labelledby="daily-heading">
|
<section class="mb-12" aria-labelledby="chart-heading">
|
||||||
<h2 id="daily-heading" class="mb-3 text-xl font-semibold tracking-tight">
|
<h2 id="chart-heading" class="mb-3 text-xl font-semibold tracking-tight">
|
||||||
hours per day <span class="text-sm font-normal text-[var(--site-muted)]"
|
hours per {unitName}
|
||||||
>(last {s.daily.length} days)</span
|
<span class="text-sm font-normal text-[var(--site-muted)]"
|
||||||
|
>({s.range === 'all' ? 'all time' : `last ${periodName}`})</span
|
||||||
>
|
>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="relative">
|
<HoursChart
|
||||||
<div
|
bars={hoursBars}
|
||||||
class="flex h-32 items-end gap-px rounded-md border border-[var(--site-border)] p-2"
|
label="hours listened per {unitName}"
|
||||||
role="group"
|
startLabel={s.chart[0] ? bucketShort(s.chart[0].bucket) : ''}
|
||||||
aria-label="hours listened per day"
|
endLabel={s.unit === 'day' ? 'today' : s.unit === 'week' ? 'this week' : 'this month'}
|
||||||
onpointerleave={(e) => {
|
/>
|
||||||
if (e.pointerType === 'mouse') activeDay = null;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#each s.daily as d, i (d.day)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
bind:this={bars[i]}
|
|
||||||
class="group flex h-full min-w-0 flex-1 cursor-default items-end rounded-sm focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--site-text)]"
|
|
||||||
tabindex={i === tabDay ? 0 : -1}
|
|
||||||
aria-label="{fullDayLabel(d.day)}: {exactHours(d.hours)}, {d.plays} plays"
|
|
||||||
onpointerenter={(e) => {
|
|
||||||
if (e.pointerType === 'mouse') activeDay = i;
|
|
||||||
}}
|
|
||||||
onpointerdown={(e) => {
|
|
||||||
wasOpen = e.pointerType !== 'mouse' && activeDay === i;
|
|
||||||
}}
|
|
||||||
onclick={() => {
|
|
||||||
activeDay = wasOpen ? null : i;
|
|
||||||
wasOpen = false;
|
|
||||||
}}
|
|
||||||
onfocus={() => {
|
|
||||||
focusDay = i;
|
|
||||||
activeDay = i;
|
|
||||||
}}
|
|
||||||
onblur={() => {
|
|
||||||
if (activeDay === i) activeDay = null;
|
|
||||||
}}
|
|
||||||
onkeydown={(e) => moveDay(e, i)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="block w-full rounded-sm transition-colors {activeDay === i
|
|
||||||
? 'bg-[#1db954]'
|
|
||||||
: 'bg-[#1db954]/80 group-hover:bg-[#1db954]'}"
|
|
||||||
style="height: {Math.max(d.hours > 0 ? 3 : 0, (d.hours / maxHours) * 100)}%"
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#if tipDay && activeDay !== null}
|
|
||||||
{@const pos = (activeDay + 0.5) / s.daily.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({tipShift(pos)})"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<span class="block font-semibold tabular-nums">{exactHours(tipDay.hours)}</span>
|
|
||||||
<span class="block text-xs text-[var(--site-muted)]"
|
|
||||||
>{fullDayLabel(tipDay.day)} · {tipDay.plays.toLocaleString('en-GB')}
|
|
||||||
{tipDay.plays === 1 ? 'play' : 'plays'}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 flex justify-between text-xs text-[var(--site-muted)]">
|
|
||||||
<span>{dayLabel(s.daily[0]?.day ?? '')}</span><span>today</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="grid gap-10 lg:grid-cols-3">
|
<div class="grid gap-10 lg:grid-cols-2">
|
||||||
{#each [{ id: 'artists', title: 'top artists', rows: s.topArtists.map( (a) => ({ ...a, sub: `${fmtHours(a.hours)} h` }) ) }, { id: 'tracks', title: 'top tracks', rows: s.topTracks.map( (t) => ({ ...t, sub: t.artists }) ) }, { id: 'albums', title: 'top albums', rows: s.topAlbums.map( (a) => ({ ...a, sub: a.artist }) ) }] as list (list.id)}
|
<section aria-labelledby="artists-heading">
|
||||||
<section aria-labelledby="{list.id}-heading">
|
<h2 id="artists-heading" class="mb-3 text-xl font-semibold tracking-tight">top artists</h2>
|
||||||
<h2 id="{list.id}-heading" class="mb-3 text-xl font-semibold tracking-tight">
|
{#if s.topArtists.length === 0}
|
||||||
{list.title}
|
|
||||||
</h2>
|
|
||||||
{#if list.rows.length === 0}
|
|
||||||
<p class="text-sm text-[var(--site-muted)]">nothing yet</p>
|
<p class="text-sm text-[var(--site-muted)]">nothing yet</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ol class="space-y-2">
|
<ol class="space-y-2">
|
||||||
{#each list.rows as row, i (i)}
|
{#each s.topArtists as row, i (i)}
|
||||||
<li class="flex items-center gap-3">
|
<li class="flex items-center gap-3">
|
||||||
<span class="w-5 shrink-0 text-right text-sm text-[var(--site-muted)]">{i + 1}</span
|
<span class="w-5 shrink-0 text-right text-sm text-[var(--site-muted)]">{i + 1}</span>
|
||||||
>
|
|
||||||
{#if row.image}
|
{#if row.image}
|
||||||
<img
|
<img
|
||||||
src={row.image}
|
src={row.image}
|
||||||
@@ -246,9 +246,72 @@
|
|||||||
width="40"
|
width="40"
|
||||||
height="40"
|
height="40"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
class="size-10 shrink-0 rounded object-cover {list.id === 'artists'
|
class="size-10 shrink-0 rounded-full object-cover"
|
||||||
? 'rounded-full'
|
/>
|
||||||
: ''}"
|
{:else}
|
||||||
|
<span class="size-10 shrink-0 rounded-full bg-[var(--site-border)]"></span>
|
||||||
|
{/if}
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
{#if row.url}
|
||||||
|
<a
|
||||||
|
href={row.url}
|
||||||
|
rel="external"
|
||||||
|
class="block truncate font-medium hover:underline">{row.name}</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="block truncate font-medium">{row.name}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="block truncate text-sm text-[var(--site-muted)]"
|
||||||
|
>{fmtHours(row.hours)} h</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
{#if row.prevRank !== undefined}
|
||||||
|
<span
|
||||||
|
class="w-10 shrink-0 text-right text-xs {row.prevRank === null
|
||||||
|
? 'text-[#1db954]'
|
||||||
|
: row.prevRank > i + 1
|
||||||
|
? 'text-[#1db954]'
|
||||||
|
: row.prevRank < i + 1
|
||||||
|
? 'text-red-500'
|
||||||
|
: 'text-[var(--site-muted)]'}"
|
||||||
|
title={row.prevRank === null
|
||||||
|
? 'not in the previous period'
|
||||||
|
: `#${row.prevRank} in the previous period`}
|
||||||
|
>{row.prevRank === null
|
||||||
|
? 'new'
|
||||||
|
: row.prevRank === i + 1
|
||||||
|
? '='
|
||||||
|
: row.prevRank > i + 1
|
||||||
|
? `↑${row.prevRank - i - 1}`
|
||||||
|
: `↓${i + 1 - row.prevRank}`}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<span class="w-12 shrink-0 text-right text-sm text-[var(--site-muted)]"
|
||||||
|
>{nf(row.plays)}</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="tracks-heading">
|
||||||
|
<h2 id="tracks-heading" class="mb-3 text-xl font-semibold tracking-tight">top tracks</h2>
|
||||||
|
{#if s.topTracks.length === 0}
|
||||||
|
<p class="text-sm text-[var(--site-muted)]">nothing yet</p>
|
||||||
|
{:else}
|
||||||
|
<ol class="space-y-2">
|
||||||
|
{#each s.topTracks as row, i (i)}
|
||||||
|
<li class="flex items-center gap-3">
|
||||||
|
<span class="w-5 shrink-0 text-right text-sm text-[var(--site-muted)]">{i + 1}</span>
|
||||||
|
{#if row.image}
|
||||||
|
<img
|
||||||
|
src={row.image}
|
||||||
|
alt=""
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
loading="lazy"
|
||||||
|
class="size-10 shrink-0 rounded object-cover"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="size-10 shrink-0 rounded bg-[var(--site-border)]"></span>
|
<span class="size-10 shrink-0 rounded bg-[var(--site-border)]"></span>
|
||||||
@@ -263,15 +326,250 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<span class="block truncate font-medium">{row.name}</span>
|
<span class="block truncate font-medium">{row.name}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="block truncate text-sm text-[var(--site-muted)]">{row.sub}</span>
|
<span class="block truncate text-sm text-[var(--site-muted)]">{row.artists}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="shrink-0 text-sm text-[var(--site-muted)]">{row.plays}</span>
|
<span class="shrink-0 text-sm text-[var(--site-muted)]">{nf(row.plays)}</span>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="mt-12" aria-labelledby="albums-heading">
|
||||||
|
<h2 id="albums-heading" class="mb-3 text-xl font-semibold tracking-tight">top albums</h2>
|
||||||
|
{#if s.topAlbums.length === 0}
|
||||||
|
<p class="text-sm text-[var(--site-muted)]">nothing yet</p>
|
||||||
|
{:else}
|
||||||
|
<ol class="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6">
|
||||||
|
{#each s.topAlbums as album, i (i)}
|
||||||
|
<li class="min-w-0">
|
||||||
|
<a
|
||||||
|
href={album.url ?? undefined}
|
||||||
|
rel="external"
|
||||||
|
class="group block {album.url ? '' : 'pointer-events-none'}"
|
||||||
|
>
|
||||||
|
{#if album.image}
|
||||||
|
<img
|
||||||
|
src={album.image}
|
||||||
|
alt=""
|
||||||
|
width="300"
|
||||||
|
height="300"
|
||||||
|
loading="lazy"
|
||||||
|
class="aspect-square w-full rounded-md object-cover transition-transform group-hover:-translate-y-0.5"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="flex aspect-square w-full items-center justify-center rounded-md bg-[var(--site-border)]"
|
||||||
|
><Music size={24} aria-hidden="true" class="text-[var(--site-muted)]" /></span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<span class="mt-2 block truncate text-sm font-medium group-hover:underline"
|
||||||
|
>{i + 1}. {album.name}</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
<span class="block truncate text-xs text-[var(--site-muted)]"
|
||||||
|
>{album.artist} · {nf(album.plays)} plays</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-12" aria-labelledby="genres-heading">
|
||||||
|
<h2 id="genres-heading" class="mb-1 text-xl font-semibold tracking-tight">genres</h2>
|
||||||
|
<p class="mb-4 text-xs text-[var(--site-muted)]">
|
||||||
|
From Last.fm artist tags; each artist's hours are split across its top genres.
|
||||||
|
{#if s.genres.coverage < 0.98}{Math.round(s.genres.coverage * 100)}% of listening time is
|
||||||
|
tagged so far.{/if}
|
||||||
|
</p>
|
||||||
|
{#if s.genres.top.length === 0}
|
||||||
|
<p class="text-sm text-[var(--site-muted)]">no genres yet</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid gap-8 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
|
||||||
|
<ol class="space-y-1.5">
|
||||||
|
{#each s.genres.top.slice(0, 12) as g (g.tag)}
|
||||||
|
<li class="grid grid-cols-[8rem_minmax(0,1fr)_3.5rem] items-center gap-2 text-sm">
|
||||||
|
<span class="truncate">{g.tag}</span>
|
||||||
|
<span class="h-2 rounded-full bg-[var(--site-border)]">
|
||||||
|
<span
|
||||||
|
class="block h-2 rounded-full"
|
||||||
|
style="width: {(g.hours / maxGenre) * 100}%; background: {genreColor[g.tag] ??
|
||||||
|
OTHER_COLOR}"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
<span class="text-right text-xs text-[var(--site-muted)] tabular-nums"
|
||||||
|
>{fmtHours(g.hours)} h</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-2 text-sm font-medium">genres per {unitName}</h3>
|
||||||
|
<HoursChart
|
||||||
|
bars={genreBars}
|
||||||
|
label="genres per {unitName}"
|
||||||
|
baseColor={OTHER_COLOR}
|
||||||
|
startLabel={s.chart[0] ? bucketShort(s.chart[0].bucket) : ''}
|
||||||
|
endLabel={s.unit === 'day' ? 'today' : s.unit === 'week' ? 'this week' : 'this month'}
|
||||||
|
/>
|
||||||
|
<ul class="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[var(--site-muted)]">
|
||||||
|
{#each s.genres.series as g (g)}
|
||||||
|
<li class="flex items-center gap-1">
|
||||||
|
<span class="size-2 rounded-full" style="background: {genreColor[g]}"></span>{g}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
<li class="flex items-center gap-1">
|
||||||
|
<span class="size-2 rounded-full" style="background: {OTHER_COLOR}"></span>other
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-12" aria-labelledby="clock-heading">
|
||||||
|
<h2 id="clock-heading" class="mb-3 text-xl font-semibold tracking-tight">listening clock</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div
|
||||||
|
class="grid min-w-[36rem] grid-cols-[2.5rem_repeat(24,minmax(0,1fr))] gap-0.5 text-xs"
|
||||||
|
role="img"
|
||||||
|
aria-label="Listening time by weekday and hour. {clockText}"
|
||||||
|
onpointerleave={() => (clockHover = null)}
|
||||||
|
onpointerover={pickCell}
|
||||||
|
onpointerdown={pickCell}
|
||||||
|
>
|
||||||
|
{#each clockCells as row, d (d)}
|
||||||
|
<span class="self-center text-[var(--site-muted)]">{DAYS[d]}</span>
|
||||||
|
{#each row as cell, h (h)}
|
||||||
|
<span
|
||||||
|
class="aspect-square rounded-sm"
|
||||||
|
style="background: {cell.hours
|
||||||
|
? `color-mix(in srgb, #1db954 ${Math.round(15 + (cell.hours / maxCell) * 85)}%, transparent)`
|
||||||
|
: 'var(--site-border)'}"
|
||||||
|
title="{DAYS[d]} {h}:00 · {fmtHours(cell.hours)} h"
|
||||||
|
data-cell="{d},{h}"
|
||||||
|
></span>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
<span></span>
|
||||||
|
{#each Array.from({ length: 24 }, (_, h) => h) as h (h)}
|
||||||
|
<span class="text-center text-[var(--site-muted)]">{h % 3 === 0 ? h : ''}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-[var(--site-muted)]" aria-live="polite">{clockText}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="mt-12 grid gap-10 lg:grid-cols-2">
|
||||||
|
{#if s.previous}
|
||||||
|
<section aria-labelledby="compare-heading">
|
||||||
|
<h2 id="compare-heading" class="mb-3 text-xl font-semibold tracking-tight">
|
||||||
|
vs the previous {periodName}
|
||||||
|
</h2>
|
||||||
|
<dl class="mb-4 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
{#each [['plays', s.totals.plays, s.previous.plays], ['hours', s.totals.hours, s.previous.hours], ['artists', s.totals.artists, s.previous.artists], ['tracks', s.totals.tracks, s.previous.tracks]] as const as [name, cur, before] (name)}
|
||||||
|
<div class="rounded-md border border-[var(--site-border)] px-3 py-2">
|
||||||
|
<dt class="text-[var(--site-muted)]">{name}</dt>
|
||||||
|
<dd>
|
||||||
|
{name === 'hours' ? fmtHours(before) : nf(before)} → {name === 'hours'
|
||||||
|
? fmtHours(cur)
|
||||||
|
: nf(cur)}
|
||||||
|
<span class={changeClass(cur, before)}>{change(cur, before)}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
{#if s.movers.length}
|
||||||
|
<h3 class="mb-2 text-sm font-medium">top movers</h3>
|
||||||
|
<ol class="space-y-1 text-sm">
|
||||||
|
{#each s.movers as m (m.id)}
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="min-w-0 flex-1 truncate">{m.name}</span>
|
||||||
|
<span class="text-[var(--site-muted)]"
|
||||||
|
>{m.prevRank ? `#${m.prevRank}` : 'new'} → #{m.rank}</span
|
||||||
|
>
|
||||||
|
<span class="w-16 text-right text-[#1db954]">+{nf(m.plays - m.prevPlays)}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="new-heading">
|
||||||
|
<h2 id="new-heading" class="mb-3 text-xl font-semibold tracking-tight">
|
||||||
|
new artists <span class="text-sm font-normal text-[var(--site-muted)]"
|
||||||
|
>({nf(s.newArtists.total)} first heard in the last {periodName})</span
|
||||||
|
>
|
||||||
|
</h2>
|
||||||
|
{#if s.newArtists.top.length === 0}
|
||||||
|
<p class="text-sm text-[var(--site-muted)]">no new artists</p>
|
||||||
|
{:else}
|
||||||
|
<ol class="space-y-1 text-sm">
|
||||||
|
{#each s.newArtists.top as a (a.name)}
|
||||||
|
<li class="flex gap-3">
|
||||||
|
{#if a.url}
|
||||||
|
<a href={a.url} rel="external" class="min-w-0 flex-1 truncate hover:underline"
|
||||||
|
>{a.name}</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="min-w-0 flex-1 truncate">{a.name}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-[var(--site-muted)]">since {shortDay(a.first)}</span>
|
||||||
|
<span class="w-16 text-right text-[var(--site-muted)]">{nf(a.plays)} plays</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<section aria-labelledby="discovered-heading">
|
||||||
|
<h2 id="discovered-heading" class="mb-3 text-xl font-semibold tracking-tight">
|
||||||
|
new artists per year
|
||||||
|
</h2>
|
||||||
|
<ol class="space-y-1.5">
|
||||||
|
{#each s.discoveredByYear as d (d.year)}
|
||||||
|
<li class="grid grid-cols-[3rem_minmax(0,1fr)_3.5rem] items-center gap-2 text-sm">
|
||||||
|
<span class="text-[var(--site-muted)]">{d.year}</span>
|
||||||
|
<span class="h-2 rounded-full bg-[var(--site-border)]">
|
||||||
|
<span
|
||||||
|
class="block h-2 rounded-full bg-[#1db954]"
|
||||||
|
style="width: {(d.artists / maxDiscovered) * 100}%"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
<span class="text-right text-xs text-[var(--site-muted)] tabular-nums"
|
||||||
|
>{nf(d.artists)}</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if s.range === 'all' || !s.previous}
|
||||||
|
<section aria-labelledby="milestones-heading">
|
||||||
|
<h2 id="milestones-heading" class="mb-3 text-xl font-semibold tracking-tight">
|
||||||
|
milestones
|
||||||
|
</h2>
|
||||||
|
<ol class="space-y-1.5 text-sm">
|
||||||
|
{#each s.milestones as m (m.n)}
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="w-20 shrink-0 text-[var(--site-muted)] tabular-nums"
|
||||||
|
>{m.n === 1 ? 'first' : `#${nf(m.n)}`}</span
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate">
|
||||||
|
{#if m.url}<a href={m.url} rel="external" class="hover:underline">{m.track}</a
|
||||||
|
>{:else}{m.track}{/if}
|
||||||
|
<span class="text-[var(--site-muted)]">· {m.artist}</span>
|
||||||
|
</span>
|
||||||
|
<time datetime={m.at} class="shrink-0 text-[var(--site-muted)]"
|
||||||
|
>{date(m.at, { day: 'numeric', month: 'short', year: 'numeric' })}</time
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="mt-12" aria-labelledby="recent-heading">
|
<section class="mt-12" aria-labelledby="recent-heading">
|
||||||
@@ -313,8 +611,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if s.sources.length > 1}
|
{#if s.sources.length > 1}
|
||||||
<p class="mt-4 text-xs text-[var(--site-muted)]">
|
<p class="mt-4 text-xs text-[var(--site-muted)]">
|
||||||
sources: {s.sources
|
sources{s.range === 'all' ? '' : ` (last ${periodName})`}: {s.sources
|
||||||
.map((x) => `${sourceLabel[x.source] ?? x.source} ${x.plays.toLocaleString('en-GB')}`)
|
.map((x) => `${sourceLabel[x.source] ?? x.source} ${nf(x.plays)}`)
|
||||||
.join(' · ')}
|
.join(' · ')}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -335,4 +633,15 @@
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
.stat .delta {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.stat .sub {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user