Add API and worker: accounts, follows, now-playing, cross-service links
- Fastify API with cookie (web) and bearer (mobile) sessions, follows, pinned friends, featured songs, account deletion, Redis-backed rate limits - Providers: Spotify (OAuth), Last.fm, ListenBrainz, Plex (PIN flow and server discovery), Jellyfin (token auth, revoked on disconnect) - Worker polls on an adaptive schedule in a Redis sorted set, so polling scales horizontally and API usage tracks whether anyone is watching - Track resolver links songs across services via ISRC (Deezer, Spotify), iTunes search and song.link - Realtime updates over WebSocket fan out through Redis pub/sub - User-supplied server URLs are restricted to public addresses, checked both up front and at connect time to defeat DNS rebinding Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
DATABASE_URL=postgres://musicshare:musicshare@localhost:5447/musicshare
|
||||||
|
REDIS_URL=redis://localhost:6380
|
||||||
|
PORT=4000
|
||||||
|
# Where the web app lives (CORS + post-OAuth redirects)
|
||||||
|
WEB_URL=http://localhost:3737
|
||||||
|
# Public base URL of this API, used for OAuth redirect URIs.
|
||||||
|
# Spotify requires a loopback IP (not "localhost") for http redirect URIs.
|
||||||
|
API_PUBLIC_URL=http://127.0.0.1:4000
|
||||||
|
COOKIE_SECURE=false
|
||||||
|
# 32 random bytes, base64. Generate with: openssl rand -base64 32
|
||||||
|
TOKEN_ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# https://developer.spotify.com/dashboard (redirect URI: $API_PUBLIC_URL/connections/spotify/callback)
|
||||||
|
SPOTIFY_CLIENT_ID=
|
||||||
|
SPOTIFY_CLIENT_SECRET=
|
||||||
|
# https://www.last.fm/api/account/create
|
||||||
|
LASTFM_API_KEY=
|
||||||
|
# song.link API key (request at https://odesli.co). Enables YouTube Music/TIDAL links; without it those fall back to search.
|
||||||
|
ODESLI_API_KEY=
|
||||||
|
# Stable identifier for this app in Plex's auth flow
|
||||||
|
PLEX_CLIENT_ID=musicshare-dev
|
||||||
|
# Let users connect Jellyfin/Plex servers on private networks (LAN). Never enable in a hosted deployment.
|
||||||
|
ALLOW_PRIVATE_SERVER_URLS=false
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: 'postgresql',
|
||||||
|
schema: './src/db/schema.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
dbCredentials: { url: process.env.DATABASE_URL ?? 'postgres://musicshare:musicshare@localhost:5447/musicshare' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
CREATE TABLE "connections" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"provider" text NOT NULL,
|
||||||
|
"external_user_id" text NOT NULL,
|
||||||
|
"display_name" text,
|
||||||
|
"access_token_enc" text,
|
||||||
|
"refresh_token_enc" text,
|
||||||
|
"token_expires_at" timestamp with time zone,
|
||||||
|
"status" text DEFAULT 'active' NOT NULL,
|
||||||
|
"last_error" text,
|
||||||
|
"last_polled_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "featured_songs" (
|
||||||
|
"user_id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"track_id" uuid NOT NULL,
|
||||||
|
"comment" varchar(280),
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "follows" (
|
||||||
|
"follower_id" uuid NOT NULL,
|
||||||
|
"followee_id" uuid NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "follows_follower_id_followee_id_pk" PRIMARY KEY("follower_id","followee_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "now_playing" (
|
||||||
|
"user_id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"track_id" uuid NOT NULL,
|
||||||
|
"source" text NOT NULL,
|
||||||
|
"is_playing" boolean NOT NULL,
|
||||||
|
"progress_ms" integer,
|
||||||
|
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "sessions" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "track_links" (
|
||||||
|
"track_id" uuid NOT NULL,
|
||||||
|
"service" text NOT NULL,
|
||||||
|
"external_id" text,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"match_type" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "track_links_track_id_service_pk" PRIMARY KEY("track_id","service")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "tracks" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"isrc" varchar(12),
|
||||||
|
"match_key" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"artist" text NOT NULL,
|
||||||
|
"album" text,
|
||||||
|
"duration_ms" integer,
|
||||||
|
"artwork_url" text,
|
||||||
|
"links_resolved_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"username" varchar(32) NOT NULL,
|
||||||
|
"email" varchar(320) NOT NULL,
|
||||||
|
"password_hash" text NOT NULL,
|
||||||
|
"display_name" varchar(64),
|
||||||
|
"bio" varchar(280),
|
||||||
|
"avatar_url" varchar(500),
|
||||||
|
"preferred_service" text,
|
||||||
|
"plan" text DEFAULT 'free' NOT NULL,
|
||||||
|
"plan_expires_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "users_username_unique" UNIQUE("username"),
|
||||||
|
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "connections" ADD CONSTRAINT "connections_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "featured_songs" ADD CONSTRAINT "featured_songs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "featured_songs" ADD CONSTRAINT "featured_songs_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "follows" ADD CONSTRAINT "follows_follower_id_users_id_fk" FOREIGN KEY ("follower_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "follows" ADD CONSTRAINT "follows_followee_id_users_id_fk" FOREIGN KEY ("followee_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "now_playing" ADD CONSTRAINT "now_playing_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "now_playing" ADD CONSTRAINT "now_playing_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "track_links" ADD CONSTRAINT "track_links_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "connections_user_provider_idx" ON "connections" USING btree ("user_id","provider");--> statement-breakpoint
|
||||||
|
CREATE INDEX "follows_followee_idx" ON "follows" USING btree ("followee_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "now_playing_track_idx" ON "now_playing" USING btree ("track_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "track_links_external_idx" ON "track_links" USING btree ("service","external_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "tracks_isrc_idx" ON "tracks" USING btree ("isrc");--> statement-breakpoint
|
||||||
|
CREATE INDEX "tracks_match_key_idx" ON "tracks" USING btree ("match_key");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "connections" ADD COLUMN "meta" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "connections" ADD COLUMN "webhook_secret" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "connections" ADD CONSTRAINT "connections_webhook_secret_unique" UNIQUE("webhook_secret");
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "follows" ADD COLUMN "pinned_at" timestamp with time zone;
|
||||||
@@ -0,0 +1,756 @@
|
|||||||
|
{
|
||||||
|
"id": "d3534337-4881-4282-b693-d2f1b5aec269",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.connections": {
|
||||||
|
"name": "connections",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"name": "provider",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"external_user_id": {
|
||||||
|
"name": "external_user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"name": "display_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_enc": {
|
||||||
|
"name": "access_token_enc",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_enc": {
|
||||||
|
"name": "refresh_token_enc",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"token_expires_at": {
|
||||||
|
"name": "token_expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'active'"
|
||||||
|
},
|
||||||
|
"last_error": {
|
||||||
|
"name": "last_error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"last_polled_at": {
|
||||||
|
"name": "last_polled_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"connections_user_provider_idx": {
|
||||||
|
"name": "connections_user_provider_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "provider",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"connections_user_id_users_id_fk": {
|
||||||
|
"name": "connections_user_id_users_id_fk",
|
||||||
|
"tableFrom": "connections",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.featured_songs": {
|
||||||
|
"name": "featured_songs",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"track_id": {
|
||||||
|
"name": "track_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"name": "comment",
|
||||||
|
"type": "varchar(280)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"featured_songs_user_id_users_id_fk": {
|
||||||
|
"name": "featured_songs_user_id_users_id_fk",
|
||||||
|
"tableFrom": "featured_songs",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"featured_songs_track_id_tracks_id_fk": {
|
||||||
|
"name": "featured_songs_track_id_tracks_id_fk",
|
||||||
|
"tableFrom": "featured_songs",
|
||||||
|
"tableTo": "tracks",
|
||||||
|
"columnsFrom": [
|
||||||
|
"track_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.follows": {
|
||||||
|
"name": "follows",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"follower_id": {
|
||||||
|
"name": "follower_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"followee_id": {
|
||||||
|
"name": "followee_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"follows_followee_idx": {
|
||||||
|
"name": "follows_followee_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "followee_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"follows_follower_id_users_id_fk": {
|
||||||
|
"name": "follows_follower_id_users_id_fk",
|
||||||
|
"tableFrom": "follows",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"follower_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"follows_followee_id_users_id_fk": {
|
||||||
|
"name": "follows_followee_id_users_id_fk",
|
||||||
|
"tableFrom": "follows",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"followee_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"follows_follower_id_followee_id_pk": {
|
||||||
|
"name": "follows_follower_id_followee_id_pk",
|
||||||
|
"columns": [
|
||||||
|
"follower_id",
|
||||||
|
"followee_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.now_playing": {
|
||||||
|
"name": "now_playing",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"track_id": {
|
||||||
|
"name": "track_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"is_playing": {
|
||||||
|
"name": "is_playing",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"progress_ms": {
|
||||||
|
"name": "progress_ms",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"now_playing_track_idx": {
|
||||||
|
"name": "now_playing_track_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "track_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"now_playing_user_id_users_id_fk": {
|
||||||
|
"name": "now_playing_user_id_users_id_fk",
|
||||||
|
"tableFrom": "now_playing",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"now_playing_track_id_tracks_id_fk": {
|
||||||
|
"name": "now_playing_track_id_tracks_id_fk",
|
||||||
|
"tableFrom": "now_playing",
|
||||||
|
"tableTo": "tracks",
|
||||||
|
"columnsFrom": [
|
||||||
|
"track_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"sessions_user_idx": {
|
||||||
|
"name": "sessions_user_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.track_links": {
|
||||||
|
"name": "track_links",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"track_id": {
|
||||||
|
"name": "track_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"name": "service",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"external_id": {
|
||||||
|
"name": "external_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"match_type": {
|
||||||
|
"name": "match_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"track_links_external_idx": {
|
||||||
|
"name": "track_links_external_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "service",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "external_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"track_links_track_id_tracks_id_fk": {
|
||||||
|
"name": "track_links_track_id_tracks_id_fk",
|
||||||
|
"tableFrom": "track_links",
|
||||||
|
"tableTo": "tracks",
|
||||||
|
"columnsFrom": [
|
||||||
|
"track_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"track_links_track_id_service_pk": {
|
||||||
|
"name": "track_links_track_id_service_pk",
|
||||||
|
"columns": [
|
||||||
|
"track_id",
|
||||||
|
"service"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.tracks": {
|
||||||
|
"name": "tracks",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"isrc": {
|
||||||
|
"name": "isrc",
|
||||||
|
"type": "varchar(12)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"match_key": {
|
||||||
|
"name": "match_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"artist": {
|
||||||
|
"name": "artist",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"album": {
|
||||||
|
"name": "album",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"duration_ms": {
|
||||||
|
"name": "duration_ms",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"artwork_url": {
|
||||||
|
"name": "artwork_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"links_resolved_at": {
|
||||||
|
"name": "links_resolved_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"tracks_isrc_idx": {
|
||||||
|
"name": "tracks_isrc_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "isrc",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"tracks_match_key_idx": {
|
||||||
|
"name": "tracks_match_key_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "match_key",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "varchar(32)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(320)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"name": "display_name",
|
||||||
|
"type": "varchar(64)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"bio": {
|
||||||
|
"name": "bio",
|
||||||
|
"type": "varchar(280)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"avatar_url": {
|
||||||
|
"name": "avatar_url",
|
||||||
|
"type": "varchar(500)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"preferred_service": {
|
||||||
|
"name": "preferred_service",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"plan": {
|
||||||
|
"name": "plan",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'free'"
|
||||||
|
},
|
||||||
|
"plan_expires_at": {
|
||||||
|
"name": "plan_expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,777 @@
|
|||||||
|
{
|
||||||
|
"id": "9287e008-3368-4f0c-bf3c-651bf14a1930",
|
||||||
|
"prevId": "d3534337-4881-4282-b693-d2f1b5aec269",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.connections": {
|
||||||
|
"name": "connections",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"name": "provider",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"external_user_id": {
|
||||||
|
"name": "external_user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"name": "display_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_enc": {
|
||||||
|
"name": "access_token_enc",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_enc": {
|
||||||
|
"name": "refresh_token_enc",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"token_expires_at": {
|
||||||
|
"name": "token_expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'active'"
|
||||||
|
},
|
||||||
|
"last_error": {
|
||||||
|
"name": "last_error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"last_polled_at": {
|
||||||
|
"name": "last_polled_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"name": "meta",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"webhook_secret": {
|
||||||
|
"name": "webhook_secret",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"connections_user_provider_idx": {
|
||||||
|
"name": "connections_user_provider_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "provider",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"connections_user_id_users_id_fk": {
|
||||||
|
"name": "connections_user_id_users_id_fk",
|
||||||
|
"tableFrom": "connections",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"connections_webhook_secret_unique": {
|
||||||
|
"name": "connections_webhook_secret_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"webhook_secret"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.featured_songs": {
|
||||||
|
"name": "featured_songs",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"track_id": {
|
||||||
|
"name": "track_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"name": "comment",
|
||||||
|
"type": "varchar(280)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"featured_songs_user_id_users_id_fk": {
|
||||||
|
"name": "featured_songs_user_id_users_id_fk",
|
||||||
|
"tableFrom": "featured_songs",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"featured_songs_track_id_tracks_id_fk": {
|
||||||
|
"name": "featured_songs_track_id_tracks_id_fk",
|
||||||
|
"tableFrom": "featured_songs",
|
||||||
|
"tableTo": "tracks",
|
||||||
|
"columnsFrom": [
|
||||||
|
"track_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.follows": {
|
||||||
|
"name": "follows",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"follower_id": {
|
||||||
|
"name": "follower_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"followee_id": {
|
||||||
|
"name": "followee_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"follows_followee_idx": {
|
||||||
|
"name": "follows_followee_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "followee_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"follows_follower_id_users_id_fk": {
|
||||||
|
"name": "follows_follower_id_users_id_fk",
|
||||||
|
"tableFrom": "follows",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"follower_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"follows_followee_id_users_id_fk": {
|
||||||
|
"name": "follows_followee_id_users_id_fk",
|
||||||
|
"tableFrom": "follows",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"followee_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"follows_follower_id_followee_id_pk": {
|
||||||
|
"name": "follows_follower_id_followee_id_pk",
|
||||||
|
"columns": [
|
||||||
|
"follower_id",
|
||||||
|
"followee_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.now_playing": {
|
||||||
|
"name": "now_playing",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"track_id": {
|
||||||
|
"name": "track_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"is_playing": {
|
||||||
|
"name": "is_playing",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"progress_ms": {
|
||||||
|
"name": "progress_ms",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"now_playing_track_idx": {
|
||||||
|
"name": "now_playing_track_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "track_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"now_playing_user_id_users_id_fk": {
|
||||||
|
"name": "now_playing_user_id_users_id_fk",
|
||||||
|
"tableFrom": "now_playing",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"now_playing_track_id_tracks_id_fk": {
|
||||||
|
"name": "now_playing_track_id_tracks_id_fk",
|
||||||
|
"tableFrom": "now_playing",
|
||||||
|
"tableTo": "tracks",
|
||||||
|
"columnsFrom": [
|
||||||
|
"track_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"sessions_user_idx": {
|
||||||
|
"name": "sessions_user_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.track_links": {
|
||||||
|
"name": "track_links",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"track_id": {
|
||||||
|
"name": "track_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"name": "service",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"external_id": {
|
||||||
|
"name": "external_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"match_type": {
|
||||||
|
"name": "match_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"track_links_external_idx": {
|
||||||
|
"name": "track_links_external_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "service",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "external_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"track_links_track_id_tracks_id_fk": {
|
||||||
|
"name": "track_links_track_id_tracks_id_fk",
|
||||||
|
"tableFrom": "track_links",
|
||||||
|
"tableTo": "tracks",
|
||||||
|
"columnsFrom": [
|
||||||
|
"track_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"track_links_track_id_service_pk": {
|
||||||
|
"name": "track_links_track_id_service_pk",
|
||||||
|
"columns": [
|
||||||
|
"track_id",
|
||||||
|
"service"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.tracks": {
|
||||||
|
"name": "tracks",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"isrc": {
|
||||||
|
"name": "isrc",
|
||||||
|
"type": "varchar(12)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"match_key": {
|
||||||
|
"name": "match_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"artist": {
|
||||||
|
"name": "artist",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"album": {
|
||||||
|
"name": "album",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"duration_ms": {
|
||||||
|
"name": "duration_ms",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"artwork_url": {
|
||||||
|
"name": "artwork_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"links_resolved_at": {
|
||||||
|
"name": "links_resolved_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"tracks_isrc_idx": {
|
||||||
|
"name": "tracks_isrc_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "isrc",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"tracks_match_key_idx": {
|
||||||
|
"name": "tracks_match_key_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "match_key",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "varchar(32)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(320)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"name": "display_name",
|
||||||
|
"type": "varchar(64)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"bio": {
|
||||||
|
"name": "bio",
|
||||||
|
"type": "varchar(280)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"avatar_url": {
|
||||||
|
"name": "avatar_url",
|
||||||
|
"type": "varchar(500)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"preferred_service": {
|
||||||
|
"name": "preferred_service",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"plan": {
|
||||||
|
"name": "plan",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'free'"
|
||||||
|
},
|
||||||
|
"plan_expires_at": {
|
||||||
|
"name": "plan_expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,783 @@
|
|||||||
|
{
|
||||||
|
"id": "56c8e712-1bb1-48c0-b84d-e8cbaa6f1dc9",
|
||||||
|
"prevId": "9287e008-3368-4f0c-bf3c-651bf14a1930",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.connections": {
|
||||||
|
"name": "connections",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"name": "provider",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"external_user_id": {
|
||||||
|
"name": "external_user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"name": "display_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_enc": {
|
||||||
|
"name": "access_token_enc",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_enc": {
|
||||||
|
"name": "refresh_token_enc",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"token_expires_at": {
|
||||||
|
"name": "token_expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'active'"
|
||||||
|
},
|
||||||
|
"last_error": {
|
||||||
|
"name": "last_error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"last_polled_at": {
|
||||||
|
"name": "last_polled_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"name": "meta",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"webhook_secret": {
|
||||||
|
"name": "webhook_secret",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"connections_user_provider_idx": {
|
||||||
|
"name": "connections_user_provider_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "provider",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"connections_user_id_users_id_fk": {
|
||||||
|
"name": "connections_user_id_users_id_fk",
|
||||||
|
"tableFrom": "connections",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"connections_webhook_secret_unique": {
|
||||||
|
"name": "connections_webhook_secret_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"webhook_secret"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.featured_songs": {
|
||||||
|
"name": "featured_songs",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"track_id": {
|
||||||
|
"name": "track_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"name": "comment",
|
||||||
|
"type": "varchar(280)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"featured_songs_user_id_users_id_fk": {
|
||||||
|
"name": "featured_songs_user_id_users_id_fk",
|
||||||
|
"tableFrom": "featured_songs",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"featured_songs_track_id_tracks_id_fk": {
|
||||||
|
"name": "featured_songs_track_id_tracks_id_fk",
|
||||||
|
"tableFrom": "featured_songs",
|
||||||
|
"tableTo": "tracks",
|
||||||
|
"columnsFrom": [
|
||||||
|
"track_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.follows": {
|
||||||
|
"name": "follows",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"follower_id": {
|
||||||
|
"name": "follower_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"followee_id": {
|
||||||
|
"name": "followee_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"pinned_at": {
|
||||||
|
"name": "pinned_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"follows_followee_idx": {
|
||||||
|
"name": "follows_followee_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "followee_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"follows_follower_id_users_id_fk": {
|
||||||
|
"name": "follows_follower_id_users_id_fk",
|
||||||
|
"tableFrom": "follows",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"follower_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"follows_followee_id_users_id_fk": {
|
||||||
|
"name": "follows_followee_id_users_id_fk",
|
||||||
|
"tableFrom": "follows",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"followee_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"follows_follower_id_followee_id_pk": {
|
||||||
|
"name": "follows_follower_id_followee_id_pk",
|
||||||
|
"columns": [
|
||||||
|
"follower_id",
|
||||||
|
"followee_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.now_playing": {
|
||||||
|
"name": "now_playing",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"track_id": {
|
||||||
|
"name": "track_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"is_playing": {
|
||||||
|
"name": "is_playing",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"progress_ms": {
|
||||||
|
"name": "progress_ms",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"now_playing_track_idx": {
|
||||||
|
"name": "now_playing_track_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "track_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"now_playing_user_id_users_id_fk": {
|
||||||
|
"name": "now_playing_user_id_users_id_fk",
|
||||||
|
"tableFrom": "now_playing",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"now_playing_track_id_tracks_id_fk": {
|
||||||
|
"name": "now_playing_track_id_tracks_id_fk",
|
||||||
|
"tableFrom": "now_playing",
|
||||||
|
"tableTo": "tracks",
|
||||||
|
"columnsFrom": [
|
||||||
|
"track_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"sessions_user_idx": {
|
||||||
|
"name": "sessions_user_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.track_links": {
|
||||||
|
"name": "track_links",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"track_id": {
|
||||||
|
"name": "track_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"name": "service",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"external_id": {
|
||||||
|
"name": "external_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"match_type": {
|
||||||
|
"name": "match_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"track_links_external_idx": {
|
||||||
|
"name": "track_links_external_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "service",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "external_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"track_links_track_id_tracks_id_fk": {
|
||||||
|
"name": "track_links_track_id_tracks_id_fk",
|
||||||
|
"tableFrom": "track_links",
|
||||||
|
"tableTo": "tracks",
|
||||||
|
"columnsFrom": [
|
||||||
|
"track_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"track_links_track_id_service_pk": {
|
||||||
|
"name": "track_links_track_id_service_pk",
|
||||||
|
"columns": [
|
||||||
|
"track_id",
|
||||||
|
"service"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.tracks": {
|
||||||
|
"name": "tracks",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"isrc": {
|
||||||
|
"name": "isrc",
|
||||||
|
"type": "varchar(12)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"match_key": {
|
||||||
|
"name": "match_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"artist": {
|
||||||
|
"name": "artist",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"album": {
|
||||||
|
"name": "album",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"duration_ms": {
|
||||||
|
"name": "duration_ms",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"artwork_url": {
|
||||||
|
"name": "artwork_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"links_resolved_at": {
|
||||||
|
"name": "links_resolved_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"tracks_isrc_idx": {
|
||||||
|
"name": "tracks_isrc_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "isrc",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"tracks_match_key_idx": {
|
||||||
|
"name": "tracks_match_key_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "match_key",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "varchar(32)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(320)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"name": "display_name",
|
||||||
|
"type": "varchar(64)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"bio": {
|
||||||
|
"name": "bio",
|
||||||
|
"type": "varchar(280)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"avatar_url": {
|
||||||
|
"name": "avatar_url",
|
||||||
|
"type": "varchar(500)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"preferred_service": {
|
||||||
|
"name": "preferred_service",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"plan": {
|
||||||
|
"name": "plan",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'free'"
|
||||||
|
},
|
||||||
|
"plan_expires_at": {
|
||||||
|
"name": "plan_expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791030721144,
|
||||||
|
"tag": "0000_bright_alex_power",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791033243960,
|
||||||
|
"tag": "0001_late_roland_deschain",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791034836624,
|
||||||
|
"tag": "0002_spotty_gorilla_man",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "@musicshare/api",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -n api,worker -c blue,magenta \"tsx watch src/server.ts\" \"tsx watch src/worker.ts\"",
|
||||||
|
"build": "tsup src/server.ts src/worker.ts --format esm --target node22 --noExternal @musicshare/shared",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"start:worker": "node dist/worker.js",
|
||||||
|
"typecheck": "tsc -p .",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "tsx src/db/migrate.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^11.1.2",
|
||||||
|
"@fastify/cors": "^11.3.0",
|
||||||
|
"@fastify/rate-limit": "^11.2.0",
|
||||||
|
"@fastify/websocket": "^11.3.2",
|
||||||
|
"@node-rs/argon2": "^2.2.1",
|
||||||
|
"bullmq": "^6.3.11",
|
||||||
|
"dotenv": "^18.0.5",
|
||||||
|
"drizzle-orm": "^0.45.3",
|
||||||
|
"fastify": "^5.12.5",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
|
"postgres": "^3.4.9",
|
||||||
|
"undici": "^6.29.0",
|
||||||
|
"zod": "^4.6.5",
|
||||||
|
"@musicshare/shared": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.6.4",
|
||||||
|
"@types/ws": "^8.18.2",
|
||||||
|
"concurrently": "^10.0.5",
|
||||||
|
"drizzle-kit": "^0.31.11",
|
||||||
|
"tsup": "^8.5.1",
|
||||||
|
"tsx": "^4.23.15",
|
||||||
|
"typescript": "~6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { env } from '../env';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
export const sql = postgres(env.DATABASE_URL, { max: 10 });
|
||||||
|
export const db = drizzle(sql, { schema });
|
||||||
|
export { schema };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
||||||
|
import { db, sql } from './index';
|
||||||
|
|
||||||
|
await migrate(db, { migrationsFolder: new URL('../../drizzle', import.meta.url).pathname });
|
||||||
|
console.log('migrations applied');
|
||||||
|
await sql.end();
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
pgTable,
|
||||||
|
primaryKey,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import type { NowPlayingProvider, Plan, StreamingService } from '@musicshare/shared';
|
||||||
|
|
||||||
|
const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow();
|
||||||
|
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
username: varchar('username', { length: 32 }).notNull().unique(),
|
||||||
|
email: varchar('email', { length: 320 }).notNull().unique(),
|
||||||
|
passwordHash: text('password_hash').notNull(),
|
||||||
|
displayName: varchar('display_name', { length: 64 }),
|
||||||
|
bio: varchar('bio', { length: 280 }),
|
||||||
|
avatarUrl: varchar('avatar_url', { length: 500 }),
|
||||||
|
preferredService: text('preferred_service').$type<StreamingService>(),
|
||||||
|
/** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */
|
||||||
|
plan: text('plan').$type<Plan>().notNull().default('free'),
|
||||||
|
planExpiresAt: timestamp('plan_expires_at', { withTimezone: true }),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sessions = pgTable(
|
||||||
|
'sessions',
|
||||||
|
{
|
||||||
|
/** sha256 of the session token; the raw token only lives in the client. */
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: uuid('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(t) => [index('sessions_user_idx').on(t.userId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const follows = pgTable(
|
||||||
|
'follows',
|
||||||
|
{
|
||||||
|
followerId: uuid('follower_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
followeeId: uuid('followee_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
/** Set when the follower pins this friend to the top of their feed. */
|
||||||
|
pinnedAt: timestamp('pinned_at', { withTimezone: true }),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.followerId, t.followeeId] }), index('follows_followee_idx').on(t.followeeId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const connections = pgTable(
|
||||||
|
'connections',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
provider: text('provider').$type<NowPlayingProvider>().notNull(),
|
||||||
|
externalUserId: text('external_user_id').notNull(),
|
||||||
|
displayName: text('display_name'),
|
||||||
|
/** AES-256-GCM encrypted, see lib/crypto.ts */
|
||||||
|
accessTokenEnc: text('access_token_enc'),
|
||||||
|
refreshTokenEnc: text('refresh_token_enc'),
|
||||||
|
tokenExpiresAt: timestamp('token_expires_at', { withTimezone: true }),
|
||||||
|
status: text('status').$type<'active' | 'error'>().notNull().default('active'),
|
||||||
|
lastError: text('last_error'),
|
||||||
|
lastPolledAt: timestamp('last_polled_at', { withTimezone: true }),
|
||||||
|
/** Provider-specific state, e.g. Jellyfin server URL or discovered Plex servers. */
|
||||||
|
meta: jsonb('meta').$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
/** Secret path segment for inbound webhooks (/webhooks/:provider/:secret). */
|
||||||
|
webhookSecret: text('webhook_secret').unique(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex('connections_user_provider_idx').on(t.userId, t.provider)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const tracks = pgTable(
|
||||||
|
'tracks',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
isrc: varchar('isrc', { length: 12 }),
|
||||||
|
/** normalized "artist|title", used to dedupe tracks that arrive without an ISRC */
|
||||||
|
matchKey: text('match_key').notNull(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
artist: text('artist').notNull(),
|
||||||
|
album: text('album'),
|
||||||
|
durationMs: integer('duration_ms'),
|
||||||
|
artworkUrl: text('artwork_url'),
|
||||||
|
linksResolvedAt: timestamp('links_resolved_at', { withTimezone: true }),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(t) => [index('tracks_isrc_idx').on(t.isrc), index('tracks_match_key_idx').on(t.matchKey)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const trackLinks = pgTable(
|
||||||
|
'track_links',
|
||||||
|
{
|
||||||
|
trackId: uuid('track_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => tracks.id, { onDelete: 'cascade' }),
|
||||||
|
service: text('service').$type<StreamingService>().notNull(),
|
||||||
|
externalId: text('external_id'),
|
||||||
|
url: text('url').notNull(),
|
||||||
|
/** how we found it: straight from the provider, via ISRC, fuzzy search, or song.link */
|
||||||
|
matchType: text('match_type').$type<'provider' | 'isrc' | 'search' | 'odesli'>().notNull(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
primaryKey({ columns: [t.trackId, t.service] }),
|
||||||
|
index('track_links_external_idx').on(t.service, t.externalId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const nowPlaying = pgTable(
|
||||||
|
'now_playing',
|
||||||
|
{
|
||||||
|
userId: uuid('user_id')
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
trackId: uuid('track_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => tracks.id),
|
||||||
|
source: text('source').$type<NowPlayingProvider>().notNull(),
|
||||||
|
isPlaying: boolean('is_playing').notNull(),
|
||||||
|
progressMs: integer('progress_ms'),
|
||||||
|
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [index('now_playing_track_idx').on(t.trackId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const featuredSongs = pgTable('featured_songs', {
|
||||||
|
userId: uuid('user_id')
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
trackId: uuid('track_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => tracks.id),
|
||||||
|
comment: varchar('comment', { length: 280 }),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`now()`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type User = typeof users.$inferSelect;
|
||||||
|
export type Connection = typeof connections.$inferSelect;
|
||||||
|
export type Track = typeof tracks.$inferSelect;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const optional = z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v ? v : undefined));
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
NODE_ENV: z.string().default('development'),
|
||||||
|
DATABASE_URL: z.string().default('postgres://musicshare:musicshare@localhost:5447/musicshare'),
|
||||||
|
REDIS_URL: z.string().default('redis://localhost:6380'),
|
||||||
|
PORT: z.coerce.number().default(4000),
|
||||||
|
WEB_URL: z.string().default('http://localhost:3737'),
|
||||||
|
API_PUBLIC_URL: z.string().default('http://127.0.0.1:4000'),
|
||||||
|
COOKIE_SECURE: z
|
||||||
|
.string()
|
||||||
|
.default('false')
|
||||||
|
.transform((v) => v === 'true'),
|
||||||
|
TOKEN_ENCRYPTION_KEY: z
|
||||||
|
.string()
|
||||||
|
.refine((v) => Buffer.from(v, 'base64').length === 32, 'TOKEN_ENCRYPTION_KEY must be 32 bytes, base64-encoded'),
|
||||||
|
SPOTIFY_CLIENT_ID: optional,
|
||||||
|
SPOTIFY_CLIENT_SECRET: optional,
|
||||||
|
LASTFM_API_KEY: optional,
|
||||||
|
ODESLI_API_KEY: optional,
|
||||||
|
/** Identifies this app to Plex. Keep it stable: changing it invalidates users' Plex authorizations. */
|
||||||
|
PLEX_CLIENT_ID: z.string().default('musicshare-dev'),
|
||||||
|
/**
|
||||||
|
* Allow Jellyfin/Plex server URLs on private networks. Only enable for local
|
||||||
|
* dev or self-hosting: in a hosted deployment it lets users make the server
|
||||||
|
* request internal addresses (SSRF).
|
||||||
|
*/
|
||||||
|
ALLOW_PRIVATE_SERVER_URLS: z
|
||||||
|
.string()
|
||||||
|
.default('false')
|
||||||
|
.transform((v) => v === 'true'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const env = schema.parse(process.env);
|
||||||
|
|
||||||
|
export const spotifyConfigured = Boolean(env.SPOTIFY_CLIENT_ID && env.SPOTIFY_CLIENT_SECRET);
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { and, eq, gt } from 'drizzle-orm';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import type { User } from '../db/schema';
|
||||||
|
import { env } from '../env';
|
||||||
|
import { randomToken, sha256 } from './crypto';
|
||||||
|
import { HttpError } from './http';
|
||||||
|
|
||||||
|
export const SESSION_COOKIE = 'ms_session';
|
||||||
|
const SESSION_DAYS = 30;
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyRequest {
|
||||||
|
user: User | null;
|
||||||
|
sessionId: string | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(userId: string, reply: FastifyReply): Promise<string> {
|
||||||
|
const token = randomToken();
|
||||||
|
const expiresAt = new Date(Date.now() + SESSION_DAYS * 86_400_000);
|
||||||
|
await db.insert(schema.sessions).values({ id: sha256(token), userId, expiresAt });
|
||||||
|
reply.setCookie(SESSION_COOKIE, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: env.COOKIE_SECURE,
|
||||||
|
path: '/',
|
||||||
|
expires: expiresAt,
|
||||||
|
});
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Populates request.user from the session cookie (web) or a bearer token (mobile). */
|
||||||
|
export async function loadUser(req: FastifyRequest) {
|
||||||
|
req.user = null;
|
||||||
|
req.sessionId = null;
|
||||||
|
const header = req.headers.authorization;
|
||||||
|
const token = header?.startsWith('Bearer ') ? header.slice(7) : req.cookies[SESSION_COOKIE];
|
||||||
|
if (!token) return;
|
||||||
|
const id = sha256(token);
|
||||||
|
const [row] = await db
|
||||||
|
.select({ user: schema.users })
|
||||||
|
.from(schema.sessions)
|
||||||
|
.innerJoin(schema.users, eq(schema.users.id, schema.sessions.userId))
|
||||||
|
.where(and(eq(schema.sessions.id, id), gt(schema.sessions.expiresAt, new Date())));
|
||||||
|
if (row) {
|
||||||
|
req.user = row.user;
|
||||||
|
req.sessionId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireUser(req: FastifyRequest): User {
|
||||||
|
if (!req.user) throw new HttpError(401, 'Not signed in');
|
||||||
|
return req.user;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||||
|
import { env } from '../env';
|
||||||
|
|
||||||
|
const key = Buffer.from(env.TOKEN_ENCRYPTION_KEY, 'base64');
|
||||||
|
|
||||||
|
/** AES-256-GCM. Output: base64(iv | tag | ciphertext). */
|
||||||
|
export function encrypt(plaintext: string): string {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||||
|
return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrypt(payload: string): string {
|
||||||
|
const buf = Buffer.from(payload, 'base64');
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', key, buf.subarray(0, 12));
|
||||||
|
decipher.setAuthTag(buf.subarray(12, 28));
|
||||||
|
return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||||
|
export const randomToken = (bytes = 32) => randomBytes(bytes).toString('base64url');
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { PLAN_ENTITLEMENTS, type Entitlements, type Plan } from '@musicshare/shared';
|
||||||
|
|
||||||
|
export function effectivePlan(user: { plan: Plan; planExpiresAt: Date | null }): Plan {
|
||||||
|
if (user.plan !== 'free' && user.planExpiresAt && user.planExpiresAt < new Date()) return 'free';
|
||||||
|
return user.plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const entitlementsFor = (user: { plan: Plan; planExpiresAt: Date | null }): Entitlements =>
|
||||||
|
PLAN_ENTITLEMENTS[effectivePlan(user)];
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export class HttpError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fetch + JSON with a timeout; throws HttpError on non-2xx. Returns null on 204. */
|
||||||
|
export async function fetchJson<T>(url: string, init: RequestInit = {}, timeoutMs = 8000): Promise<T | null> {
|
||||||
|
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
throw new HttpError(res.status, `${res.status} from ${new URL(url).host}: ${body.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize a title/artist for fuzzy matching across services. */
|
||||||
|
export function normalize(s: string): string {
|
||||||
|
return s
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.replace(/\s*[([].*?(remaster|version|edit|mono|stereo|live|deluxe|feat\.?|ft\.?).*?[)\]]/g, '')
|
||||||
|
.replace(/\s+-\s+.*(remaster|version|edit).*$/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const primaryArtist = (artist: string) => artist.split(/,|&| feat\.? | ft\.? | x /i)[0]!.trim();
|
||||||
|
|
||||||
|
export const matchKey = (artist: string, title: string) => `${normalize(primaryArtist(artist))}|${normalize(title)}`;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Redis } from 'ioredis';
|
||||||
|
import { env } from '../env';
|
||||||
|
|
||||||
|
/** BullMQ requires maxRetriesPerRequest: null on its connections. */
|
||||||
|
export const createRedis = () => new Redis(env.REDIS_URL, { maxRetriesPerRequest: null });
|
||||||
|
|
||||||
|
export const redis = createRedis();
|
||||||
|
|
||||||
|
export const nowPlayingChannel = (userId: string) => `np:${userId}`;
|
||||||
|
export const FOLLOWS_CHANGED_CHANNEL = 'follows-changed';
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Guards for user-supplied server URLs (Jellyfin, Plex). Without these a
|
||||||
|
* user could make our servers call internal addresses such as cloud metadata
|
||||||
|
* endpoints, the database, or other private services.
|
||||||
|
*/
|
||||||
|
import dns from 'node:dns';
|
||||||
|
import { lookup } from 'node:dns/promises';
|
||||||
|
import { isIP } from 'node:net';
|
||||||
|
import { Agent } from 'undici';
|
||||||
|
import { env } from '../env';
|
||||||
|
|
||||||
|
export class UnsafeUrlError extends Error {}
|
||||||
|
|
||||||
|
function isPrivateV4(ip: string): boolean {
|
||||||
|
const [a, b] = ip.split('.').map(Number) as [number, number];
|
||||||
|
return (
|
||||||
|
a === 0 ||
|
||||||
|
a === 10 ||
|
||||||
|
a === 127 ||
|
||||||
|
(a === 100 && b >= 64 && b <= 127) ||
|
||||||
|
(a === 169 && b === 254) ||
|
||||||
|
(a === 172 && b >= 16 && b <= 31) ||
|
||||||
|
(a === 192 && b === 168) ||
|
||||||
|
(a === 192 && b === 0) ||
|
||||||
|
(a === 198 && (b === 18 || b === 19)) ||
|
||||||
|
a >= 224
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIp(ip: string): boolean {
|
||||||
|
if (isIP(ip) === 4) return isPrivateV4(ip);
|
||||||
|
const v6 = ip.toLowerCase();
|
||||||
|
// IPv4-mapped (::ffff:10.0.0.1), which URL parsing normalizes to hex (::ffff:a00:1)
|
||||||
|
const dotted = v6.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||||
|
if (dotted) return isPrivateV4(dotted[1]!);
|
||||||
|
const hex = v6.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
||||||
|
if (hex) {
|
||||||
|
const n = (parseInt(hex[1]!, 16) << 16) | parseInt(hex[2]!, 16);
|
||||||
|
return isPrivateV4([n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join('.'));
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
v6 === '::' ||
|
||||||
|
v6 === '::1' ||
|
||||||
|
/^f[cd]/.test(v6) || // unique local
|
||||||
|
/^fe[89ab]/.test(v6) || // link local
|
||||||
|
v6.startsWith('64:ff9b:') || // NAT64, can embed any IPv4
|
||||||
|
v6.startsWith('::ffff:') // anything else mapped we couldn't parse
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses and validates a server URL; throws UnsafeUrlError. Returns the normalized origin + path. */
|
||||||
|
export async function assertSafeServerUrl(raw: string): Promise<string> {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw.trim());
|
||||||
|
} catch {
|
||||||
|
throw new UnsafeUrlError('Enter a full URL, like https://jellyfin.example.com');
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new UnsafeUrlError('URL must start with http(s)://');
|
||||||
|
if (url.username || url.password) throw new UnsafeUrlError('URL must not contain credentials');
|
||||||
|
|
||||||
|
if (!env.ALLOW_PRIVATE_SERVER_URLS) {
|
||||||
|
const host = url.hostname.replace(/^\[|\]$/g, '');
|
||||||
|
const addresses = isIP(host) ? [host] : (await lookup(host, { all: true }).catch(() => [])).map((a) => a.address);
|
||||||
|
if (addresses.length === 0) throw new UnsafeUrlError(`Couldn't resolve ${host}`);
|
||||||
|
if (addresses.some(isPrivateIp)) {
|
||||||
|
throw new UnsafeUrlError('That server is on a private network. It must be reachable from the internet.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-checks the address at connect time. assertSafeServerUrl alone is open to
|
||||||
|
* DNS rebinding: a hostile domain can resolve to a public IP for the check and
|
||||||
|
* a private one for the actual request.
|
||||||
|
*/
|
||||||
|
const guardedAgent = new Agent({
|
||||||
|
connect: {
|
||||||
|
lookup(hostname, options, callback) {
|
||||||
|
dns.lookup(hostname, options, (err, address, family) => {
|
||||||
|
if (err) return callback(err, address as string, family as number);
|
||||||
|
const all = Array.isArray(address) ? address : [{ address, family }];
|
||||||
|
if (all.some((a) => isPrivateIp(a.address))) {
|
||||||
|
return callback(new UnsafeUrlError(`${hostname} resolves to a private address`), '', 0);
|
||||||
|
}
|
||||||
|
callback(null, address as string, family as number);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Spread into fetch() options for any request to a user-supplied server. */
|
||||||
|
export const serverFetchOptions = (): RequestInit =>
|
||||||
|
(env.ALLOW_PRIVATE_SERVER_URLS ? { redirect: 'error' } : { redirect: 'error', dispatcher: guardedAgent }) as RequestInit;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Public catalogs used to match a track across services. None of these need
|
||||||
|
* user auth; Deezer is our canonical ISRC source because it's free and keyless.
|
||||||
|
*/
|
||||||
|
import type { StreamingService } from '@musicshare/shared';
|
||||||
|
import { env } from '../env';
|
||||||
|
import { HttpError, fetchJson, primaryArtist } from '../lib/http';
|
||||||
|
import { redis } from '../lib/redis';
|
||||||
|
import { findBestMatch } from './match';
|
||||||
|
|
||||||
|
export interface DeezerTrack {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
duration: number;
|
||||||
|
isrc?: string;
|
||||||
|
link: string;
|
||||||
|
artist: { name: string };
|
||||||
|
album: { title: string; cover_xl?: string; cover_big?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deezerTrack(id: string): Promise<DeezerTrack | null> {
|
||||||
|
const t = await fetchJson<DeezerTrack & { error?: unknown }>(`https://api.deezer.com/track/${id}`);
|
||||||
|
return t && !t.error ? t : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deezerByIsrc(isrc: string): Promise<DeezerTrack | null> {
|
||||||
|
return deezerTrack(`isrc:${encodeURIComponent(isrc)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deezerSearch(q: string, limit = 10): Promise<DeezerTrack[]> {
|
||||||
|
const res = await fetchJson<{ data?: DeezerTrack[] }>(
|
||||||
|
`https://api.deezer.com/search?limit=${limit}&q=${encodeURIComponent(q)}`,
|
||||||
|
);
|
||||||
|
return res?.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a track on Deezer by text; fetches full details (search results lack the ISRC). */
|
||||||
|
export async function deezerFind(target: { title: string; artist: string; durationMs?: number | null }) {
|
||||||
|
// Deezer's advanced syntax (artist:"x" track:"y") currently returns nothing; plain text + our matcher works.
|
||||||
|
const results = await deezerSearch(`${primaryArtist(target.artist)} ${target.title}`);
|
||||||
|
const best = findBestMatch(
|
||||||
|
results.map((r) => ({ r, title: r.title, artist: r.artist.name, durationMs: r.duration * 1000 })),
|
||||||
|
target,
|
||||||
|
);
|
||||||
|
return best ? deezerTrack(String(best.r.id)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItunesSong {
|
||||||
|
trackId: number;
|
||||||
|
trackName: string;
|
||||||
|
artistName: string;
|
||||||
|
trackTimeMillis?: number;
|
||||||
|
trackViewUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apple Music via the keyless iTunes Search API. Upgrade to MusicKit `filter[isrc]` once you have a developer token. */
|
||||||
|
export async function appleMusicFind(target: { title: string; artist: string; durationMs?: number | null }) {
|
||||||
|
const term = encodeURIComponent(`${target.artist} ${target.title}`);
|
||||||
|
const res = await fetchJson<{ results: ItunesSong[] }>(
|
||||||
|
`https://itunes.apple.com/search?media=music&entity=song&limit=10&term=${term}`,
|
||||||
|
);
|
||||||
|
const best = findBestMatch(
|
||||||
|
(res?.results ?? []).map((s) => ({ s, title: s.trackName, artist: s.artistName, durationMs: s.trackTimeMillis })),
|
||||||
|
target,
|
||||||
|
);
|
||||||
|
if (!best) return null;
|
||||||
|
// trackViewUrl points at music.apple.com and opens the Music app via universal links
|
||||||
|
const url = best.s.trackViewUrl.replace(/[?&]uo=\d+/, '');
|
||||||
|
return { id: String(best.s.trackId), url };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ODESLI_PLATFORMS: Partial<Record<string, StreamingService>> = {
|
||||||
|
spotify: 'spotify',
|
||||||
|
appleMusic: 'apple_music',
|
||||||
|
youtubeMusic: 'youtube_music',
|
||||||
|
deezer: 'deezer',
|
||||||
|
tidal: 'tidal',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* song.link requires an API key. Calls are rate limited globally across
|
||||||
|
* workers through Redis; when exhausted (or unconfigured) we skip and clients
|
||||||
|
* fall back to search links.
|
||||||
|
*/
|
||||||
|
async function takeOdesliToken(): Promise<boolean> {
|
||||||
|
const key = `odesli:rl:${Math.floor(Date.now() / 60_000)}`;
|
||||||
|
const n = await redis.incr(key);
|
||||||
|
if (n === 1) await redis.expire(key, 70);
|
||||||
|
return n <= 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function odesliLinks(sourceUrl: string): Promise<Partial<Record<StreamingService, string>> | null> {
|
||||||
|
if (!env.ODESLI_API_KEY || !(await takeOdesliToken())) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetchJson<{ linksByPlatform: Record<string, { url: string }> }>(
|
||||||
|
`https://api.song.link/v1-alpha.1/links?songIfSingle=true&key=${env.ODESLI_API_KEY}&url=${encodeURIComponent(sourceUrl)}`,
|
||||||
|
);
|
||||||
|
const out: Partial<Record<StreamingService, string>> = {};
|
||||||
|
for (const [platform, link] of Object.entries(res?.linksByPlatform ?? {})) {
|
||||||
|
const service = ODESLI_PLATFORMS[platform];
|
||||||
|
if (service) out[service] = link.url;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof HttpError && (e.status === 404 || e.status === 400)) return {};
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { NowPlayingProvider } from '@musicshare/shared';
|
||||||
|
import { jellyfinProvider } from './jellyfin';
|
||||||
|
import { lastfmProvider } from './lastfm';
|
||||||
|
import { listenbrainzProvider } from './listenbrainz';
|
||||||
|
import { plexProvider } from './plex';
|
||||||
|
import { spotifyProvider } from './spotify';
|
||||||
|
import type { PollingProvider } from './types';
|
||||||
|
|
||||||
|
/** Providers the server polls. Push-only sources (the Android app) don't appear here. */
|
||||||
|
export const pollingProviders: Partial<Record<NowPlayingProvider, PollingProvider>> = {
|
||||||
|
spotify: spotifyProvider,
|
||||||
|
lastfm: lastfmProvider,
|
||||||
|
listenbrainz: listenbrainzProvider,
|
||||||
|
plex: plexProvider,
|
||||||
|
jellyfin: jellyfinProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POLLED_PROVIDERS = Object.keys(pollingProviders) as NowPlayingProvider[];
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Jellyfin. The user signs in with their server URL + credentials; we keep
|
||||||
|
* only the access token Jellyfin issues (never the password) and poll
|
||||||
|
* /Sessions. The Jellyfin Webhook plugin can hit our webhook URL to trigger
|
||||||
|
* an immediate poll.
|
||||||
|
*/
|
||||||
|
import type { Connection } from '../db/schema';
|
||||||
|
import { decrypt } from '../lib/crypto';
|
||||||
|
import { HttpError, fetchJson } from '../lib/http';
|
||||||
|
import { assertSafeServerUrl, serverFetchOptions } from '../lib/ssrf';
|
||||||
|
import { ProviderAuthError, type PollingProvider } from './types';
|
||||||
|
|
||||||
|
export interface JellyfinMeta {
|
||||||
|
serverUrl: string;
|
||||||
|
serverName: string;
|
||||||
|
deviceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = (deviceId: string, token?: string) =>
|
||||||
|
`MediaBrowser Client="musicshare", Device="musicshare", DeviceId="${deviceId}", Version="1.0.0"` +
|
||||||
|
(token ? `, Token="${token}"` : '');
|
||||||
|
|
||||||
|
export async function jellyfinPublicInfo(serverUrl: string) {
|
||||||
|
const info = await fetchJson<{ ServerName: string; ProductName?: string; Version: string }>(
|
||||||
|
`${serverUrl}/System/Info/Public`,
|
||||||
|
{ ...serverFetchOptions() },
|
||||||
|
);
|
||||||
|
if (!info?.Version) throw new Error('not a Jellyfin server');
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function jellyfinAuthenticate(serverUrl: string, deviceId: string, username: string, password: string) {
|
||||||
|
try {
|
||||||
|
return (await fetchJson<{ AccessToken: string; User: { Id: string; Name: string } }>(
|
||||||
|
`${serverUrl}/Users/AuthenticateByName`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: authHeader(deviceId) },
|
||||||
|
body: JSON.stringify({ Username: username, Pw: password }),
|
||||||
|
...serverFetchOptions(),
|
||||||
|
},
|
||||||
|
))!;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof HttpError && e.status === 401) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revoke our token on the server when the user disconnects. Best-effort. */
|
||||||
|
export async function jellyfinLogout(conn: Connection) {
|
||||||
|
const meta = conn.meta as unknown as JellyfinMeta;
|
||||||
|
if (!conn.accessTokenEnc || !meta.serverUrl) return;
|
||||||
|
const url = await assertSafeServerUrl(meta.serverUrl);
|
||||||
|
await fetch(`${url}/Sessions/Logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: authHeader(meta.deviceId, decrypt(conn.accessTokenEnc)) },
|
||||||
|
...serverFetchOptions(),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JellyfinSession {
|
||||||
|
UserId: string;
|
||||||
|
NowPlayingItem?: {
|
||||||
|
Type: string;
|
||||||
|
Name: string;
|
||||||
|
Artists?: string[];
|
||||||
|
AlbumArtist?: string;
|
||||||
|
Album?: string;
|
||||||
|
RunTimeTicks?: number;
|
||||||
|
};
|
||||||
|
PlayState?: { IsPaused?: boolean; PositionTicks?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticksToMs = (ticks?: number) => (ticks ? Math.round(ticks / 10_000) : null);
|
||||||
|
|
||||||
|
export const jellyfinProvider: PollingProvider = {
|
||||||
|
id: 'jellyfin',
|
||||||
|
async poll(conn) {
|
||||||
|
const meta = conn.meta as unknown as JellyfinMeta;
|
||||||
|
const url = await assertSafeServerUrl(meta.serverUrl);
|
||||||
|
let sessions: JellyfinSession[] | null;
|
||||||
|
try {
|
||||||
|
sessions = await fetchJson<JellyfinSession[]>(
|
||||||
|
`${url}/Sessions?ActiveWithinSeconds=960`,
|
||||||
|
{ headers: { Authorization: authHeader(meta.deviceId, decrypt(conn.accessTokenEnc!)) }, ...serverFetchOptions() },
|
||||||
|
6000,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof HttpError && e.status === 401) throw new ProviderAuthError('Jellyfin session expired or revoked');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const mine = (sessions ?? []).filter((s) => s.UserId === conn.externalUserId && s.NowPlayingItem?.Type === 'Audio');
|
||||||
|
const session = mine.find((s) => !s.PlayState?.IsPaused) ?? mine[0];
|
||||||
|
const item = session?.NowPlayingItem;
|
||||||
|
if (!session || !item) return null;
|
||||||
|
return {
|
||||||
|
track: {
|
||||||
|
title: item.Name,
|
||||||
|
artist: item.Artists?.length ? item.Artists.join(', ') : (item.AlbumArtist ?? 'Unknown artist'),
|
||||||
|
album: item.Album ?? null,
|
||||||
|
durationMs: ticksToMs(item.RunTimeTicks),
|
||||||
|
},
|
||||||
|
isPlaying: !session.PlayState?.IsPaused,
|
||||||
|
progressMs: ticksToMs(session.PlayState?.PositionTicks),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { env } from '../env';
|
||||||
|
import { HttpError, fetchJson } from '../lib/http';
|
||||||
|
import { ProviderAuthError, type PollingProvider } from './types';
|
||||||
|
|
||||||
|
interface LastfmTrack {
|
||||||
|
name: string;
|
||||||
|
artist: { '#text': string };
|
||||||
|
album: { '#text': string };
|
||||||
|
image: { size: string; '#text': string }[];
|
||||||
|
'@attr'?: { nowplaying?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = (params: Record<string, string>) =>
|
||||||
|
`https://ws.audioscrobbler.com/2.0/?${new URLSearchParams({ ...params, api_key: env.LASTFM_API_KEY!, format: 'json' })}`;
|
||||||
|
|
||||||
|
export async function lastfmUserExists(username: string): Promise<{ name: string } | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetchJson<{ user: { name: string } }>(api({ method: 'user.getinfo', user: username }));
|
||||||
|
return res ? { name: res.user.name } : null;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof HttpError && e.status === 404) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lastfmProvider: PollingProvider = {
|
||||||
|
id: 'lastfm',
|
||||||
|
async poll(conn) {
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetchJson<{ recenttracks: { track: LastfmTrack[] | LastfmTrack } }>(
|
||||||
|
api({ method: 'user.getrecenttracks', user: conn.externalUserId, limit: '1' }),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof HttpError && e.status === 404) throw new ProviderAuthError('last.fm user not found');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const raw = res?.recenttracks.track;
|
||||||
|
const first = Array.isArray(raw) ? raw[0] : raw;
|
||||||
|
if (!first || first['@attr']?.nowplaying !== 'true') return null;
|
||||||
|
const art = first.image.find((i) => i.size === 'extralarge')?.['#text'] || null;
|
||||||
|
return {
|
||||||
|
track: {
|
||||||
|
title: first.name,
|
||||||
|
artist: first.artist['#text'],
|
||||||
|
album: first.album['#text'] || null,
|
||||||
|
// Last.fm serves a grey star placeholder when it has no art
|
||||||
|
artworkUrl: art && !art.includes('2a96cbd8b46e442fc41c2b86b821562f') ? art : null,
|
||||||
|
},
|
||||||
|
isPlaying: true,
|
||||||
|
progressMs: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { HttpError, fetchJson } from '../lib/http';
|
||||||
|
import { ProviderAuthError, type PollingProvider } from './types';
|
||||||
|
|
||||||
|
interface LbPlayingNow {
|
||||||
|
payload: {
|
||||||
|
listens: {
|
||||||
|
track_metadata: {
|
||||||
|
artist_name: string;
|
||||||
|
track_name: string;
|
||||||
|
release_name?: string;
|
||||||
|
additional_info?: { isrc?: string; duration_ms?: number; spotify_id?: string };
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const playingNow = (username: string) =>
|
||||||
|
// ListenBrainz is regularly slow to respond; give it more headroom than the default
|
||||||
|
fetchJson<LbPlayingNow>(`https://api.listenbrainz.org/1/user/${encodeURIComponent(username)}/playing-now`, {}, 15_000);
|
||||||
|
|
||||||
|
export async function listenbrainzUserExists(username: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await playingNow(username);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof HttpError && e.status === 404) return false;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listenbrainzProvider: PollingProvider = {
|
||||||
|
id: 'listenbrainz',
|
||||||
|
async poll(conn) {
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await playingNow(conn.externalUserId);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof HttpError && e.status === 404) throw new ProviderAuthError('listenbrainz user not found');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const meta = res?.payload.listens[0]?.track_metadata;
|
||||||
|
if (!meta) return null;
|
||||||
|
const info = meta.additional_info ?? {};
|
||||||
|
const spotifyId = info.spotify_id?.match(/track\/([A-Za-z0-9]+)/)?.[1];
|
||||||
|
return {
|
||||||
|
track: {
|
||||||
|
title: meta.track_name,
|
||||||
|
artist: meta.artist_name,
|
||||||
|
album: meta.release_name ?? null,
|
||||||
|
durationMs: info.duration_ms ?? null,
|
||||||
|
isrc: info.isrc ?? null,
|
||||||
|
links: spotifyId
|
||||||
|
? { spotify: { id: spotifyId, url: `https://open.spotify.com/track/${spotifyId}` } }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
isPlaying: true,
|
||||||
|
progressMs: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { normalize, primaryArtist } from '../lib/http';
|
||||||
|
|
||||||
|
export interface Candidate {
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
durationMs?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick the candidate that is plausibly the same recording, or null. */
|
||||||
|
export function findBestMatch<T extends Candidate>(
|
||||||
|
candidates: T[],
|
||||||
|
target: { title: string; artist: string; durationMs?: number | null },
|
||||||
|
): T | null {
|
||||||
|
const title = normalize(target.title);
|
||||||
|
const artist = normalize(primaryArtist(target.artist));
|
||||||
|
const scored = candidates
|
||||||
|
.map((c) => {
|
||||||
|
const ct = normalize(c.title);
|
||||||
|
const ca = normalize(primaryArtist(c.artist));
|
||||||
|
if (ca !== artist && !normalize(c.artist).includes(artist)) return null;
|
||||||
|
if (ct !== title && !ct.startsWith(title) && !title.startsWith(ct)) return null;
|
||||||
|
let score = ct === title ? 2 : 1;
|
||||||
|
if (target.durationMs && c.durationMs) {
|
||||||
|
const diff = Math.abs(target.durationMs - c.durationMs);
|
||||||
|
if (diff > 15_000) return null;
|
||||||
|
if (diff < 3_000) score += 2;
|
||||||
|
}
|
||||||
|
return { c, score };
|
||||||
|
})
|
||||||
|
.filter((x): x is { c: T; score: number } => x !== null)
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
return scored[0]?.c ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Plex. Auth is Plex's PIN flow (no Plex Pass needed). We discover the
|
||||||
|
* user's servers via plex.tv and poll each one's /status/sessions for music
|
||||||
|
* the user is playing. Plex Pass users can also point a Plex webhook at us,
|
||||||
|
* which triggers an immediate poll.
|
||||||
|
*/
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import type { Connection } from '../db/schema';
|
||||||
|
import { env } from '../env';
|
||||||
|
import { decrypt, encrypt } from '../lib/crypto';
|
||||||
|
import { HttpError, fetchJson } from '../lib/http';
|
||||||
|
import { UnsafeUrlError, assertSafeServerUrl, serverFetchOptions } from '../lib/ssrf';
|
||||||
|
import { ProviderAuthError, type PollingProvider, type PollResult } from './types';
|
||||||
|
|
||||||
|
const PLEX_TV = 'https://plex.tv/api/v2';
|
||||||
|
const DISCOVERY_TTL_MS = 6 * 3600_000;
|
||||||
|
const MAX_SERVERS = 5;
|
||||||
|
|
||||||
|
const plexHeaders = (token?: string): Record<string, string> => ({
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-Plex-Product': 'musicshare',
|
||||||
|
'X-Plex-Client-Identifier': env.PLEX_CLIENT_ID,
|
||||||
|
...(token ? { 'X-Plex-Token': token } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- auth (PIN flow) ----
|
||||||
|
|
||||||
|
export async function createPin(): Promise<{ id: number; code: string }> {
|
||||||
|
return (await fetchJson<{ id: number; code: string }>(`${PLEX_TV}/pins?strong=true`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: plexHeaders(),
|
||||||
|
}))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authAppUrl(code: string, forwardUrl: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
clientID: env.PLEX_CLIENT_ID,
|
||||||
|
code,
|
||||||
|
'context[device][product]': 'musicshare',
|
||||||
|
forwardUrl,
|
||||||
|
});
|
||||||
|
return `https://app.plex.tv/auth#?${params}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the auth token once the user has approved the PIN, else null. */
|
||||||
|
export async function checkPin(id: number): Promise<string | null> {
|
||||||
|
const pin = await fetchJson<{ authToken: string | null }>(`${PLEX_TV}/pins/${id}`, { headers: plexHeaders() });
|
||||||
|
return pin?.authToken ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlexAccount(token: string) {
|
||||||
|
return (await fetchJson<{ id: number; uuid: string; username: string; title: string }>(`${PLEX_TV}/user`, {
|
||||||
|
headers: plexHeaders(token),
|
||||||
|
}))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- server discovery ----
|
||||||
|
|
||||||
|
interface PlexServer {
|
||||||
|
name: string;
|
||||||
|
owned: boolean;
|
||||||
|
tokenEnc: string;
|
||||||
|
/** candidate base URLs, best first */
|
||||||
|
uris: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlexMeta {
|
||||||
|
servers?: PlexServer[];
|
||||||
|
discoveredAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlexResource {
|
||||||
|
name: string;
|
||||||
|
provides: string;
|
||||||
|
owned: boolean;
|
||||||
|
accessToken: string;
|
||||||
|
connections: { uri: string; local: boolean; relay: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverServers(accountToken: string): Promise<PlexServer[]> {
|
||||||
|
let resources: PlexResource[] | null;
|
||||||
|
try {
|
||||||
|
resources = await fetchJson<PlexResource[]>(`${PLEX_TV}/resources?includeHttps=1&includeRelay=1`, {
|
||||||
|
headers: plexHeaders(accountToken),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof HttpError && e.status === 401) throw new ProviderAuthError('Plex authorization was revoked');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return (resources ?? [])
|
||||||
|
.filter((r) => r.provides.split(',').includes('server'))
|
||||||
|
.slice(0, MAX_SERVERS)
|
||||||
|
.map((r) => ({
|
||||||
|
name: r.name,
|
||||||
|
owned: r.owned,
|
||||||
|
tokenEnc: encrypt(r.accessToken),
|
||||||
|
// direct remote first, then Plex relay, then LAN (only usable when self-hosting)
|
||||||
|
uris: [...r.connections]
|
||||||
|
.sort((a, b) => Number(a.local) - Number(b.local) || Number(a.relay) - Number(b.relay))
|
||||||
|
.map((c) => c.uri),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- polling ----
|
||||||
|
|
||||||
|
interface PlexSessionTrack {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
grandparentTitle?: string; // album artist
|
||||||
|
originalTitle?: string; // track artist, when it differs
|
||||||
|
parentTitle?: string; // album
|
||||||
|
duration?: number;
|
||||||
|
viewOffset?: number;
|
||||||
|
User?: { id: string; title: string };
|
||||||
|
Player?: { state: 'playing' | 'paused' | 'buffering' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Try each candidate URI until one answers; moves the working one to the front. */
|
||||||
|
async function fetchSessions(server: PlexServer): Promise<PlexSessionTrack[]> {
|
||||||
|
const token = decrypt(server.tokenEnc);
|
||||||
|
let lastError: unknown;
|
||||||
|
for (const [i, uri] of server.uris.entries()) {
|
||||||
|
try {
|
||||||
|
const base = await assertSafeServerUrl(uri);
|
||||||
|
const res = await fetchJson<{ MediaContainer: { Metadata?: PlexSessionTrack[] } }>(
|
||||||
|
`${base}/status/sessions`,
|
||||||
|
{ headers: plexHeaders(token), ...serverFetchOptions() },
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
if (i > 0) server.uris = [uri, ...server.uris.filter((u) => u !== uri)];
|
||||||
|
return res?.MediaContainer.Metadata ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e;
|
||||||
|
if (e instanceof HttpError && e.status === 401) break; // token no longer valid for this server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new Error(`${server.name}: unreachable`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUsersSession(s: PlexSessionTrack, accountId: string, server: PlexServer): boolean {
|
||||||
|
// On a server you own, your sessions report the local admin id "1" rather than your plex.tv id.
|
||||||
|
return s.User?.id === accountId || (server.owned && s.User?.id === '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const plexProvider: PollingProvider = {
|
||||||
|
id: 'plex',
|
||||||
|
async poll(conn: Connection) {
|
||||||
|
const meta = conn.meta as PlexMeta;
|
||||||
|
const accountToken = decrypt(conn.accessTokenEnc!);
|
||||||
|
let servers = meta.servers;
|
||||||
|
if (!servers?.length || !meta.discoveredAt || Date.now() - meta.discoveredAt > DISCOVERY_TTL_MS) {
|
||||||
|
servers = await discoverServers(accountToken);
|
||||||
|
meta.discoveredAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(servers.map((s) => fetchSessions(s)));
|
||||||
|
await db
|
||||||
|
.update(schema.connections)
|
||||||
|
.set({ meta: { ...meta, servers } })
|
||||||
|
.where(eq(schema.connections.id, conn.id));
|
||||||
|
|
||||||
|
if (servers.length > 0 && results.every((r) => r.status === 'rejected')) {
|
||||||
|
// force rediscovery next time; server addresses may have changed
|
||||||
|
await db
|
||||||
|
.update(schema.connections)
|
||||||
|
.set({ meta: { ...meta, servers, discoveredAt: 0 } })
|
||||||
|
.where(eq(schema.connections.id, conn.id));
|
||||||
|
const reason = (results[0] as PromiseRejectedResult).reason;
|
||||||
|
if (reason instanceof UnsafeUrlError) throw new Error('Your Plex server is only reachable on a private network');
|
||||||
|
throw new Error(`Couldn't reach your Plex server${servers.length > 1 ? 's' : ''}: ${reason?.message ?? reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mine = results
|
||||||
|
.flatMap((r, i) => (r.status === 'fulfilled' ? r.value.map((s) => ({ s, server: servers[i]! })) : []))
|
||||||
|
.filter(({ s, server }) => s.type === 'track' && isUsersSession(s, conn.externalUserId, server))
|
||||||
|
.map(({ s }) => s);
|
||||||
|
const session = mine.find((s) => s.Player?.state === 'playing') ?? mine[0];
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
track: {
|
||||||
|
title: session.title,
|
||||||
|
artist: session.originalTitle || session.grandparentTitle || 'Unknown artist',
|
||||||
|
album: session.parentTitle ?? null,
|
||||||
|
durationMs: session.duration ?? null,
|
||||||
|
},
|
||||||
|
isPlaying: session.Player?.state !== 'paused',
|
||||||
|
progressMs: session.viewOffset ?? null,
|
||||||
|
} satisfies PollResult;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import type { Connection } from '../db/schema';
|
||||||
|
import { env } from '../env';
|
||||||
|
import { decrypt, encrypt } from '../lib/crypto';
|
||||||
|
import { HttpError, fetchJson } from '../lib/http';
|
||||||
|
import { redis } from '../lib/redis';
|
||||||
|
import { findBestMatch } from './match';
|
||||||
|
import { ProviderAuthError, ProviderRateLimitError, type IncomingTrack, type PollingProvider } from './types';
|
||||||
|
|
||||||
|
const SCOPES = ['user-read-currently-playing', 'user-read-playback-state'];
|
||||||
|
export const redirectUri = () => `${env.API_PUBLIC_URL}/connections/spotify/callback`;
|
||||||
|
|
||||||
|
interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpotifyTrack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
duration_ms: number;
|
||||||
|
artists: { name: string }[];
|
||||||
|
album: { name: string; images: { url: string }[] };
|
||||||
|
external_ids?: { isrc?: string };
|
||||||
|
external_urls: { spotify: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const basicAuth = () =>
|
||||||
|
'Basic ' + Buffer.from(`${env.SPOTIFY_CLIENT_ID}:${env.SPOTIFY_CLIENT_SECRET}`).toString('base64');
|
||||||
|
|
||||||
|
export function authorizeUrl(state: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: env.SPOTIFY_CLIENT_ID!,
|
||||||
|
response_type: 'code',
|
||||||
|
redirect_uri: redirectUri(),
|
||||||
|
scope: SCOPES.join(' '),
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
return `https://accounts.spotify.com/authorize?${params}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tokenRequest(body: Record<string, string>): Promise<TokenResponse> {
|
||||||
|
const res = await fetch('https://accounts.spotify.com/api/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: basicAuth(), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams(body),
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
if (res.status === 400 || res.status === 401) throw new ProviderAuthError(`spotify token: ${await res.text()}`);
|
||||||
|
if (!res.ok) throw new HttpError(res.status, `spotify token: ${res.status}`);
|
||||||
|
return (await res.json()) as TokenResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exchangeCode = (code: string) =>
|
||||||
|
tokenRequest({ grant_type: 'authorization_code', code, redirect_uri: redirectUri() });
|
||||||
|
|
||||||
|
export async function getProfile(accessToken: string) {
|
||||||
|
return (await fetchJson<{ id: string; display_name: string | null }>('https://api.spotify.com/v1/me', {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
}))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a valid access token for the connection, refreshing and persisting if needed. */
|
||||||
|
async function userAccessToken(conn: Connection): Promise<string> {
|
||||||
|
if (conn.accessTokenEnc && conn.tokenExpiresAt && conn.tokenExpiresAt.getTime() > Date.now() + 60_000) {
|
||||||
|
return decrypt(conn.accessTokenEnc);
|
||||||
|
}
|
||||||
|
if (!conn.refreshTokenEnc) throw new ProviderAuthError('no refresh token');
|
||||||
|
const tok = await tokenRequest({ grant_type: 'refresh_token', refresh_token: decrypt(conn.refreshTokenEnc) });
|
||||||
|
await db
|
||||||
|
.update(schema.connections)
|
||||||
|
.set({
|
||||||
|
accessTokenEnc: encrypt(tok.access_token),
|
||||||
|
refreshTokenEnc: tok.refresh_token ? encrypt(tok.refresh_token) : conn.refreshTokenEnc,
|
||||||
|
tokenExpiresAt: new Date(Date.now() + tok.expires_in * 1000),
|
||||||
|
})
|
||||||
|
.where(eq(schema.connections.id, conn.id));
|
||||||
|
return tok.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spotifyGet<T>(path: string, token: string): Promise<T | null> {
|
||||||
|
const res = await fetch(`https://api.spotify.com/v1${path}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
if (res.status === 429) throw new ProviderRateLimitError(Number(res.headers.get('retry-after') ?? 30) * 1000);
|
||||||
|
if (res.status === 401 || res.status === 403) throw new ProviderAuthError(`spotify ${res.status}: ${await res.text()}`);
|
||||||
|
if (!res.ok) throw new HttpError(res.status, `spotify ${res.status}`);
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIncoming(t: SpotifyTrack): IncomingTrack {
|
||||||
|
return {
|
||||||
|
title: t.name,
|
||||||
|
artist: t.artists.map((a) => a.name).join(', '),
|
||||||
|
album: t.album.name,
|
||||||
|
durationMs: t.duration_ms,
|
||||||
|
artworkUrl: t.album.images[0]?.url ?? null,
|
||||||
|
isrc: t.external_ids?.isrc ?? null,
|
||||||
|
links: { spotify: { id: t.id, url: t.external_urls.spotify } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const spotifyProvider: PollingProvider = {
|
||||||
|
id: 'spotify',
|
||||||
|
async poll(conn) {
|
||||||
|
const token = await userAccessToken(conn);
|
||||||
|
const data = await spotifyGet<{
|
||||||
|
is_playing: boolean;
|
||||||
|
progress_ms: number | null;
|
||||||
|
currently_playing_type: string;
|
||||||
|
item: SpotifyTrack | null;
|
||||||
|
}>('/me/player/currently-playing', token);
|
||||||
|
// Podcasts/ads come back with a null or non-track item.
|
||||||
|
if (!data?.item || data.currently_playing_type !== 'track') return null;
|
||||||
|
return { track: toIncoming(data.item), isPlaying: data.is_playing, progressMs: data.progress_ms };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- catalog lookups (client credentials), used for cross-service linking ----
|
||||||
|
|
||||||
|
async function appAccessToken(): Promise<string> {
|
||||||
|
const cached = await redis.get('spotify:app-token');
|
||||||
|
if (cached) return cached;
|
||||||
|
const tok = await tokenRequest({ grant_type: 'client_credentials' });
|
||||||
|
await redis.set('spotify:app-token', tok.access_token, 'EX', tok.expires_in - 60);
|
||||||
|
return tok.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchSpotify(
|
||||||
|
track: { title: string; artist: string; durationMs?: number | null },
|
||||||
|
isrc: string | null,
|
||||||
|
): Promise<{ track: IncomingTrack; matchType: 'isrc' | 'search' } | null> {
|
||||||
|
const token = await appAccessToken();
|
||||||
|
if (isrc) {
|
||||||
|
const res = await spotifyGet<{ tracks: { items: SpotifyTrack[] } }>(
|
||||||
|
`/search?type=track&limit=1&q=${encodeURIComponent(`isrc:${isrc}`)}`,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
const hit = res?.tracks.items[0];
|
||||||
|
if (hit) return { track: toIncoming(hit), matchType: 'isrc' };
|
||||||
|
}
|
||||||
|
const q = `track:${track.title} artist:${track.artist}`;
|
||||||
|
const res = await spotifyGet<{ tracks: { items: SpotifyTrack[] } }>(
|
||||||
|
`/search?type=track&limit=10&q=${encodeURIComponent(q)}`,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
const items = (res?.tracks.items ?? []).map((t) => ({ ...toIncoming(t), title: t.name, artist: t.artists[0]!.name }));
|
||||||
|
const best = findBestMatch(items, track);
|
||||||
|
return best ? { track: best, matchType: 'search' } : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { NowPlayingProvider, StreamingService } from '@musicshare/shared';
|
||||||
|
import type { Connection } from '../db/schema';
|
||||||
|
|
||||||
|
export interface IncomingTrack {
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album?: string | null;
|
||||||
|
durationMs?: number | null;
|
||||||
|
artworkUrl?: string | null;
|
||||||
|
isrc?: string | null;
|
||||||
|
/** Links we already know from the source, e.g. the Spotify track itself. */
|
||||||
|
links?: Partial<Record<StreamingService, { id: string | null; url: string }>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PollResult {
|
||||||
|
track: IncomingTrack;
|
||||||
|
isPlaying: boolean;
|
||||||
|
progressMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PollingProvider {
|
||||||
|
id: NowPlayingProvider;
|
||||||
|
/** null = nothing playing */
|
||||||
|
poll(conn: Connection): Promise<PollResult | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user must reconnect; stop polling this connection. */
|
||||||
|
export class ProviderAuthError extends Error {}
|
||||||
|
|
||||||
|
/** Back off until retryAfterMs. */
|
||||||
|
export class ProviderRateLimitError extends Error {
|
||||||
|
constructor(public retryAfterMs: number) {
|
||||||
|
super(`rate limited, retry after ${retryAfterMs}ms`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { hash, verify } from '@node-rs/argon2';
|
||||||
|
import { eq, or } from 'drizzle-orm';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { loginSchema, registerSchema } from '@musicshare/shared';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import { SESSION_COOKIE, createSession } from '../lib/auth';
|
||||||
|
import { HttpError } from '../lib/http';
|
||||||
|
import { toMeDTO } from './me';
|
||||||
|
|
||||||
|
const authRateLimit = { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } };
|
||||||
|
|
||||||
|
export async function authRoutes(app: FastifyInstance) {
|
||||||
|
app.post('/auth/register', authRateLimit, async (req, reply) => {
|
||||||
|
const input = registerSchema.parse(req.body);
|
||||||
|
const [taken] = await db
|
||||||
|
.select({ username: schema.users.username, email: schema.users.email })
|
||||||
|
.from(schema.users)
|
||||||
|
.where(or(eq(schema.users.username, input.username), eq(schema.users.email, input.email)));
|
||||||
|
if (taken) throw new HttpError(409, taken.email === input.email ? 'Email already registered' : 'Username taken');
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.insert(schema.users)
|
||||||
|
.values({ username: input.username, email: input.email, passwordHash: await hash(input.password) })
|
||||||
|
.returning();
|
||||||
|
const token = await createSession(user!.id, reply);
|
||||||
|
return { user: toMeDTO(user!), token };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/auth/login', authRateLimit, async (req, reply) => {
|
||||||
|
const input = loginSchema.parse(req.body);
|
||||||
|
const [user] = await db.select().from(schema.users).where(eq(schema.users.email, input.email));
|
||||||
|
if (!user || !(await verify(user.passwordHash, input.password))) {
|
||||||
|
throw new HttpError(401, 'Invalid email or password');
|
||||||
|
}
|
||||||
|
const token = await createSession(user.id, reply);
|
||||||
|
return { user: toMeDTO(user), token };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/auth/logout', async (req, reply) => {
|
||||||
|
if (req.sessionId) await db.delete(schema.sessions).where(eq(schema.sessions.id, req.sessionId));
|
||||||
|
reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { and, count, eq } from 'drizzle-orm';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { connectUsernameSchema, jellyfinConnectSchema, type NowPlayingProvider } from '@musicshare/shared';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import type { User } from '../db/schema';
|
||||||
|
import { env, spotifyConfigured } from '../env';
|
||||||
|
import { requireUser } from '../lib/auth';
|
||||||
|
import { encrypt, randomToken } from '../lib/crypto';
|
||||||
|
import { entitlementsFor } from '../lib/entitlements';
|
||||||
|
import { HttpError } from '../lib/http';
|
||||||
|
import { redis } from '../lib/redis';
|
||||||
|
import { UnsafeUrlError, assertSafeServerUrl } from '../lib/ssrf';
|
||||||
|
import { jellyfinAuthenticate, jellyfinLogout, jellyfinPublicInfo, type JellyfinMeta } from '../providers/jellyfin';
|
||||||
|
import { lastfmUserExists } from '../providers/lastfm';
|
||||||
|
import { listenbrainzUserExists } from '../providers/listenbrainz';
|
||||||
|
import * as plex from '../providers/plex';
|
||||||
|
import * as spotify from '../providers/spotify';
|
||||||
|
import { toConnectionDTO, upsertConnection } from '../services/connections';
|
||||||
|
import { unschedulePoll } from '../services/scheduler';
|
||||||
|
|
||||||
|
const oauthStateKey = (state: string) => `oauth:state:${state}`;
|
||||||
|
|
||||||
|
const authorizeBodySchema = z.object({
|
||||||
|
client: z.enum(['web', 'mobile']).default('web'),
|
||||||
|
/** Mobile only: deep link to return to (the app scheme, or Expo Go's exp:// URL in development). */
|
||||||
|
returnTo: z.string().max(500).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Where to send the user after OAuth. Only our own web app or app scheme is allowed (no open redirects). */
|
||||||
|
function resolveReturnTo(body: z.infer<typeof authorizeBodySchema>): string {
|
||||||
|
if (body.client === 'web') return `${env.WEB_URL}/settings`;
|
||||||
|
const allowed = body.returnTo?.startsWith('musicshare://') || (env.NODE_ENV !== 'production' && body.returnTo?.startsWith('exp://'));
|
||||||
|
return allowed ? body.returnTo! : 'musicshare://settings';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertCanConnect(user: User, provider: NowPlayingProvider) {
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: schema.connections.id })
|
||||||
|
.from(schema.connections)
|
||||||
|
.where(and(eq(schema.connections.userId, user.id), eq(schema.connections.provider, provider)));
|
||||||
|
if (existing) return; // reconnecting doesn't count against the limit
|
||||||
|
const [row] = await db.select({ n: count() }).from(schema.connections).where(eq(schema.connections.userId, user.id));
|
||||||
|
if ((row?.n ?? 0) >= entitlementsFor(user).maxConnections) {
|
||||||
|
throw new HttpError(402, 'Connection limit reached for your plan');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectionRoutes(app: FastifyInstance) {
|
||||||
|
app.get('/connections', async (req) => {
|
||||||
|
const user = requireUser(req);
|
||||||
|
const rows = await db.select().from(schema.connections).where(eq(schema.connections.userId, user.id));
|
||||||
|
return rows.map(toConnectionDTO);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Spotify (OAuth) ----
|
||||||
|
|
||||||
|
app.post('/connections/spotify/authorize', async (req) => {
|
||||||
|
const user = requireUser(req);
|
||||||
|
if (!spotifyConfigured) throw new HttpError(501, 'Spotify is not configured on this server');
|
||||||
|
await assertCanConnect(user, 'spotify');
|
||||||
|
const returnTo = resolveReturnTo(authorizeBodySchema.parse(req.body ?? {}));
|
||||||
|
const state = randomToken(24);
|
||||||
|
await redis.set(oauthStateKey(state), JSON.stringify({ userId: user.id, returnTo }), 'EX', 600);
|
||||||
|
return { url: spotify.authorizeUrl(state) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// The callback is identified by `state`, not the session cookie: Spotify
|
||||||
|
// requires a 127.0.0.1 redirect in dev, which won't carry localhost cookies.
|
||||||
|
app.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
|
||||||
|
'/connections/spotify/callback',
|
||||||
|
async (req, reply) => {
|
||||||
|
const raw = req.query.state ? await redis.getdel(oauthStateKey(req.query.state)) : null;
|
||||||
|
if (!raw) throw new HttpError(400, 'Invalid or expired OAuth state');
|
||||||
|
const { userId, ...state } = JSON.parse(raw) as { userId: string; returnTo: string };
|
||||||
|
const returnTo = new URL(state.returnTo);
|
||||||
|
|
||||||
|
if (req.query.error || !req.query.code) {
|
||||||
|
returnTo.searchParams.set('error', req.query.error ?? 'spotify_denied');
|
||||||
|
return reply.redirect(returnTo.toString());
|
||||||
|
}
|
||||||
|
const tok = await spotify.exchangeCode(req.query.code);
|
||||||
|
const profile = await spotify.getProfile(tok.access_token);
|
||||||
|
await upsertConnection({
|
||||||
|
userId,
|
||||||
|
provider: 'spotify',
|
||||||
|
externalUserId: profile.id,
|
||||||
|
displayName: profile.display_name,
|
||||||
|
accessTokenEnc: encrypt(tok.access_token),
|
||||||
|
refreshTokenEnc: tok.refresh_token ? encrypt(tok.refresh_token) : null,
|
||||||
|
tokenExpiresAt: new Date(Date.now() + tok.expires_in * 1000),
|
||||||
|
});
|
||||||
|
returnTo.searchParams.set('connected', 'spotify');
|
||||||
|
return reply.redirect(returnTo.toString());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Plex (PIN flow) ----
|
||||||
|
|
||||||
|
app.post('/connections/plex/authorize', async (req) => {
|
||||||
|
const user = requireUser(req);
|
||||||
|
await assertCanConnect(user, 'plex');
|
||||||
|
const returnTo = resolveReturnTo(authorizeBodySchema.parse(req.body ?? {}));
|
||||||
|
const pin = await plex.createPin();
|
||||||
|
const state = randomToken(24);
|
||||||
|
await redis.set(oauthStateKey(state), JSON.stringify({ userId: user.id, returnTo, pinId: pin.id }), 'EX', 900);
|
||||||
|
return { url: plex.authAppUrl(pin.code, `${env.API_PUBLIC_URL}/connections/plex/callback?state=${state}`) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Querystring: { state?: string } }>('/connections/plex/callback', async (req, reply) => {
|
||||||
|
const raw = req.query.state ? await redis.getdel(oauthStateKey(req.query.state)) : null;
|
||||||
|
if (!raw) throw new HttpError(400, 'Invalid or expired Plex login');
|
||||||
|
const { userId, pinId, ...state } = JSON.parse(raw) as { userId: string; returnTo: string; pinId: number };
|
||||||
|
const returnTo = new URL(state.returnTo);
|
||||||
|
|
||||||
|
// Plex forwards right after approval; the token can lag by a moment
|
||||||
|
let token: string | null = null;
|
||||||
|
for (let i = 0; i < 3 && !token; i++) {
|
||||||
|
token = await plex.checkPin(pinId);
|
||||||
|
if (!token) await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
returnTo.searchParams.set('error', 'Plex sign-in was cancelled');
|
||||||
|
return reply.redirect(returnTo.toString());
|
||||||
|
}
|
||||||
|
const account = await plex.getPlexAccount(token);
|
||||||
|
await upsertConnection({
|
||||||
|
userId,
|
||||||
|
provider: 'plex',
|
||||||
|
externalUserId: String(account.id),
|
||||||
|
displayName: account.username || account.title,
|
||||||
|
accessTokenEnc: encrypt(token),
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
returnTo.searchParams.set('connected', 'plex');
|
||||||
|
return reply.redirect(returnTo.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Jellyfin (server URL + credentials) ----
|
||||||
|
|
||||||
|
app.post('/connections/jellyfin', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (req) => {
|
||||||
|
const user = requireUser(req);
|
||||||
|
await assertCanConnect(user, 'jellyfin');
|
||||||
|
const input = jellyfinConnectSchema.parse(req.body);
|
||||||
|
let serverUrl: string;
|
||||||
|
try {
|
||||||
|
serverUrl = await assertSafeServerUrl(input.serverUrl);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof UnsafeUrlError) throw new HttpError(400, e.message);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const info = await jellyfinPublicInfo(serverUrl).catch(() => {
|
||||||
|
throw new HttpError(400, `Couldn't reach a Jellyfin server at ${serverUrl}`);
|
||||||
|
});
|
||||||
|
const deviceId = `musicshare-${randomToken(12)}`;
|
||||||
|
const auth = await jellyfinAuthenticate(serverUrl, deviceId, input.username, input.password);
|
||||||
|
if (!auth) throw new HttpError(401, 'Jellyfin rejected that username or password');
|
||||||
|
|
||||||
|
const meta: JellyfinMeta = { serverUrl, serverName: info.ServerName, deviceId };
|
||||||
|
return toConnectionDTO(
|
||||||
|
await upsertConnection({
|
||||||
|
userId: user.id,
|
||||||
|
provider: 'jellyfin',
|
||||||
|
externalUserId: auth.User.Id,
|
||||||
|
displayName: `${auth.User.Name} on ${info.ServerName}`,
|
||||||
|
accessTokenEnc: encrypt(auth.AccessToken),
|
||||||
|
meta: { ...meta },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- username-based scrobblers ----
|
||||||
|
|
||||||
|
app.post('/connections/lastfm', async (req) => {
|
||||||
|
const user = requireUser(req);
|
||||||
|
if (!env.LASTFM_API_KEY) throw new HttpError(501, 'Last.fm is not configured on this server');
|
||||||
|
await assertCanConnect(user, 'lastfm');
|
||||||
|
const { username } = connectUsernameSchema.parse(req.body);
|
||||||
|
const lfm = await lastfmUserExists(username);
|
||||||
|
if (!lfm) throw new HttpError(404, 'Last.fm user not found');
|
||||||
|
return toConnectionDTO(
|
||||||
|
await upsertConnection({ userId: user.id, provider: 'lastfm', externalUserId: lfm.name, displayName: lfm.name }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/connections/listenbrainz', async (req) => {
|
||||||
|
const user = requireUser(req);
|
||||||
|
await assertCanConnect(user, 'listenbrainz');
|
||||||
|
const { username } = connectUsernameSchema.parse(req.body);
|
||||||
|
if (!(await listenbrainzUserExists(username))) throw new HttpError(404, 'ListenBrainz user not found');
|
||||||
|
return toConnectionDTO(
|
||||||
|
await upsertConnection({ userId: user.id, provider: 'listenbrainz', externalUserId: username, displayName: username }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { provider: string } }>('/connections/:provider', async (req) => {
|
||||||
|
const user = requireUser(req);
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(schema.connections)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.connections.userId, user.id),
|
||||||
|
eq(schema.connections.provider, req.params.provider as NowPlayingProvider),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
if (deleted) {
|
||||||
|
await unschedulePoll(deleted.id);
|
||||||
|
if (deleted.provider === 'jellyfin') await jellyfinLogout(deleted);
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { setFeaturedSchema, updateMeSchema, type MeDTO } from '@musicshare/shared';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import type { User } from '../db/schema';
|
||||||
|
import { SESSION_COOKIE, requireUser } from '../lib/auth';
|
||||||
|
import { effectivePlan, entitlementsFor } from '../lib/entitlements';
|
||||||
|
import { HttpError } from '../lib/http';
|
||||||
|
import { deezerTrack } from '../providers/catalog';
|
||||||
|
import { enqueueResolve } from '../services/queues';
|
||||||
|
import { unschedulePoll } from '../services/scheduler';
|
||||||
|
import { upsertTrack } from '../services/tracks';
|
||||||
|
|
||||||
|
export const toMeDTO = (u: User): MeDTO => ({
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
displayName: u.displayName,
|
||||||
|
avatarUrl: u.avatarUrl,
|
||||||
|
email: u.email,
|
||||||
|
bio: u.bio,
|
||||||
|
preferredService: u.preferredService,
|
||||||
|
plan: effectivePlan(u),
|
||||||
|
entitlements: entitlementsFor(u),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function meRoutes(app: FastifyInstance) {
|
||||||
|
app.get('/me', async (req) => toMeDTO(requireUser(req)));
|
||||||
|
|
||||||
|
app.patch('/me', async (req) => {
|
||||||
|
const user = requireUser(req);
|
||||||
|
const input = updateMeSchema.parse(req.body);
|
||||||
|
const [updated] = await db.update(schema.users).set(input).where(eq(schema.users.id, user.id)).returning();
|
||||||
|
return toMeDTO(updated!);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Required by the App Store: users must be able to delete their account in-app. */
|
||||||
|
app.delete('/me', async (req, reply) => {
|
||||||
|
const user = requireUser(req);
|
||||||
|
const conns = await db
|
||||||
|
.select({ id: schema.connections.id })
|
||||||
|
.from(schema.connections)
|
||||||
|
.where(eq(schema.connections.userId, user.id));
|
||||||
|
await Promise.all(conns.map((c) => unschedulePoll(c.id)));
|
||||||
|
await db.delete(schema.users).where(eq(schema.users.id, user.id));
|
||||||
|
reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/me/featured', async (req) => {
|
||||||
|
const user = requireUser(req);
|
||||||
|
const input = setFeaturedSchema.parse(req.body);
|
||||||
|
let trackId: string;
|
||||||
|
if ('deezerId' in input) {
|
||||||
|
const dz = await deezerTrack(input.deezerId);
|
||||||
|
if (!dz) throw new HttpError(404, 'Track not found');
|
||||||
|
const t = await upsertTrack({
|
||||||
|
title: dz.title,
|
||||||
|
artist: dz.artist.name,
|
||||||
|
album: dz.album.title,
|
||||||
|
durationMs: dz.duration * 1000,
|
||||||
|
artworkUrl: dz.album.cover_xl ?? dz.album.cover_big ?? null,
|
||||||
|
isrc: dz.isrc ?? null,
|
||||||
|
links: { deezer: { id: String(dz.id), url: dz.link } },
|
||||||
|
});
|
||||||
|
trackId = t.id;
|
||||||
|
} else {
|
||||||
|
const [t] = await db.select({ id: schema.tracks.id }).from(schema.tracks).where(eq(schema.tracks.id, input.trackId));
|
||||||
|
if (!t) throw new HttpError(404, 'Track not found');
|
||||||
|
trackId = t.id;
|
||||||
|
}
|
||||||
|
const values = { trackId, comment: input.comment || null, updatedAt: new Date() };
|
||||||
|
await db
|
||||||
|
.insert(schema.featuredSongs)
|
||||||
|
.values({ userId: user.id, ...values })
|
||||||
|
.onConflictDoUpdate({ target: schema.featuredSongs.userId, set: values });
|
||||||
|
await enqueueResolve(trackId);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/me/featured', async (req) => {
|
||||||
|
const user = requireUser(req);
|
||||||
|
await db.delete(schema.featuredSongs).where(eq(schema.featuredSongs.userId, user.id));
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Live now-playing updates over WebSocket.
|
||||||
|
*
|
||||||
|
* Workers publish to Redis channel `np:<userId>`; every API instance
|
||||||
|
* pattern-subscribes and forwards to its own sockets that follow that user.
|
||||||
|
* Instances share nothing else, so you can run as many as you like behind a
|
||||||
|
* load balancer.
|
||||||
|
*/
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { WebSocket } from 'ws';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import { FOLLOWS_CHANGED_CHANNEL, createRedis } from '../lib/redis';
|
||||||
|
import { watchUsers } from '../services/connections';
|
||||||
|
|
||||||
|
const watchers = new Map<string, Set<WebSocket>>(); // watched userId -> sockets
|
||||||
|
const socketsByOwner = new Map<string, Set<WebSocket>>(); // viewer userId -> their sockets
|
||||||
|
const watching = new Map<WebSocket, Set<string>>();
|
||||||
|
|
||||||
|
function addTo<K, V>(map: Map<K, Set<V>>, key: K, value: V) {
|
||||||
|
let set = map.get(key);
|
||||||
|
if (!set) map.set(key, (set = new Set()));
|
||||||
|
set.add(value);
|
||||||
|
}
|
||||||
|
function removeFrom<K, V>(map: Map<K, Set<V>>, key: K, value: V) {
|
||||||
|
const set = map.get(key);
|
||||||
|
set?.delete(value);
|
||||||
|
if (set?.size === 0) map.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscribeSocket(socket: WebSocket, viewerId: string) {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: schema.follows.followeeId })
|
||||||
|
.from(schema.follows)
|
||||||
|
.where(eq(schema.follows.followerId, viewerId));
|
||||||
|
const ids = new Set([viewerId, ...rows.map((r) => r.id)]);
|
||||||
|
for (const old of watching.get(socket) ?? []) removeFrom(watchers, old, socket);
|
||||||
|
for (const id of ids) addTo(watchers, id, socket);
|
||||||
|
watching.set(socket, ids);
|
||||||
|
await watchUsers([...ids]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function realtimeRoutes(app: FastifyInstance) {
|
||||||
|
const sub = createRedis();
|
||||||
|
await sub.psubscribe('np:*');
|
||||||
|
await sub.subscribe(FOLLOWS_CHANGED_CHANNEL);
|
||||||
|
|
||||||
|
sub.on('pmessage', (_pattern, channel, message) => {
|
||||||
|
const userId = channel.slice(3);
|
||||||
|
const payload = `{"type":"now_playing","data":${message}}`;
|
||||||
|
for (const socket of watchers.get(userId) ?? []) socket.send(payload);
|
||||||
|
});
|
||||||
|
sub.on('message', (channel, viewerId) => {
|
||||||
|
if (channel !== FOLLOWS_CHANGED_CHANNEL) return;
|
||||||
|
for (const socket of socketsByOwner.get(viewerId) ?? []) {
|
||||||
|
subscribeSocket(socket, viewerId).catch((e) => app.log.error(e));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep presence alive so workers keep polling watched users at the fast rate.
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
watchUsers([...watchers.keys()]).catch((e) => app.log.error(e));
|
||||||
|
for (const socket of watching.keys()) socket.ping();
|
||||||
|
}, 30_000);
|
||||||
|
app.addHook('onClose', async () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
sub.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/ws', { websocket: true }, (socket, req) => {
|
||||||
|
const viewer = req.user;
|
||||||
|
if (!viewer) {
|
||||||
|
socket.close(4401, 'unauthorized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addTo(socketsByOwner, viewer.id, socket);
|
||||||
|
subscribeSocket(socket, viewer.id).catch((e) => app.log.error(e));
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
for (const id of watching.get(socket) ?? []) removeFrom(watchers, id, socket);
|
||||||
|
watching.delete(socket);
|
||||||
|
removeFrom(socketsByOwner, viewer.id, socket);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { STREAMING_SERVICES, openUrl, type StreamingService, type TrackSearchResultDTO } from '@musicshare/shared';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import { HttpError } from '../lib/http';
|
||||||
|
import { deezerSearch } from '../providers/catalog';
|
||||||
|
import { resolveTrackLinks } from '../services/resolver';
|
||||||
|
import { getTrackDTOs } from '../services/tracks';
|
||||||
|
|
||||||
|
export async function trackRoutes(app: FastifyInstance) {
|
||||||
|
app.get<{ Querystring: { q?: string } }>(
|
||||||
|
'/tracks/search',
|
||||||
|
{ config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
|
||||||
|
async (req): Promise<TrackSearchResultDTO[]> => {
|
||||||
|
const q = (req.query.q ?? '').trim();
|
||||||
|
if (q.length < 2) return [];
|
||||||
|
const results = await deezerSearch(q, 15);
|
||||||
|
return results.map((r) => ({
|
||||||
|
deezerId: String(r.id),
|
||||||
|
title: r.title,
|
||||||
|
artist: r.artist.name,
|
||||||
|
album: r.album.title,
|
||||||
|
artworkUrl: r.album.cover_big ?? null,
|
||||||
|
durationMs: r.duration * 1000,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/tracks/:id', async (req) => {
|
||||||
|
const track = (await getTrackDTOs([req.params.id])).get(req.params.id);
|
||||||
|
if (!track) throw new HttpError(404, 'Track not found');
|
||||||
|
return track;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Shareable redirect: /tracks/:id/open?service=spotify (defaults to the viewer's preferred service). */
|
||||||
|
app.get<{ Params: { id: string }; Querystring: { service?: string } }>('/tracks/:id/open', async (req, reply) => {
|
||||||
|
const service = (req.query.service ?? req.user?.preferredService ?? 'spotify') as StreamingService;
|
||||||
|
if (!STREAMING_SERVICES.includes(service)) throw new HttpError(400, 'Unknown service');
|
||||||
|
let track = (await getTrackDTOs([req.params.id])).get(req.params.id);
|
||||||
|
if (!track) throw new HttpError(404, 'Track not found');
|
||||||
|
if (!track.links[service]) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ resolved: schema.tracks.linksResolvedAt })
|
||||||
|
.from(schema.tracks)
|
||||||
|
.where(eq(schema.tracks.id, track.id));
|
||||||
|
if (!row?.resolved) {
|
||||||
|
await resolveTrackLinks(track.id).catch((e) => req.log.warn(e, 'inline resolve failed'));
|
||||||
|
track = (await getTrackDTOs([track.id])).get(track.id)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reply.redirect(openUrl(track, service).url);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { and, count, eq, ilike, isNotNull, or } from 'drizzle-orm';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { sortFeed, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import { requireUser } from '../lib/auth';
|
||||||
|
import { entitlementsFor } from '../lib/entitlements';
|
||||||
|
import { HttpError } from '../lib/http';
|
||||||
|
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
|
||||||
|
import { watchUsers } from '../services/connections';
|
||||||
|
import { getNowPlaying, toUserSummary } from '../services/nowPlaying';
|
||||||
|
import { getTrackDTOs } from '../services/tracks';
|
||||||
|
|
||||||
|
async function findByUsername(username: string) {
|
||||||
|
const [user] = await db.select().from(schema.users).where(eq(schema.users.username, username.toLowerCase()));
|
||||||
|
if (!user) throw new HttpError(404, 'User not found');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userRoutes(app: FastifyInstance) {
|
||||||
|
app.get<{ Querystring: { q?: string } }>('/users/search', async (req) => {
|
||||||
|
const q = (req.query.q ?? '').trim().replace(/[%_\\]/g, '');
|
||||||
|
if (q.length < 2) return [];
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(or(ilike(schema.users.username, `${q}%`), ilike(schema.users.displayName, `%${q}%`)))
|
||||||
|
.limit(20);
|
||||||
|
return rows.map(toUserSummary);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { username: string } }>('/users/:username', async (req): Promise<ProfileDTO> => {
|
||||||
|
const user = await findByUsername(req.params.username);
|
||||||
|
const viewer = req.user;
|
||||||
|
const [[followers], [following], [follow], [featured], nowPlaying] = await Promise.all([
|
||||||
|
db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followeeId, user.id)),
|
||||||
|
db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followerId, user.id)),
|
||||||
|
viewer
|
||||||
|
? db
|
||||||
|
.select({ pinnedAt: schema.follows.pinnedAt })
|
||||||
|
.from(schema.follows)
|
||||||
|
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, user.id)))
|
||||||
|
: Promise.resolve([]),
|
||||||
|
db.select().from(schema.featuredSongs).where(eq(schema.featuredSongs.userId, user.id)),
|
||||||
|
getNowPlaying([user.id]),
|
||||||
|
]);
|
||||||
|
const featuredTrack = featured ? (await getTrackDTOs([featured.trackId])).get(featured.trackId) : undefined;
|
||||||
|
await watchUsers([user.id]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: { ...toUserSummary(user), bio: user.bio },
|
||||||
|
followerCount: followers?.n ?? 0,
|
||||||
|
followingCount: following?.n ?? 0,
|
||||||
|
isFollowing: Boolean(follow),
|
||||||
|
isPinned: Boolean(follow?.pinnedAt),
|
||||||
|
isSelf: viewer?.id === user.id,
|
||||||
|
featured:
|
||||||
|
featured && featuredTrack
|
||||||
|
? { track: featuredTrack, comment: featured.comment, updatedAt: featured.updatedAt.toISOString() }
|
||||||
|
: null,
|
||||||
|
nowPlaying: nowPlaying[0] ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { username: string } }>('/users/:username/follow', async (req) => {
|
||||||
|
const viewer = requireUser(req);
|
||||||
|
const target = await findByUsername(req.params.username);
|
||||||
|
if (target.id === viewer.id) throw new HttpError(400, "You can't follow yourself");
|
||||||
|
await db.insert(schema.follows).values({ followerId: viewer.id, followeeId: target.id }).onConflictDoNothing();
|
||||||
|
await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { username: string } }>('/users/:username/follow', async (req) => {
|
||||||
|
const viewer = requireUser(req);
|
||||||
|
const target = await findByUsername(req.params.username);
|
||||||
|
await db
|
||||||
|
.delete(schema.follows)
|
||||||
|
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id)));
|
||||||
|
await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put<{ Params: { username: string } }>('/users/:username/pin', async (req) => {
|
||||||
|
const viewer = requireUser(req);
|
||||||
|
const target = await findByUsername(req.params.username);
|
||||||
|
const [follow] = await db
|
||||||
|
.select({ pinnedAt: schema.follows.pinnedAt })
|
||||||
|
.from(schema.follows)
|
||||||
|
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id)));
|
||||||
|
if (!follow) throw new HttpError(400, 'Follow someone before pinning them');
|
||||||
|
if (follow.pinnedAt) return { ok: true };
|
||||||
|
const [pinned] = await db
|
||||||
|
.select({ n: count() })
|
||||||
|
.from(schema.follows)
|
||||||
|
.where(and(eq(schema.follows.followerId, viewer.id), isNotNull(schema.follows.pinnedAt)));
|
||||||
|
const max = entitlementsFor(viewer).maxPinnedFriends;
|
||||||
|
if ((pinned?.n ?? 0) >= max) throw new HttpError(402, `You can pin up to ${max} friends on your plan`);
|
||||||
|
await db
|
||||||
|
.update(schema.follows)
|
||||||
|
.set({ pinnedAt: new Date() })
|
||||||
|
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id)));
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { username: string } }>('/users/:username/pin', async (req) => {
|
||||||
|
const viewer = requireUser(req);
|
||||||
|
const target = await findByUsername(req.params.username);
|
||||||
|
await db
|
||||||
|
.update(schema.follows)
|
||||||
|
.set({ pinnedAt: null })
|
||||||
|
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id)));
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Now-playing state for everyone the viewer follows, pinned friends first. */
|
||||||
|
app.get('/feed', async (req): Promise<FeedItemDTO[]> => {
|
||||||
|
const viewer = requireUser(req);
|
||||||
|
const followees = await db
|
||||||
|
.select({ id: schema.follows.followeeId, pinnedAt: schema.follows.pinnedAt })
|
||||||
|
.from(schema.follows)
|
||||||
|
.where(eq(schema.follows.followerId, viewer.id));
|
||||||
|
const pins = new Map(followees.map((f) => [f.id, f.pinnedAt?.toISOString() ?? null]));
|
||||||
|
await watchUsers([...pins.keys()]);
|
||||||
|
const items = await getNowPlaying([...pins.keys()]);
|
||||||
|
return sortFeed(items.map((np) => ({ ...np, pinnedAt: pins.get(np.user.id) ?? null })));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Inbound webhooks from Plex (Plex Pass) and the Jellyfin Webhook plugin.
|
||||||
|
*
|
||||||
|
* We don't trust or parse the payload: a webhook only means "something
|
||||||
|
* changed", so we poll that connection right away. That keeps one code path
|
||||||
|
* for state, works with any Jellyfin template, and means a forged request can
|
||||||
|
* at most cause an extra poll. The secret in the URL identifies the connection.
|
||||||
|
*/
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { NowPlayingProvider } from '@musicshare/shared';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import { pollSoon } from '../services/scheduler';
|
||||||
|
|
||||||
|
export async function webhookRoutes(app: FastifyInstance) {
|
||||||
|
// Plex posts multipart/form-data (sometimes with a thumbnail attached). We
|
||||||
|
// ignore bodies entirely, so accept any content type without parsing.
|
||||||
|
app.addContentTypeParser('*', { bodyLimit: 10 * 1024 * 1024 }, (_req, payload, done) => {
|
||||||
|
payload.resume();
|
||||||
|
payload.on('end', () => done(null, undefined));
|
||||||
|
payload.on('error', done);
|
||||||
|
});
|
||||||
|
app.removeContentTypeParser('application/json');
|
||||||
|
app.addContentTypeParser('application/json', (_req, payload, done) => {
|
||||||
|
payload.resume();
|
||||||
|
payload.on('end', () => done(null, undefined));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { provider: string; secret: string } }>(
|
||||||
|
'/webhooks/:provider/:secret',
|
||||||
|
{ config: { rateLimit: { max: 120, timeWindow: '1 minute' } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const [conn] = await db
|
||||||
|
.select({ id: schema.connections.id })
|
||||||
|
.from(schema.connections)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.connections.webhookSecret, req.params.secret),
|
||||||
|
eq(schema.connections.provider, req.params.provider as NowPlayingProvider),
|
||||||
|
eq(schema.connections.status, 'active'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (conn) await pollSoon([conn.id]);
|
||||||
|
// Same response either way, so secrets can't be probed.
|
||||||
|
return reply.status(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import cookie from '@fastify/cookie';
|
||||||
|
import cors from '@fastify/cors';
|
||||||
|
import rateLimit from '@fastify/rate-limit';
|
||||||
|
import websocket from '@fastify/websocket';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
import { sql } from './db';
|
||||||
|
import { env } from './env';
|
||||||
|
import { loadUser } from './lib/auth';
|
||||||
|
import { HttpError } from './lib/http';
|
||||||
|
import { redis } from './lib/redis';
|
||||||
|
import { authRoutes } from './routes/auth';
|
||||||
|
import { connectionRoutes } from './routes/connections';
|
||||||
|
import { meRoutes } from './routes/me';
|
||||||
|
import { realtimeRoutes } from './routes/realtime';
|
||||||
|
import { trackRoutes } from './routes/tracks';
|
||||||
|
import { userRoutes } from './routes/users';
|
||||||
|
import { webhookRoutes } from './routes/webhooks';
|
||||||
|
|
||||||
|
const app = Fastify({ logger: true, trustProxy: true });
|
||||||
|
|
||||||
|
await app.register(cors, {
|
||||||
|
origin: [env.WEB_URL],
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||||
|
});
|
||||||
|
await app.register(cookie);
|
||||||
|
// Rate limits are per-route (see `config.rateLimit`); the Redis store makes them global across instances.
|
||||||
|
await app.register(rateLimit, { global: false, redis });
|
||||||
|
await app.register(websocket);
|
||||||
|
|
||||||
|
app.decorateRequest('user', null);
|
||||||
|
app.decorateRequest('sessionId', null);
|
||||||
|
app.addHook('onRequest', loadUser);
|
||||||
|
|
||||||
|
app.setErrorHandler((err, req, reply) => {
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
return reply.status(400).send({ error: err.issues[0]?.message ?? 'Invalid input', issues: err.issues });
|
||||||
|
}
|
||||||
|
if (err instanceof HttpError) return reply.status(err.status).send({ error: err.message });
|
||||||
|
const status = (err as { statusCode?: number }).statusCode;
|
||||||
|
if (status && status < 500) return reply.status(status).send({ error: (err as Error).message });
|
||||||
|
req.log.error(err);
|
||||||
|
return reply.status(500).send({ error: 'Internal server error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/health', async () => {
|
||||||
|
await Promise.all([sql`select 1`, redis.ping()]);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(authRoutes);
|
||||||
|
await app.register(meRoutes);
|
||||||
|
await app.register(userRoutes);
|
||||||
|
await app.register(trackRoutes);
|
||||||
|
await app.register(connectionRoutes);
|
||||||
|
await app.register(realtimeRoutes);
|
||||||
|
// registered as its own plugin so its permissive body parsers stay scoped to webhook routes
|
||||||
|
await app.register(webhookRoutes);
|
||||||
|
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||||
|
process.on(signal, async () => {
|
||||||
|
await app.close();
|
||||||
|
await sql.end();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.listen({ port: env.PORT, host: '0.0.0.0' });
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
|
import type { ConnectionDTO } from '@musicshare/shared';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import type { Connection } from '../db/schema';
|
||||||
|
import { env } from '../env';
|
||||||
|
import { randomToken } from '../lib/crypto';
|
||||||
|
import { POLLED_PROVIDERS } from '../providers';
|
||||||
|
import { markWatched, pollSoon, schedulePoll } from './scheduler';
|
||||||
|
|
||||||
|
export const toConnectionDTO = (c: Connection): ConnectionDTO => ({
|
||||||
|
provider: c.provider,
|
||||||
|
externalUserId: c.externalUserId,
|
||||||
|
displayName: c.displayName,
|
||||||
|
status: c.status,
|
||||||
|
lastError: c.lastError,
|
||||||
|
lastPolledAt: c.lastPolledAt?.toISOString() ?? null,
|
||||||
|
webhookUrl: c.webhookSecret ? `${env.API_PUBLIC_URL}/webhooks/${c.provider}/${c.webhookSecret}` : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Providers that can notify us via webhook (which triggers an immediate poll). */
|
||||||
|
const WEBHOOK_PROVIDERS = new Set(['plex', 'jellyfin']);
|
||||||
|
|
||||||
|
export async function upsertConnection(
|
||||||
|
values: Omit<typeof schema.connections.$inferInsert, 'id' | 'createdAt' | 'status' | 'lastError' | 'webhookSecret'>,
|
||||||
|
) {
|
||||||
|
const set = { ...values, status: 'active' as const, lastError: null };
|
||||||
|
const [row] = await db
|
||||||
|
.insert(schema.connections)
|
||||||
|
// on reconnect the existing webhook secret is kept, so URLs users pasted into Plex/Jellyfin keep working
|
||||||
|
.values({ ...set, webhookSecret: WEBHOOK_PROVIDERS.has(values.provider) ? randomToken(24) : null })
|
||||||
|
.onConflictDoUpdate({ target: [schema.connections.userId, schema.connections.provider], set })
|
||||||
|
.returning();
|
||||||
|
await schedulePoll(row!.id);
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Someone started looking at these users: poll them now instead of waiting for the idle interval. */
|
||||||
|
export async function watchUsers(userIds: string[]) {
|
||||||
|
const fresh = await markWatched(userIds);
|
||||||
|
if (fresh.length === 0) return;
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: schema.connections.id })
|
||||||
|
.from(schema.connections)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(schema.connections.userId, fresh),
|
||||||
|
eq(schema.connections.status, 'active'),
|
||||||
|
inArray(schema.connections.provider, POLLED_PROVIDERS),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await pollSoon(rows.map((r) => r.id));
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
|
import type { NowPlayingDTO, NowPlayingProvider, UserSummaryDTO } from '@musicshare/shared';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import type { User } from '../db/schema';
|
||||||
|
import { nowPlayingChannel, redis } from '../lib/redis';
|
||||||
|
import type { PollResult } from '../providers/types';
|
||||||
|
import { enqueueResolve } from './queues';
|
||||||
|
import { getTrackDTOs, upsertTrack } from './tracks';
|
||||||
|
|
||||||
|
export const toUserSummary = (u: Pick<User, 'id' | 'username' | 'displayName' | 'avatarUrl'>): UserSummaryDTO => ({
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
displayName: u.displayName,
|
||||||
|
avatarUrl: u.avatarUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]> {
|
||||||
|
if (userIds.length === 0) return [];
|
||||||
|
const rows = await db
|
||||||
|
.select({ np: schema.nowPlaying, user: schema.users })
|
||||||
|
.from(schema.nowPlaying)
|
||||||
|
.innerJoin(schema.users, eq(schema.users.id, schema.nowPlaying.userId))
|
||||||
|
.where(inArray(schema.nowPlaying.userId, userIds));
|
||||||
|
const tracks = await getTrackDTOs(rows.map((r) => r.np.trackId));
|
||||||
|
return rows
|
||||||
|
.map(({ np, user }) => ({
|
||||||
|
user: toUserSummary(user),
|
||||||
|
track: tracks.get(np.trackId)!,
|
||||||
|
isPlaying: np.isPlaying,
|
||||||
|
source: np.source,
|
||||||
|
progressMs: np.progressMs,
|
||||||
|
updatedAt: np.updatedAt.toISOString(),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => Number(b.isPlaying) - Number(a.isPlaying) || b.updatedAt.localeCompare(a.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishNowPlaying(userId: string) {
|
||||||
|
const [dto] = await getNowPlaying([userId]);
|
||||||
|
if (dto) await redis.publish(nowPlayingChannel(userId), JSON.stringify(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply one provider's poll result to the user's now-playing state. Returns whether they're playing. */
|
||||||
|
export async function applyPollResult(
|
||||||
|
userId: string,
|
||||||
|
source: NowPlayingProvider,
|
||||||
|
result: PollResult | null,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const [current] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, userId));
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (!result || !result.isPlaying) {
|
||||||
|
// Only the source that reported playback may mark it stopped; otherwise an
|
||||||
|
// idle Last.fm connection would clobber an active Spotify session.
|
||||||
|
if (current?.isPlaying && current.source === source) {
|
||||||
|
await db
|
||||||
|
.update(schema.nowPlaying)
|
||||||
|
.set({ isPlaying: false, updatedAt: now })
|
||||||
|
.where(eq(schema.nowPlaying.userId, userId));
|
||||||
|
await publishNowPlaying(userId);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = await upsertTrack(result.track);
|
||||||
|
const changed = !current || current.trackId !== track.id || !current.isPlaying;
|
||||||
|
const values = {
|
||||||
|
trackId: track.id,
|
||||||
|
source,
|
||||||
|
isPlaying: true,
|
||||||
|
progressMs: result.progressMs,
|
||||||
|
updatedAt: now,
|
||||||
|
...(changed ? { startedAt: now } : {}),
|
||||||
|
};
|
||||||
|
await db
|
||||||
|
.insert(schema.nowPlaying)
|
||||||
|
.values({ userId, ...values })
|
||||||
|
.onConflictDoUpdate({ target: schema.nowPlaying.userId, set: values });
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await publishNowPlaying(userId);
|
||||||
|
const [t] = await db
|
||||||
|
.select({ resolved: schema.tracks.linksResolvedAt })
|
||||||
|
.from(schema.tracks)
|
||||||
|
.where(eq(schema.tracks.id, track.id));
|
||||||
|
if (!t?.resolved) await enqueueResolve(track.id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { createRedis } from '../lib/redis';
|
||||||
|
|
||||||
|
const connection = createRedis();
|
||||||
|
|
||||||
|
export const POLL_QUEUE = 'poll';
|
||||||
|
export const RESOLVE_QUEUE = 'resolve-track';
|
||||||
|
|
||||||
|
export const pollQueue = new Queue<{ connectionId: string }>(POLL_QUEUE, { connection });
|
||||||
|
export const resolveQueue = new Queue<{ trackId: string }>(RESOLVE_QUEUE, { connection });
|
||||||
|
|
||||||
|
export function enqueueResolve(trackId: string) {
|
||||||
|
return resolveQueue.add(
|
||||||
|
'resolve',
|
||||||
|
{ trackId },
|
||||||
|
{
|
||||||
|
jobId: `resolve-${trackId}`,
|
||||||
|
attempts: 3,
|
||||||
|
backoff: { type: 'exponential', delay: 30_000 },
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: 1000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { StreamingService } from '@musicshare/shared';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import { spotifyConfigured } from '../env';
|
||||||
|
import { appleMusicFind, deezerByIsrc, deezerFind, odesliLinks, type DeezerTrack } from '../providers/catalog';
|
||||||
|
import { searchSpotify } from '../providers/spotify';
|
||||||
|
import { publishNowPlaying } from './nowPlaying';
|
||||||
|
import { addLinks } from './tracks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill in links to every streaming service for a track.
|
||||||
|
* ISRC is the backbone: Deezer gives us one for free, and Spotify can be
|
||||||
|
* searched by it exactly. Apple Music (without MusicKit) and YouTube Music
|
||||||
|
* need fuzzy matching or song.link.
|
||||||
|
*/
|
||||||
|
export async function resolveTrackLinks(trackId: string) {
|
||||||
|
const [track] = await db.select().from(schema.tracks).where(eq(schema.tracks.id, trackId));
|
||||||
|
if (!track) return;
|
||||||
|
const existing = await db.select().from(schema.trackLinks).where(eq(schema.trackLinks.trackId, trackId));
|
||||||
|
const links = new Map<StreamingService, string>(existing.map((l) => [l.service, l.url]));
|
||||||
|
let isrc = track.isrc;
|
||||||
|
|
||||||
|
// Deezer: canonical ISRC + artwork source
|
||||||
|
if (!links.has('deezer') || !isrc) {
|
||||||
|
let dz: DeezerTrack | null = isrc ? await deezerByIsrc(isrc) : null;
|
||||||
|
const matchType = dz ? 'isrc' : 'search';
|
||||||
|
dz ??= await deezerFind(track);
|
||||||
|
if (dz) {
|
||||||
|
await addLinks(trackId, { deezer: { id: String(dz.id), url: dz.link } }, matchType);
|
||||||
|
links.set('deezer', dz.link);
|
||||||
|
const patch: Partial<typeof track> = {};
|
||||||
|
if (!isrc && dz.isrc) isrc = patch.isrc = dz.isrc;
|
||||||
|
if (!track.artworkUrl) patch.artworkUrl = dz.album.cover_xl ?? dz.album.cover_big ?? null;
|
||||||
|
if (!track.durationMs) patch.durationMs = dz.duration * 1000;
|
||||||
|
if (!track.album) patch.album = dz.album.title;
|
||||||
|
if (Object.keys(patch).length) await db.update(schema.tracks).set(patch).where(eq(schema.tracks.id, trackId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!links.has('spotify') && spotifyConfigured) {
|
||||||
|
const sp = await searchSpotify(track, isrc);
|
||||||
|
const link = sp?.track.links?.spotify;
|
||||||
|
if (sp && link) {
|
||||||
|
await addLinks(trackId, { spotify: link }, sp.matchType);
|
||||||
|
links.set('spotify', link.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!links.has('apple_music')) {
|
||||||
|
const am = await appleMusicFind(track);
|
||||||
|
if (am) {
|
||||||
|
await addLinks(trackId, { apple_music: am }, 'search');
|
||||||
|
links.set('apple_music', am.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// song.link covers YouTube Music and TIDAL, which have no keyless search
|
||||||
|
if (!links.has('youtube_music') || !links.has('tidal')) {
|
||||||
|
const source = links.get('spotify') ?? links.get('deezer') ?? links.get('apple_music');
|
||||||
|
// best-effort: a song.link outage shouldn't block the links we already found
|
||||||
|
const found = source ? await odesliLinks(source).catch((e) => (console.warn('odesli failed', e.message), null)) : null;
|
||||||
|
if (found) {
|
||||||
|
const missing = Object.entries(found).filter(([s]) => !links.has(s as StreamingService));
|
||||||
|
await addLinks(trackId, Object.fromEntries(missing.map(([s, url]) => [s, { id: null, url }])), 'odesli');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(schema.tracks).set({ linksResolvedAt: new Date() }).where(eq(schema.tracks.id, trackId));
|
||||||
|
|
||||||
|
// push the new links to anyone currently looking at this track
|
||||||
|
const listeners = await db
|
||||||
|
.select({ userId: schema.nowPlaying.userId })
|
||||||
|
.from(schema.nowPlaying)
|
||||||
|
.where(eq(schema.nowPlaying.trackId, trackId));
|
||||||
|
await Promise.all(listeners.map((l) => publishNowPlaying(l.userId)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Poll scheduling. Each connection has a "next poll at" score in a Redis
|
||||||
|
* sorted set. Any number of workers can run `claimDue` concurrently: the Lua
|
||||||
|
* script claims atomically, so no leader election is needed and polling
|
||||||
|
* scales horizontally. Intervals adapt to whether anyone is watching the user,
|
||||||
|
* which is what keeps third-party API usage proportional to real engagement.
|
||||||
|
*/
|
||||||
|
import { redis } from '../lib/redis';
|
||||||
|
|
||||||
|
const DUE_KEY = 'poll:due';
|
||||||
|
const WATCHED_TTL_SECONDS = 90;
|
||||||
|
|
||||||
|
export const IDLE_POLL_SECONDS = 180;
|
||||||
|
export const PLAYING_UNWATCHED_POLL_SECONDS = 60;
|
||||||
|
|
||||||
|
export function schedulePoll(connectionId: string, delayMs = 0) {
|
||||||
|
return redis.zadd(DUE_KEY, Date.now() + delayMs, connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull forward (never push back) a connection's next poll. */
|
||||||
|
export function pollSoon(connectionIds: string[]) {
|
||||||
|
if (connectionIds.length === 0) return;
|
||||||
|
const now = Date.now();
|
||||||
|
return redis.zadd(DUE_KEY, 'LT', ...connectionIds.flatMap((id) => [now, id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add without overriding an existing schedule. Used to reconcile from the DB. */
|
||||||
|
export function ensureScheduled(connectionIds: string[]) {
|
||||||
|
if (connectionIds.length === 0) return;
|
||||||
|
const now = Date.now();
|
||||||
|
return redis.zadd(DUE_KEY, 'NX', ...connectionIds.flatMap((id) => [now, id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const unschedulePoll = (connectionId: string) => redis.zrem(DUE_KEY, connectionId);
|
||||||
|
|
||||||
|
const CLAIM_SCRIPT = `
|
||||||
|
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2])
|
||||||
|
if #ids > 0 then
|
||||||
|
-- park claimed items far in the future; the worker reschedules after polling
|
||||||
|
for _, id in ipairs(ids) do redis.call('ZADD', KEYS[1], ARGV[3], id) end
|
||||||
|
end
|
||||||
|
return ids`;
|
||||||
|
|
||||||
|
/** Claims due connections. Claimed items get a safety reschedule in case the worker dies. */
|
||||||
|
export async function claimDue(limit = 500): Promise<string[]> {
|
||||||
|
const now = Date.now();
|
||||||
|
return (await redis.eval(CLAIM_SCRIPT, 1, DUE_KEY, now, limit, now + 5 * 60_000)) as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- presence: "someone is looking at this user right now" ----
|
||||||
|
|
||||||
|
const watchedKey = (userId: string) => `watched:${userId}`;
|
||||||
|
|
||||||
|
/** Marks users as watched; returns the ones that weren't already watched. */
|
||||||
|
export async function markWatched(userIds: string[]): Promise<string[]> {
|
||||||
|
if (userIds.length === 0) return [];
|
||||||
|
const pipeline = redis.pipeline();
|
||||||
|
for (const id of userIds) pipeline.set(watchedKey(id), '1', 'EX', WATCHED_TTL_SECONDS, 'NX');
|
||||||
|
const res = await pipeline.exec();
|
||||||
|
const fresh = userIds.filter((_, i) => res?.[i]?.[1] === 'OK');
|
||||||
|
// refresh TTL on the rest
|
||||||
|
const refresh = redis.pipeline();
|
||||||
|
for (const id of userIds) refresh.expire(watchedKey(id), WATCHED_TTL_SECONDS);
|
||||||
|
await refresh.exec();
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isWatched(userId: string): Promise<boolean> {
|
||||||
|
return (await redis.exists(watchedKey(userId))) === 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { and, eq, inArray, or } from 'drizzle-orm';
|
||||||
|
import type { StreamingService, TrackDTO } from '@musicshare/shared';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import type { Track } from '../db/schema';
|
||||||
|
import { matchKey } from '../lib/http';
|
||||||
|
import type { IncomingTrack } from '../providers/types';
|
||||||
|
|
||||||
|
type LinkRow = typeof schema.trackLinks.$inferSelect;
|
||||||
|
|
||||||
|
export function toTrackDTO(track: Track, links: LinkRow[]): TrackDTO {
|
||||||
|
return {
|
||||||
|
id: track.id,
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist,
|
||||||
|
album: track.album,
|
||||||
|
artworkUrl: track.artworkUrl,
|
||||||
|
durationMs: track.durationMs,
|
||||||
|
isrc: track.isrc,
|
||||||
|
links: Object.fromEntries(links.map((l) => [l.service, l.url])) as Partial<Record<StreamingService, string>>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTrackDTOs(ids: string[]): Promise<Map<string, TrackDTO>> {
|
||||||
|
const unique = [...new Set(ids)];
|
||||||
|
if (unique.length === 0) return new Map();
|
||||||
|
const [rows, links] = await Promise.all([
|
||||||
|
db.select().from(schema.tracks).where(inArray(schema.tracks.id, unique)),
|
||||||
|
db.select().from(schema.trackLinks).where(inArray(schema.trackLinks.trackId, unique)),
|
||||||
|
]);
|
||||||
|
return new Map(rows.map((t) => [t.id, toTrackDTO(t, links.filter((l) => l.trackId === t.id))]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addLinks(
|
||||||
|
trackId: string,
|
||||||
|
links: Partial<Record<StreamingService, { id: string | null; url: string }>>,
|
||||||
|
matchType: LinkRow['matchType'],
|
||||||
|
) {
|
||||||
|
const rows = Object.entries(links).map(([service, l]) => ({
|
||||||
|
trackId,
|
||||||
|
service: service as StreamingService,
|
||||||
|
externalId: l.id,
|
||||||
|
url: l.url,
|
||||||
|
matchType,
|
||||||
|
}));
|
||||||
|
if (rows.length) await db.insert(schema.trackLinks).values(rows).onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find-or-create the canonical track for something a provider reported.
|
||||||
|
* Identity, strongest first: ISRC → a known provider ID → normalized artist|title.
|
||||||
|
*/
|
||||||
|
export async function upsertTrack(incoming: IncomingTrack): Promise<{ id: string; created: boolean }> {
|
||||||
|
const key = matchKey(incoming.artist, incoming.title);
|
||||||
|
let existing: Track | undefined;
|
||||||
|
|
||||||
|
if (incoming.isrc) {
|
||||||
|
[existing] = await db.select().from(schema.tracks).where(eq(schema.tracks.isrc, incoming.isrc)).limit(1);
|
||||||
|
}
|
||||||
|
const knownIds = Object.entries(incoming.links ?? {}).filter(([, l]) => l.id);
|
||||||
|
if (!existing && knownIds.length) {
|
||||||
|
const [hit] = await db
|
||||||
|
.select({ track: schema.tracks })
|
||||||
|
.from(schema.trackLinks)
|
||||||
|
.innerJoin(schema.tracks, eq(schema.tracks.id, schema.trackLinks.trackId))
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
...knownIds.map(([service, l]) =>
|
||||||
|
and(eq(schema.trackLinks.service, service as StreamingService), eq(schema.trackLinks.externalId, l.id!)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
existing = hit?.track;
|
||||||
|
}
|
||||||
|
if (!existing) {
|
||||||
|
[existing] = await db.select().from(schema.tracks).where(eq(schema.tracks.matchKey, key)).limit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const patch: Partial<Track> = {};
|
||||||
|
if (!existing.isrc && incoming.isrc) patch.isrc = incoming.isrc;
|
||||||
|
if (!existing.artworkUrl && incoming.artworkUrl) patch.artworkUrl = incoming.artworkUrl;
|
||||||
|
if (!existing.album && incoming.album) patch.album = incoming.album;
|
||||||
|
if (!existing.durationMs && incoming.durationMs) patch.durationMs = incoming.durationMs;
|
||||||
|
if (Object.keys(patch).length) await db.update(schema.tracks).set(patch).where(eq(schema.tracks.id, existing.id));
|
||||||
|
if (incoming.links) await addLinks(existing.id, incoming.links, 'provider');
|
||||||
|
return { id: existing.id, created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(schema.tracks)
|
||||||
|
.values({
|
||||||
|
matchKey: key,
|
||||||
|
title: incoming.title,
|
||||||
|
artist: incoming.artist,
|
||||||
|
album: incoming.album ?? null,
|
||||||
|
durationMs: incoming.durationMs ?? null,
|
||||||
|
artworkUrl: incoming.artworkUrl ?? null,
|
||||||
|
isrc: incoming.isrc ?? null,
|
||||||
|
})
|
||||||
|
.returning({ id: schema.tracks.id });
|
||||||
|
if (incoming.links) await addLinks(created!.id, incoming.links, 'provider');
|
||||||
|
return { id: created!.id, created: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { Worker } from 'bullmq';
|
||||||
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
|
import { db, schema, sql } from './db';
|
||||||
|
import { entitlementsFor } from './lib/entitlements';
|
||||||
|
import { createRedis } from './lib/redis';
|
||||||
|
import { POLLED_PROVIDERS, pollingProviders } from './providers';
|
||||||
|
import { ProviderAuthError, ProviderRateLimitError } from './providers/types';
|
||||||
|
import { applyPollResult } from './services/nowPlaying';
|
||||||
|
import { POLL_QUEUE, RESOLVE_QUEUE, pollQueue } from './services/queues';
|
||||||
|
import { resolveTrackLinks } from './services/resolver';
|
||||||
|
import {
|
||||||
|
IDLE_POLL_SECONDS,
|
||||||
|
PLAYING_UNWATCHED_POLL_SECONDS,
|
||||||
|
claimDue,
|
||||||
|
ensureScheduled,
|
||||||
|
isWatched,
|
||||||
|
schedulePoll,
|
||||||
|
unschedulePoll,
|
||||||
|
} from './services/scheduler';
|
||||||
|
|
||||||
|
const log = (...args: unknown[]) => console.log(new Date().toISOString(), ...args);
|
||||||
|
|
||||||
|
async function pollConnection(connectionId: string) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ conn: schema.connections, user: schema.users })
|
||||||
|
.from(schema.connections)
|
||||||
|
.innerJoin(schema.users, eq(schema.users.id, schema.connections.userId))
|
||||||
|
.where(eq(schema.connections.id, connectionId));
|
||||||
|
const provider = row && pollingProviders[row.conn.provider];
|
||||||
|
if (!row || row.conn.status !== 'active' || !provider) {
|
||||||
|
await unschedulePoll(connectionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { conn, user } = row;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await provider.poll(conn);
|
||||||
|
const playing = await applyPollResult(user.id, conn.provider, result);
|
||||||
|
await db
|
||||||
|
.update(schema.connections)
|
||||||
|
.set({ lastPolledAt: new Date(), lastError: null })
|
||||||
|
.where(eq(schema.connections.id, conn.id));
|
||||||
|
|
||||||
|
const seconds = (await isWatched(user.id))
|
||||||
|
? entitlementsFor(user).activePollSeconds
|
||||||
|
: playing
|
||||||
|
? PLAYING_UNWATCHED_POLL_SECONDS
|
||||||
|
: IDLE_POLL_SECONDS;
|
||||||
|
await schedulePoll(conn.id, seconds * 1000);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (err instanceof ProviderAuthError) {
|
||||||
|
log(`connection ${conn.id} (${conn.provider}) needs reconnect: ${message}`);
|
||||||
|
await db
|
||||||
|
.update(schema.connections)
|
||||||
|
.set({ status: 'error', lastError: message })
|
||||||
|
.where(eq(schema.connections.id, conn.id));
|
||||||
|
await unschedulePoll(conn.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await db.update(schema.connections).set({ lastError: message }).where(eq(schema.connections.id, conn.id));
|
||||||
|
await schedulePoll(conn.id, err instanceof ProviderRateLimitError ? err.retryAfterMs : 60_000);
|
||||||
|
if (!(err instanceof ProviderRateLimitError)) log(`poll ${conn.provider} ${conn.id} failed: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Self-heal: make sure every active connection is in the schedule (e.g. after a Redis flush). */
|
||||||
|
async function reconcile() {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: schema.connections.id })
|
||||||
|
.from(schema.connections)
|
||||||
|
.where(and(eq(schema.connections.status, 'active'), inArray(schema.connections.provider, POLLED_PROVIDERS)));
|
||||||
|
await ensureScheduled(rows.map((r) => r.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ticking = false;
|
||||||
|
async function tick() {
|
||||||
|
if (ticking) return;
|
||||||
|
ticking = true;
|
||||||
|
try {
|
||||||
|
const ids = await claimDue();
|
||||||
|
if (ids.length) {
|
||||||
|
await pollQueue.addBulk(
|
||||||
|
ids.map((connectionId) => ({
|
||||||
|
name: 'poll',
|
||||||
|
data: { connectionId },
|
||||||
|
opts: { jobId: `poll-${connectionId}`, removeOnComplete: true, removeOnFail: true },
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log('scheduler tick failed', err);
|
||||||
|
} finally {
|
||||||
|
ticking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollWorker = new Worker<{ connectionId: string }>(POLL_QUEUE, (job) => pollConnection(job.data.connectionId), {
|
||||||
|
connection: createRedis(),
|
||||||
|
concurrency: 50,
|
||||||
|
});
|
||||||
|
const resolveWorker = new Worker<{ trackId: string }>(RESOLVE_QUEUE, (job) => resolveTrackLinks(job.data.trackId), {
|
||||||
|
connection: createRedis(),
|
||||||
|
concurrency: 5,
|
||||||
|
});
|
||||||
|
for (const w of [pollWorker, resolveWorker]) w.on('failed', (job, err) => log(`${w.name} job ${job?.id} failed`, err.message));
|
||||||
|
|
||||||
|
await reconcile();
|
||||||
|
const tickTimer = setInterval(tick, 1000);
|
||||||
|
const reconcileTimer = setInterval(() => reconcile().catch((e) => log('reconcile failed', e)), 10 * 60_000);
|
||||||
|
log('worker started');
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
clearInterval(tickTimer);
|
||||||
|
clearInterval(reconcileTimer);
|
||||||
|
await Promise.all([pollWorker.close(), resolveWorker.close()]);
|
||||||
|
await sql.end();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src", "drizzle.config.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user