music: Last.fm-style stats with 7d/30d/1y/all ranges and genres
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:
2026-10-25 14:03:39 +00:00
parent 5e4cd83748
commit 516bc15545
9 changed files with 27811 additions and 229 deletions

View 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 $$;

File diff suppressed because it is too large Load Diff