music: self-hosted scrobbler (Spotify poller, Last.fm + streaming-history importers, /trackers/music, homepage listening card)
All checks were successful
Deploy production / deploy (push) Successful in 2m37s
All checks were successful
Deploy production / deploy (push) Successful in 2m37s
- music schema: artists/albums/tracks (+album art), plays with source + dedupe, now_playing, spotify_auth, import_state; plays_deduped view hides Last.fm scrobbles Spotify also has - in-app Spotify poller (60s, leased) started from hooks init; no new role or env needed - /api/spotify/connect + callback (owner only), /trackers/music public stats - scripts: music-poll, music-import-lastfm (resumable), music-import-history - deploy/music-collector: optional host timer with its own role
This commit is contained in:
159
drizzle/20261024155715_music/migration.sql
Normal file
159
drizzle/20261024155715_music/migration.sql
Normal file
@@ -0,0 +1,159 @@
|
||||
CREATE SCHEMA "music";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music"."albums" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"spotify_id" text,
|
||||
"name" text NOT NULL,
|
||||
"artist_id" integer,
|
||||
"image_url" text,
|
||||
"image_url_small" text,
|
||||
"release_date" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music"."artists" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"spotify_id" text,
|
||||
"name" text NOT NULL,
|
||||
"image_url" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music"."import_state" (
|
||||
"key" text PRIMARY KEY,
|
||||
"data" jsonb NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music"."now_playing" (
|
||||
"id" smallint PRIMARY KEY DEFAULT 1,
|
||||
"track_id" integer,
|
||||
"is_playing" boolean DEFAULT false NOT NULL,
|
||||
"progress_ms" integer,
|
||||
"device_name" text,
|
||||
"device_type" text,
|
||||
"context_uri" text,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "now_playing_single_row" CHECK ("id" = 1)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music"."plays" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"track_id" integer NOT NULL,
|
||||
"played_at" timestamp with time zone NOT NULL,
|
||||
"source" text DEFAULT 'spotify' NOT NULL,
|
||||
"origin" text DEFAULT 'api' NOT NULL,
|
||||
"ms_played" integer,
|
||||
"context_type" text,
|
||||
"context_uri" text,
|
||||
"platform" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "plays_source_check" CHECK ("source" in ('spotify', 'lastfm', 'youtube', 'local')),
|
||||
CONSTRAINT "plays_origin_check" CHECK ("origin" in ('api', 'export', 'import', 'manual'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music"."spotify_auth" (
|
||||
"id" smallint PRIMARY KEY DEFAULT 1,
|
||||
"spotify_user_id" text NOT NULL,
|
||||
"display_name" text,
|
||||
"refresh_token" text NOT NULL,
|
||||
"access_token" text,
|
||||
"access_expires_at" timestamp with time zone,
|
||||
"scope" text,
|
||||
"recent_cursor" bigint,
|
||||
"needs_reauth" boolean DEFAULT false NOT NULL,
|
||||
"lease_until" timestamp with time zone,
|
||||
"last_poll_at" timestamp with time zone,
|
||||
"last_error" text,
|
||||
"connected_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "spotify_auth_single_row" CHECK ("id" = 1)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music"."track_artists" (
|
||||
"track_id" integer,
|
||||
"artist_id" integer,
|
||||
"position" smallint DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT "track_artists_pkey" PRIMARY KEY("track_id","artist_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music"."tracks" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"spotify_id" text,
|
||||
"name" text NOT NULL,
|
||||
"album_id" integer,
|
||||
"artist_id" integer,
|
||||
"duration_ms" integer,
|
||||
"explicit" boolean,
|
||||
"match_key" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "albums_spotify_id_key" ON "music"."albums" ("spotify_id") WHERE "spotify_id" is not null;--> statement-breakpoint
|
||||
CREATE INDEX "albums_name_idx" ON "music"."albums" (lower("name"));--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "artists_spotify_id_key" ON "music"."artists" ("spotify_id") WHERE "spotify_id" is not null;--> statement-breakpoint
|
||||
CREATE INDEX "artists_name_idx" ON "music"."artists" (lower("name"));--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plays_dedupe_key" ON "music"."plays" ("source","track_id","played_at");--> statement-breakpoint
|
||||
CREATE INDEX "plays_played_at_idx" ON "music"."plays" ("played_at");--> statement-breakpoint
|
||||
CREATE INDEX "plays_source_played_idx" ON "music"."plays" ("source","played_at");--> statement-breakpoint
|
||||
CREATE INDEX "plays_track_played_idx" ON "music"."plays" ("track_id","played_at");--> statement-breakpoint
|
||||
CREATE INDEX "track_artists_artist_idx" ON "music"."track_artists" ("artist_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "tracks_spotify_id_key" ON "music"."tracks" ("spotify_id") WHERE "spotify_id" is not null;--> statement-breakpoint
|
||||
CREATE INDEX "tracks_name_idx" ON "music"."tracks" (lower("name"));--> statement-breakpoint
|
||||
CREATE INDEX "tracks_match_key_idx" ON "music"."tracks" ("match_key");--> statement-breakpoint
|
||||
ALTER TABLE "music"."albums" ADD CONSTRAINT "albums_artist_id_artists_id_fkey" FOREIGN KEY ("artist_id") REFERENCES "music"."artists"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "music"."now_playing" ADD CONSTRAINT "now_playing_track_id_tracks_id_fkey" FOREIGN KEY ("track_id") REFERENCES "music"."tracks"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "music"."plays" ADD CONSTRAINT "plays_track_id_tracks_id_fkey" FOREIGN KEY ("track_id") REFERENCES "music"."tracks"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "music"."track_artists" ADD CONSTRAINT "track_artists_track_id_tracks_id_fkey" FOREIGN KEY ("track_id") REFERENCES "music"."tracks"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "music"."track_artists" ADD CONSTRAINT "track_artists_artist_id_artists_id_fkey" FOREIGN KEY ("artist_id") REFERENCES "music"."artists"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "music"."tracks" ADD CONSTRAINT "tracks_album_id_albums_id_fkey" FOREIGN KEY ("album_id") REFERENCES "music"."albums"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "music"."tracks" ADD CONSTRAINT "tracks_artist_id_artists_id_fkey" FOREIGN KEY ("artist_id") REFERENCES "music"."artists"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
-- Hand-written below. Cross-source dedupe: every stat reads plays_deduped, which hides a
|
||||
-- Last.fm scrobble when Spotify (live API or the streaming-history export) has the same
|
||||
-- normalised artist+title around the same time. Last.fm stores when a track started,
|
||||
-- Spotify when it ended, so the Spotify end may fall from 3 min before the scrobble to
|
||||
-- track length + 3 min after it. Spotify rows always win; Last.fm fills the gaps.
|
||||
CREATE VIEW "music"."plays_deduped" AS
|
||||
SELECT p.* FROM "music"."plays" p
|
||||
WHERE p.source <> 'lastfm' OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "music"."tracks" lt, "music"."plays" s
|
||||
JOIN "music"."tracks" st ON st.id = s.track_id
|
||||
WHERE lt.id = p.track_id
|
||||
AND s.source = 'spotify'
|
||||
AND s.played_at >= p.played_at - interval '3 minutes'
|
||||
AND s.played_at <= p.played_at + interval '25 minutes'
|
||||
AND s.played_at <= p.played_at + make_interval(secs => coalesce(st.duration_ms, 600000) / 1000.0) + interval '3 minutes'
|
||||
AND st.match_key = lt.match_key
|
||||
);
|
||||
--> statement-breakpoint
|
||||
-- Grants only run on prod, where migrations run as `personal`. Listening history is public
|
||||
-- (personal_ro may read it); the Spotify refresh token is for the app role only.
|
||||
REVOKE ALL ON SCHEMA music FROM PUBLIC;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA music TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA music TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA music TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA music
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA music
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
GRANT USAGE ON SCHEMA music TO personal_ro;
|
||||
GRANT SELECT ON music.artists, music.albums, music.tracks, music.track_artists, music.plays,
|
||||
music.plays_deduped, music.now_playing TO personal_ro;
|
||||
END IF;
|
||||
-- Optional host-side poller (deploy/music-collector). The role is created out of band
|
||||
-- (it has a password); see deploy/music-collector/README.md.
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'music_collector') THEN
|
||||
GRANT USAGE ON SCHEMA music TO music_collector;
|
||||
GRANT SELECT, INSERT, UPDATE ON music.artists, music.albums, music.tracks, music.track_artists,
|
||||
music.plays, music.now_playing, music.import_state TO music_collector;
|
||||
GRANT SELECT, UPDATE ON music.spotify_auth TO music_collector;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA music TO music_collector;
|
||||
END IF;
|
||||
END $$;
|
||||
19833
drizzle/20261024155715_music/snapshot.json
Normal file
19833
drizzle/20261024155715_music/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user