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
Reference in New Issue
Block a user