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