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
160 lines
8.1 KiB
SQL
160 lines
8.1 KiB
SQL
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 $$;
|