diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..27277c0 --- /dev/null +++ b/apps/api/.env.example @@ -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 diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts new file mode 100644 index 0000000..689c677 --- /dev/null +++ b/apps/api/drizzle.config.ts @@ -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' }, +}); diff --git a/apps/api/drizzle/0000_bright_alex_power.sql b/apps/api/drizzle/0000_bright_alex_power.sql new file mode 100644 index 0000000..f1a9a32 --- /dev/null +++ b/apps/api/drizzle/0000_bright_alex_power.sql @@ -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"); \ No newline at end of file diff --git a/apps/api/drizzle/0001_late_roland_deschain.sql b/apps/api/drizzle/0001_late_roland_deschain.sql new file mode 100644 index 0000000..bed4eaf --- /dev/null +++ b/apps/api/drizzle/0001_late_roland_deschain.sql @@ -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"); \ No newline at end of file diff --git a/apps/api/drizzle/0002_spotty_gorilla_man.sql b/apps/api/drizzle/0002_spotty_gorilla_man.sql new file mode 100644 index 0000000..c746a3c --- /dev/null +++ b/apps/api/drizzle/0002_spotty_gorilla_man.sql @@ -0,0 +1 @@ +ALTER TABLE "follows" ADD COLUMN "pinned_at" timestamp with time zone; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0000_snapshot.json b/apps/api/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..1d3c64b --- /dev/null +++ b/apps/api/drizzle/meta/0000_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0001_snapshot.json b/apps/api/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..64e89aa --- /dev/null +++ b/apps/api/drizzle/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0002_snapshot.json b/apps/api/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..7078d96 --- /dev/null +++ b/apps/api/drizzle/meta/0002_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json new file mode 100644 index 0000000..dd3deea --- /dev/null +++ b/apps/api/drizzle/meta/_journal.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..c9cf19d --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts new file mode 100644 index 0000000..976a43b --- /dev/null +++ b/apps/api/src/db/index.ts @@ -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 }; diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts new file mode 100644 index 0000000..9cd98e4 --- /dev/null +++ b/apps/api/src/db/migrate.ts @@ -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(); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts new file mode 100644 index 0000000..ba25499 --- /dev/null +++ b/apps/api/src/db/schema.ts @@ -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(), + /** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */ + plan: text('plan').$type().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().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>().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().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().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; diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts new file mode 100644 index 0000000..100d77e --- /dev/null +++ b/apps/api/src/env.ts @@ -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); diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts new file mode 100644 index 0000000..aff7309 --- /dev/null +++ b/apps/api/src/lib/auth.ts @@ -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 { + 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; +} diff --git a/apps/api/src/lib/crypto.ts b/apps/api/src/lib/crypto.ts new file mode 100644 index 0000000..655bf1b --- /dev/null +++ b/apps/api/src/lib/crypto.ts @@ -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'); diff --git a/apps/api/src/lib/entitlements.ts b/apps/api/src/lib/entitlements.ts new file mode 100644 index 0000000..1cef53b --- /dev/null +++ b/apps/api/src/lib/entitlements.ts @@ -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)]; diff --git a/apps/api/src/lib/http.ts b/apps/api/src/lib/http.ts new file mode 100644 index 0000000..f98b605 --- /dev/null +++ b/apps/api/src/lib/http.ts @@ -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(url: string, init: RequestInit = {}, timeoutMs = 8000): Promise { + 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)}`; diff --git a/apps/api/src/lib/redis.ts b/apps/api/src/lib/redis.ts new file mode 100644 index 0000000..e34cfd6 --- /dev/null +++ b/apps/api/src/lib/redis.ts @@ -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'; diff --git a/apps/api/src/lib/ssrf.ts b/apps/api/src/lib/ssrf.ts new file mode 100644 index 0000000..75f483a --- /dev/null +++ b/apps/api/src/lib/ssrf.ts @@ -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 { + 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; diff --git a/apps/api/src/providers/catalog.ts b/apps/api/src/providers/catalog.ts new file mode 100644 index 0000000..fd0a076 --- /dev/null +++ b/apps/api/src/providers/catalog.ts @@ -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 { + const t = await fetchJson(`https://api.deezer.com/track/${id}`); + return t && !t.error ? t : null; +} + +export async function deezerByIsrc(isrc: string): Promise { + return deezerTrack(`isrc:${encodeURIComponent(isrc)}`); +} + +export async function deezerSearch(q: string, limit = 10): Promise { + 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> = { + 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 { + 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> | null> { + if (!env.ODESLI_API_KEY || !(await takeOdesliToken())) return null; + try { + const res = await fetchJson<{ linksByPlatform: Record }>( + `https://api.song.link/v1-alpha.1/links?songIfSingle=true&key=${env.ODESLI_API_KEY}&url=${encodeURIComponent(sourceUrl)}`, + ); + const out: Partial> = {}; + 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; + } +} diff --git a/apps/api/src/providers/index.ts b/apps/api/src/providers/index.ts new file mode 100644 index 0000000..c87dd78 --- /dev/null +++ b/apps/api/src/providers/index.ts @@ -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> = { + spotify: spotifyProvider, + lastfm: lastfmProvider, + listenbrainz: listenbrainzProvider, + plex: plexProvider, + jellyfin: jellyfinProvider, +}; + +export const POLLED_PROVIDERS = Object.keys(pollingProviders) as NowPlayingProvider[]; diff --git a/apps/api/src/providers/jellyfin.ts b/apps/api/src/providers/jellyfin.ts new file mode 100644 index 0000000..87c81c1 --- /dev/null +++ b/apps/api/src/providers/jellyfin.ts @@ -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( + `${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), + }; + }, +}; diff --git a/apps/api/src/providers/lastfm.ts b/apps/api/src/providers/lastfm.ts new file mode 100644 index 0000000..ae42511 --- /dev/null +++ b/apps/api/src/providers/lastfm.ts @@ -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) => + `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, + }; + }, +}; diff --git a/apps/api/src/providers/listenbrainz.ts b/apps/api/src/providers/listenbrainz.ts new file mode 100644 index 0000000..ca9b913 --- /dev/null +++ b/apps/api/src/providers/listenbrainz.ts @@ -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(`https://api.listenbrainz.org/1/user/${encodeURIComponent(username)}/playing-now`, {}, 15_000); + +export async function listenbrainzUserExists(username: string): Promise { + 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, + }; + }, +}; diff --git a/apps/api/src/providers/match.ts b/apps/api/src/providers/match.ts new file mode 100644 index 0000000..650e7e6 --- /dev/null +++ b/apps/api/src/providers/match.ts @@ -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( + 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; +} diff --git a/apps/api/src/providers/plex.ts b/apps/api/src/providers/plex.ts new file mode 100644 index 0000000..6956642 --- /dev/null +++ b/apps/api/src/providers/plex.ts @@ -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 => ({ + 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 { + 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 { + let resources: PlexResource[] | null; + try { + resources = await fetchJson(`${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 { + 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; + }, +}; diff --git a/apps/api/src/providers/spotify.ts b/apps/api/src/providers/spotify.ts new file mode 100644 index 0000000..e327311 --- /dev/null +++ b/apps/api/src/providers/spotify.ts @@ -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): Promise { + 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 { + 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(path: string, token: string): Promise { + 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 { + 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; +} diff --git a/apps/api/src/providers/types.ts b/apps/api/src/providers/types.ts new file mode 100644 index 0000000..03db092 --- /dev/null +++ b/apps/api/src/providers/types.ts @@ -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>; +} + +export interface PollResult { + track: IncomingTrack; + isPlaying: boolean; + progressMs: number | null; +} + +export interface PollingProvider { + id: NowPlayingProvider; + /** null = nothing playing */ + poll(conn: Connection): Promise; +} + +/** 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`); + } +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 0000000..7381e82 --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -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 }; + }); +} diff --git a/apps/api/src/routes/connections.ts b/apps/api/src/routes/connections.ts new file mode 100644 index 0000000..1cb4ecd --- /dev/null +++ b/apps/api/src/routes/connections.ts @@ -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): 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 }; + }); +} diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts new file mode 100644 index 0000000..04db8b3 --- /dev/null +++ b/apps/api/src/routes/me.ts @@ -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 }; + }); +} diff --git a/apps/api/src/routes/realtime.ts b/apps/api/src/routes/realtime.ts new file mode 100644 index 0000000..5902d37 --- /dev/null +++ b/apps/api/src/routes/realtime.ts @@ -0,0 +1,85 @@ +/** + * Live now-playing updates over WebSocket. + * + * Workers publish to Redis channel `np:`; 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>(); // watched userId -> sockets +const socketsByOwner = new Map>(); // viewer userId -> their sockets +const watching = new Map>(); + +function addTo(map: Map>, key: K, value: V) { + let set = map.get(key); + if (!set) map.set(key, (set = new Set())); + set.add(value); +} +function removeFrom(map: Map>, 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); + }); + }); +} diff --git a/apps/api/src/routes/tracks.ts b/apps/api/src/routes/tracks.ts new file mode 100644 index 0000000..c993fb3 --- /dev/null +++ b/apps/api/src/routes/tracks.ts @@ -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 => { + 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); + }); +} diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts new file mode 100644 index 0000000..c9abd51 --- /dev/null +++ b/apps/api/src/routes/users.ts @@ -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 => { + 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 => { + 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 }))); + }); +} diff --git a/apps/api/src/routes/webhooks.ts b/apps/api/src/routes/webhooks.ts new file mode 100644 index 0000000..c57694a --- /dev/null +++ b/apps/api/src/routes/webhooks.ts @@ -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(); + }, + ); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..fc46284 --- /dev/null +++ b/apps/api/src/server.ts @@ -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' }); diff --git a/apps/api/src/services/connections.ts b/apps/api/src/services/connections.ts new file mode 100644 index 0000000..94b7b6d --- /dev/null +++ b/apps/api/src/services/connections.ts @@ -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, +) { + 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)); +} diff --git a/apps/api/src/services/nowPlaying.ts b/apps/api/src/services/nowPlaying.ts new file mode 100644 index 0000000..999f0ea --- /dev/null +++ b/apps/api/src/services/nowPlaying.ts @@ -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): UserSummaryDTO => ({ + id: u.id, + username: u.username, + displayName: u.displayName, + avatarUrl: u.avatarUrl, +}); + +export async function getNowPlaying(userIds: string[]): Promise { + 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 { + 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; +} diff --git a/apps/api/src/services/queues.ts b/apps/api/src/services/queues.ts new file mode 100644 index 0000000..425fc0a --- /dev/null +++ b/apps/api/src/services/queues.ts @@ -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, + }, + ); +} diff --git a/apps/api/src/services/resolver.ts b/apps/api/src/services/resolver.ts new file mode 100644 index 0000000..b56dbac --- /dev/null +++ b/apps/api/src/services/resolver.ts @@ -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(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 = {}; + 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))); +} diff --git a/apps/api/src/services/scheduler.ts b/apps/api/src/services/scheduler.ts new file mode 100644 index 0000000..ff179d8 --- /dev/null +++ b/apps/api/src/services/scheduler.ts @@ -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 { + 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 { + 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 { + return (await redis.exists(watchedKey(userId))) === 1; +} diff --git a/apps/api/src/services/tracks.ts b/apps/api/src/services/tracks.ts new file mode 100644 index 0000000..b426022 --- /dev/null +++ b/apps/api/src/services/tracks.ts @@ -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>, + }; +} + +export async function getTrackDTOs(ids: string[]): Promise> { + 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>, + 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 = {}; + 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 }; +} diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts new file mode 100644 index 0000000..ccaaa71 --- /dev/null +++ b/apps/api/src/worker.ts @@ -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); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..a95f659 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src", "drizzle.config.ts"] +}