diff --git a/apps/api/drizzle/0004_late_proudstar.sql b/apps/api/drizzle/0004_late_proudstar.sql new file mode 100644 index 0000000..97b8ac2 --- /dev/null +++ b/apps/api/drizzle/0004_late_proudstar.sql @@ -0,0 +1,14 @@ +CREATE TABLE "devices" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "token_hash" text NOT NULL, + "platform" text NOT NULL, + "name" varchar(100) NOT NULL, + "last_seen_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "devices_token_hash_unique" UNIQUE("token_hash") +); +--> statement-breakpoint +ALTER TABLE "now_playing" ADD COLUMN "source_app" text;--> statement-breakpoint +ALTER TABLE "devices" ADD CONSTRAINT "devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "devices_user_idx" ON "devices" USING btree ("user_id"); \ No newline at end of file diff --git a/apps/api/drizzle/0005_cooing_thena.sql b/apps/api/drizzle/0005_cooing_thena.sql new file mode 100644 index 0000000..4586d29 --- /dev/null +++ b/apps/api/drizzle/0005_cooing_thena.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "sharing_paused_until" timestamp with time zone; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0004_snapshot.json b/apps/api/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..03c684c --- /dev/null +++ b/apps/api/drizzle/meta/0004_snapshot.json @@ -0,0 +1,978 @@ +{ + "id": "362955f3-fb83-46da-ab8e-e73eeaf805eb", + "prevId": "fb3fef85-3893-4f8d-b4b9-3fab12952d4e", + "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.devices": { + "name": "devices", + "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 + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_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": { + "devices_user_idx": { + "name": "devices_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_users_id_fk": { + "name": "devices_user_id_users_id_fk", + "tableFrom": "devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "devices_token_hash_unique": { + "name": "devices_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "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.listens": { + "name": "listens", + "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 + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listens_user_started_idx": { + "name": "listens_user_started_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "listens_user_id_users_id_fk": { + "name": "listens_user_id_users_id_fk", + "tableFrom": "listens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listens_track_id_tracks_id_fk": { + "name": "listens_track_id_tracks_id_fk", + "tableFrom": "listens", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "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 + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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/0005_snapshot.json b/apps/api/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..2af9142 --- /dev/null +++ b/apps/api/drizzle/meta/0005_snapshot.json @@ -0,0 +1,984 @@ +{ + "id": "96e26cc1-ef9f-4bdb-9889-54a20705d43f", + "prevId": "362955f3-fb83-46da-ab8e-e73eeaf805eb", + "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.devices": { + "name": "devices", + "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 + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_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": { + "devices_user_idx": { + "name": "devices_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_users_id_fk": { + "name": "devices_user_id_users_id_fk", + "tableFrom": "devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "devices_token_hash_unique": { + "name": "devices_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "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.listens": { + "name": "listens", + "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 + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listens_user_started_idx": { + "name": "listens_user_started_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "listens_user_id_users_id_fk": { + "name": "listens_user_id_users_id_fk", + "tableFrom": "listens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listens_track_id_tracks_id_fk": { + "name": "listens_track_id_tracks_id_fk", + "tableFrom": "listens", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "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 + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "sharing_paused_until": { + "name": "sharing_paused_until", + "type": "timestamp with time zone", + "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 index e7e4ccc..922aa76 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -29,6 +29,20 @@ "when": 1791057194458, "tag": "0003_small_taskmaster", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1791058721937, + "tag": "0004_late_proudstar", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1791059998457, + "tag": "0005_cooing_thena", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 151e2c0..77e4ad6 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -11,6 +11,7 @@ import { HttpError } from './lib/http'; import { redis } from './lib/redis'; import { authRoutes } from './routes/auth'; import { connectionRoutes } from './routes/connections'; +import { deviceRoutes } from './routes/devices'; import { meRoutes } from './routes/me'; import { realtimeRoutes } from './routes/realtime'; import { trackRoutes } from './routes/tracks'; @@ -56,6 +57,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?: await app.register(userRoutes); await app.register(trackRoutes); await app.register(connectionRoutes); + await app.register(deviceRoutes); await app.register(realtimeRoutes); // registered as its own plugin so its permissive body parsers stay scoped to webhook routes await app.register(webhookRoutes); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 8ff889e..bd4bd16 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -26,6 +26,8 @@ export const users = pgTable('users', { 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 */ + /** While in the future, nothing new is shared: services aren't polled and phone reports are dropped. */ + sharingPausedUntil: timestamp('sharing_paused_until', { withTimezone: true }), plan: text('plan').$type().notNull().default('free'), planExpiresAt: timestamp('plan_expires_at', { withTimezone: true }), createdAt: createdAt(), @@ -134,6 +136,8 @@ export const nowPlaying = pgTable( .notNull() .references(() => tracks.id), source: text('source').$type().notNull(), + /** App name when a phone reported it, e.g. "Spotify". */ + sourceApp: text('source_app'), isPlaying: boolean('is_playing').notNull(), progressMs: integer('progress_ms'), startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(), @@ -142,6 +146,24 @@ export const nowPlaying = pgTable( (t) => [index('now_playing_track_idx').on(t.trackId)], ); +/** Phones that report playback directly (Android media sessions). Each has its own revocable token. */ +export const devices = pgTable( + 'devices', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + /** sha256 of the device token; the token can only report playback. */ + tokenHash: text('token_hash').notNull().unique(), + platform: text('platform').$type<'android' | 'ios'>().notNull(), + name: varchar('name', { length: 100 }).notNull(), + lastSeenAt: timestamp('last_seen_at', { withTimezone: true }), + createdAt: createdAt(), + }, + (t) => [index('devices_user_idx').on(t.userId)], +); + /** Recent plays per user, for "Recently played" on profiles. Trimmed to the latest LISTEN_HISTORY_LIMIT. */ export const listens = pgTable( 'listens', diff --git a/apps/api/src/routes/devices.ts b/apps/api/src/routes/devices.ts new file mode 100644 index 0000000..b07d431 --- /dev/null +++ b/apps/api/src/routes/devices.ts @@ -0,0 +1,122 @@ +/** + * Phones that report playback themselves (the Android app reads system media + * sessions). Each phone registers once and gets a device token that can only + * call POST /devices/now-playing, so a leaked token can't read or change + * anything else, and the user can revoke a single phone. + */ +import { and, desc, eq, notInArray } from 'drizzle-orm'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { ANDROID_MUSIC_APPS, devicePlaybackSchema, registerDeviceSchema, type DeviceDTO } from '@musicshare/shared'; +import { db, schema } from '../db'; +import { requireUser } from '../lib/auth'; +import { randomToken, sha256 } from '../lib/crypto'; +import { HttpError } from '../lib/http'; +import { applyPollResult, isSharingPaused } from '../services/nowPlaying'; + +const MAX_DEVICES_PER_USER = 10; +export const DEVICE_TOKEN_PREFIX = 'dev_'; + +type Device = typeof schema.devices.$inferSelect; + +const toDeviceDTO = (d: Device): DeviceDTO => ({ + id: d.id, + platform: d.platform, + name: d.name, + lastSeenAt: d.lastSeenAt?.toISOString() ?? null, + createdAt: d.createdAt.toISOString(), +}); + +function deviceToken(req: FastifyRequest): string | null { + const header = req.headers.authorization; + const token = header?.startsWith('Bearer ') ? header.slice(7) : null; + return token?.startsWith(DEVICE_TOKEN_PREFIX) ? token : null; +} + +export async function deviceRoutes(app: FastifyInstance) { + app.get('/me/devices', async (req) => { + const user = requireUser(req); + const rows = await db + .select() + .from(schema.devices) + .where(eq(schema.devices.userId, user.id)) + .orderBy(desc(schema.devices.createdAt)); + return rows.map(toDeviceDTO); + }); + + app.post('/me/devices', async (req) => { + const user = requireUser(req); + const input = registerDeviceSchema.parse(req.body); + const token = DEVICE_TOKEN_PREFIX + randomToken(32); + const [device] = await db + .insert(schema.devices) + .values({ userId: user.id, tokenHash: sha256(token), platform: input.platform, name: input.name }) + .returning(); + + // Reinstalls register a new device; keep only the newest few. + const keep = await db + .select({ id: schema.devices.id }) + .from(schema.devices) + .where(eq(schema.devices.userId, user.id)) + .orderBy(desc(schema.devices.createdAt)) + .limit(MAX_DEVICES_PER_USER); + await db.delete(schema.devices).where( + and( + eq(schema.devices.userId, user.id), + notInArray( + schema.devices.id, + keep.map((k) => k.id), + ), + ), + ); + return { device: toDeviceDTO(device!), token }; + }); + + app.delete<{ Params: { id: string } }>('/me/devices/:id', async (req) => { + const user = requireUser(req); + if (!/^[0-9a-f-]{36}$/i.test(req.params.id)) throw new HttpError(404, 'Device not found'); + await db.delete(schema.devices).where(and(eq(schema.devices.id, req.params.id), eq(schema.devices.userId, user.id))); + return { ok: true }; + }); + + app.post( + '/devices/now-playing', + { + config: { + rateLimit: { + max: 120, + timeWindow: '1 minute', + keyGenerator: (req: FastifyRequest) => `device:${sha256(deviceToken(req) ?? req.ip)}`, + }, + }, + }, + async (req, reply) => { + const token = deviceToken(req); + const [device] = token + ? await db.select().from(schema.devices).where(eq(schema.devices.tokenHash, sha256(token))) + : []; + if (!device) throw new HttpError(401, 'Unknown device'); + + const input = devicePlaybackSchema.parse(req.body); + await db.update(schema.devices).set({ lastSeenAt: new Date() }).where(eq(schema.devices.id, device.id)); + const [owner] = await db.select().from(schema.users).where(eq(schema.users.id, device.userId)); + if (!owner || isSharingPaused(owner)) return reply.status(204).send(); // accepted, not shared + const sourceApp = ANDROID_MUSIC_APPS[input.app] ?? input.appName ?? null; + const track = input.track + ? { + title: input.track.title, + artist: input.track.artist || 'Unknown artist', + album: input.track.album ?? null, + durationMs: input.track.durationMs ?? null, + } + : null; + + await applyPollResult( + device.userId, + 'android_media_session', + track ? { track, isPlaying: input.state === 'playing', progressMs: input.positionMs ?? null } : null, + { sourceApp }, + ); + return reply.status(204).send(); + }, + ); +} diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 04db8b3..bce7418 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -1,6 +1,6 @@ import { eq } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; -import { setFeaturedSchema, updateMeSchema, type MeDTO } from '@musicshare/shared'; +import { setFeaturedSchema, setSharingSchema, updateMeSchema, type MeDTO } from '@musicshare/shared'; import { db, schema } from '../db'; import type { User } from '../db/schema'; import { SESSION_COOKIE, requireUser } from '../lib/auth'; @@ -8,7 +8,9 @@ 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 { connectionIdsForUser } from '../services/connections'; +import { isSharingPaused, pauseSharing } from '../services/nowPlaying'; +import { pollSoon, unschedulePoll } from '../services/scheduler'; import { upsertTrack } from '../services/tracks'; export const toMeDTO = (u: User): MeDTO => ({ @@ -19,6 +21,7 @@ export const toMeDTO = (u: User): MeDTO => ({ email: u.email, bio: u.bio, preferredService: u.preferredService, + sharingPausedUntil: isSharingPaused(u) ? u.sharingPausedUntil!.toISOString() : null, plan: effectivePlan(u), entitlements: entitlementsFor(u), }); @@ -46,6 +49,21 @@ export async function meRoutes(app: FastifyInstance) { return { ok: true }; }); + app.put('/me/sharing', async (req) => { + const user = requireUser(req); + const { pause } = setSharingSchema.parse(req.body); + const hours = { '1h': 1, '8h': 8, indefinitely: 24 * 365 * 100 } as const; + const until = pause ? new Date(Date.now() + hours[pause] * 3600_000) : null; + const [updated] = await db + .update(schema.users) + .set({ sharingPausedUntil: until }) + .where(eq(schema.users.id, user.id)) + .returning(); + if (until) await pauseSharing(user.id); + else await pollSoon(await connectionIdsForUser(user.id)); // pick up what's playing right away + return toMeDTO(updated!); + }); + app.put('/me/featured', async (req) => { const user = requireUser(req); const input = setFeaturedSchema.parse(req.body); diff --git a/apps/api/src/services/connections.ts b/apps/api/src/services/connections.ts index 94b7b6d..26cb07d 100644 --- a/apps/api/src/services/connections.ts +++ b/apps/api/src/services/connections.ts @@ -50,3 +50,11 @@ export async function watchUsers(userIds: string[]) { ); await pollSoon(rows.map((r) => r.id)); } + +export async function connectionIdsForUser(userId: string): Promise { + const rows = await db + .select({ id: schema.connections.id }) + .from(schema.connections) + .where(and(eq(schema.connections.userId, userId), eq(schema.connections.status, 'active'))); + return rows.map((r) => r.id); +} diff --git a/apps/api/src/services/nowPlaying.ts b/apps/api/src/services/nowPlaying.ts index 4ea95eb..b5cdd7d 100644 --- a/apps/api/src/services/nowPlaying.ts +++ b/apps/api/src/services/nowPlaying.ts @@ -14,6 +14,10 @@ export const toUserSummary = (u: Pick(['android_media_session']); + export async function getNowPlaying(userIds: string[]): Promise { if (userIds.length === 0) return []; const rows = await db @@ -26,8 +30,9 @@ export async function getNowPlaying(userIds: string[]): Promise .map(({ np, user }) => ({ user: toUserSummary(user), track: tracks.get(np.trackId)!, - isPlaying: np.isPlaying, + isPlaying: np.isPlaying && !(PUSH_SOURCES.has(np.source) && Date.now() - np.updatedAt.getTime() > DEVICE_STALE_MS), source: np.source, + sourceApp: np.sourceApp, progressMs: np.progressMs, updatedAt: np.updatedAt.toISOString(), })) @@ -68,6 +73,19 @@ export async function getRecentPlays(userId: string, limit = 10): Promise ({ track: tracks.get(p.trackId)!, source: p.source, playedAt: p.startedAt.toISOString() })); } +export const isSharingPaused = (user: Pick) => + Boolean(user.sharingPausedUntil && user.sharingPausedUntil > new Date()); + +/** Starting a pause: whatever was playing becomes "played … ago" for friends. */ +export async function pauseSharing(userId: string) { + const updated = await db + .update(schema.nowPlaying) + .set({ isPlaying: false, updatedAt: new Date() }) + .where(and(eq(schema.nowPlaying.userId, userId), eq(schema.nowPlaying.isPlaying, true))) + .returning({ userId: schema.nowPlaying.userId }); + if (updated.length) await publishNowPlaying(userId); +} + export async function publishNowPlaying(userId: string) { const [dto] = await getNowPlaying([userId]); if (dto) await redis.publish(nowPlayingChannel(userId), JSON.stringify(dto)); @@ -78,6 +96,7 @@ export async function applyPollResult( userId: string, source: NowPlayingProvider, result: PollResult | null, + opts: { sourceApp?: string | null } = {}, ): Promise { const [current] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, userId)); const now = new Date(); @@ -102,6 +121,7 @@ export async function applyPollResult( const values = { trackId: track.id, source, + sourceApp: opts.sourceApp ?? null, isPlaying: true, progressMs: result.progressMs, updatedAt: now, diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 8aeaec1..fd6c58f 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -6,7 +6,7 @@ import { closeConnections } from './lib/lifecycle'; import { createRedis } from './lib/redis'; import { POLLED_PROVIDERS, pollingProviders } from './providers'; import { ProviderAuthError, ProviderRateLimitError } from './providers/types'; -import { applyPollResult } from './services/nowPlaying'; +import { applyPollResult, isSharingPaused } from './services/nowPlaying'; import { POLL_QUEUE, RESOLVE_QUEUE, pollQueue } from './services/queues'; import { resolveTrackLinks } from './services/resolver'; import { @@ -34,6 +34,13 @@ async function pollConnection(connectionId: string) { } const { conn, user } = row; + if (isSharingPaused(user)) { + // Don't even ask the provider while paused; check back when the pause ends. + const resumeIn = user.sharingPausedUntil!.getTime() - Date.now(); + await schedulePoll(conn.id, Math.min(resumeIn, IDLE_POLL_SECONDS * 1000)); + return; + } + try { const result = await provider.poll(conn); const playing = await applyPollResult(user.id, conn.provider, result); diff --git a/apps/api/test/devices.test.ts b/apps/api/test/devices.test.ts new file mode 100644 index 0000000..6a8c9ef --- /dev/null +++ b/apps/api/test/devices.test.ts @@ -0,0 +1,122 @@ +import { eq } from 'drizzle-orm'; +import { describe, expect, it } from 'vitest'; +import { db, schema } from '../src/db'; +import { DEVICE_STALE_MS } from '../src/services/nowPlaying'; +import { bearer, createUser, follow, useTestApp } from './helpers'; + +const app = useTestApp(); + +async function registerDevice(token: string) { + const res = await app().inject({ method: 'POST', url: '/me/devices', headers: bearer(token), payload: { platform: 'android', name: 'Pixel 9' } }); + expect(res.statusCode).toBe(200); + return res.json() as { device: { id: string }; token: string }; +} + +const report = (deviceToken: string, payload: object) => + app().inject({ method: 'POST', url: '/devices/now-playing', headers: bearer(deviceToken), payload }); + +const spotify = (state: 'playing' | 'paused' | 'stopped', title = 'Atmosphere') => ({ + app: 'com.spotify.music', + state, + track: state === 'stopped' ? undefined : { title, artist: 'Joy Division', album: 'Closer', durationMs: 250_000 }, + positionMs: 1000, +}); + +describe('device playback reporting', () => { + it('registers a device and returns a one-time token, storing only its hash', async () => { + const { token } = await createUser(app(), 'alice'); + const { device, token: deviceToken } = await registerDevice(token); + expect(deviceToken).toMatch(/^dev_/); + const [row] = await db.select().from(schema.devices).where(eq(schema.devices.id, device.id)); + expect(row!.tokenHash).not.toContain(deviceToken); + expect((await app().inject({ url: '/me/devices', headers: bearer(token) })).json()).toHaveLength(1); + }); + + it('reports playback as "via Spotify" and friends see it', async () => { + const alice = await createUser(app(), 'alice'); + const bob = await createUser(app(), 'bob'); + await follow(app(), bob.token, 'alice'); + const { token: deviceToken } = await registerDevice(alice.token); + + expect((await report(deviceToken, spotify('playing'))).statusCode).toBe(204); + const [item] = (await app().inject({ url: '/feed', headers: bearer(bob.token) })).json(); + expect(item).toMatchObject({ + user: { username: 'alice' }, + track: { title: 'Atmosphere', artist: 'Joy Division', album: 'Closer', durationMs: 250_000 }, + isPlaying: true, + source: 'android_media_session', + sourceApp: 'Spotify', + }); + }); + + it('uses the OS app name for apps it does not know', async () => { + const { token, user } = await createUser(app(), 'alice'); + const { token: deviceToken } = await registerDevice(token); + await report(deviceToken, { ...spotify('playing'), app: 'com.example.player', appName: 'Example Player' }); + const [np] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, user.id)); + expect(np!.sourceApp).toBe('Example Player'); + }); + + it('pause and stop end "listening now"', async () => { + const { token } = await createUser(app(), 'alice'); + const { token: deviceToken } = await registerDevice(token); + const isPlaying = async () => (await app().inject({ url: '/users/alice' })).json().nowPlaying.isPlaying; + + await report(deviceToken, spotify('playing')); + expect(await isPlaying()).toBe(true); + await report(deviceToken, spotify('paused')); + expect(await isPlaying()).toBe(false); + await report(deviceToken, spotify('playing')); + await report(deviceToken, { app: 'com.spotify.music', state: 'stopped' }); + expect(await isPlaying()).toBe(false); + }); + + it('stops showing "listening now" if the phone goes quiet', async () => { + const { token, user } = await createUser(app(), 'alice'); + const { token: deviceToken } = await registerDevice(token); + await report(deviceToken, spotify('playing')); + await db + .update(schema.nowPlaying) + .set({ updatedAt: new Date(Date.now() - DEVICE_STALE_MS - 1000) }) + .where(eq(schema.nowPlaying.userId, user.id)); + expect((await app().inject({ url: '/users/alice' })).json().nowPlaying.isPlaying).toBe(false); + }); + + it('device tokens cannot be used as a login, and user tokens cannot report', async () => { + const { token } = await createUser(app(), 'alice'); + const { token: deviceToken } = await registerDevice(token); + expect((await app().inject({ url: '/me', headers: bearer(deviceToken) })).statusCode).toBe(401); + expect((await report(token, spotify('playing'))).statusCode).toBe(401); + expect((await report('dev_bogus', spotify('playing'))).statusCode).toBe(401); + }); + + it('revoking a device stops its token working', async () => { + const { token } = await createUser(app(), 'alice'); + const { device, token: deviceToken } = await registerDevice(token); + await app().inject({ method: 'DELETE', url: `/me/devices/${device.id}`, headers: bearer(token) }); + expect((await report(deviceToken, spotify('playing'))).statusCode).toBe(401); + }); + + it("can't revoke someone else's device", async () => { + const alice = await createUser(app(), 'alice'); + const mallory = await createUser(app(), 'mallory'); + const { device, token: deviceToken } = await registerDevice(alice.token); + await app().inject({ method: 'DELETE', url: `/me/devices/${device.id}`, headers: bearer(mallory.token) }); + expect((await report(deviceToken, spotify('playing'))).statusCode).toBe(204); + }); + + it('keeps only the 10 newest devices per user', async () => { + const { token } = await createUser(app(), 'alice'); + const first = await registerDevice(token); + for (let i = 0; i < 10; i++) await registerDevice(token); + expect((await app().inject({ url: '/me/devices', headers: bearer(token) })).json()).toHaveLength(10); + expect((await report(first.token, spotify('playing'))).statusCode).toBe(401); + }); + + it('rejects malformed reports', async () => { + const { token } = await createUser(app(), 'alice'); + const { token: deviceToken } = await registerDevice(token); + expect((await report(deviceToken, { app: 'com.spotify.music', state: 'playing' })).statusCode).toBe(400); + expect((await report(deviceToken, { ...spotify('playing'), track: { title: '', artist: 'x' } })).statusCode).toBe(400); + }); +}); diff --git a/apps/api/test/helpers.ts b/apps/api/test/helpers.ts index d9d1b8d..bba0880 100644 --- a/apps/api/test/helpers.ts +++ b/apps/api/test/helpers.ts @@ -17,7 +17,7 @@ function assertTestTargets() { export async function resetState() { assertTestTargets(); - await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens restart identity cascade`; + await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices restart identity cascade`; await redis.flushdb(); } diff --git a/apps/api/test/pause.test.ts b/apps/api/test/pause.test.ts new file mode 100644 index 0000000..2244086 --- /dev/null +++ b/apps/api/test/pause.test.ts @@ -0,0 +1,81 @@ +import { eq } from 'drizzle-orm'; +import { describe, expect, it } from 'vitest'; +import { db, schema } from '../src/db'; +import { redis } from '../src/lib/redis'; +import { applyPollResult } from '../src/services/nowPlaying'; +import { upsertConnection } from '../src/services/connections'; +import { bearer, createUser, useTestApp } from './helpers'; + +const app = useTestApp(); + +const setSharing = (token: string, pause: string | null) => + app().inject({ method: 'PUT', url: '/me/sharing', headers: bearer(token), payload: { pause } }); + +async function deviceFor(token: string) { + const res = await app().inject({ method: 'POST', url: '/me/devices', headers: bearer(token), payload: { platform: 'android', name: 'Pixel' } }); + return res.json().token as string; +} +const report = (deviceToken: string, title: string) => + app().inject({ + method: 'POST', + url: '/devices/now-playing', + headers: bearer(deviceToken), + payload: { app: 'com.spotify.music', state: 'playing', track: { title, artist: 'New Order' } }, + }); + +describe('pausing sharing', () => { + it.each([ + ['1h', 3600_000], + ['8h', 8 * 3600_000], + ])('pause %s sets a resume time', async (pause, ms) => { + const { token } = await createUser(app(), 'alice'); + const me = (await setSharing(token, pause)).json(); + const until = new Date(me.sharingPausedUntil).getTime(); + expect(Math.abs(until - (Date.now() + ms))).toBeLessThan(5000); + }); + + it('turns what was playing into "played … ago" immediately', async () => { + const { token, user } = await createUser(app(), 'alice'); + await applyPollResult(user.id, 'spotify', { track: { title: 'Ceremony', artist: 'New Order' }, isPlaying: true, progressMs: 0 }); + await setSharing(token, 'indefinitely'); + expect((await app().inject({ url: '/users/alice' })).json().nowPlaying.isPlaying).toBe(false); + }); + + it('drops phone reports and records no history while paused, then shares again on resume', async () => { + const { token } = await createUser(app(), 'alice'); + const deviceToken = await deviceFor(token); + await setSharing(token, 'indefinitely'); + + expect((await report(deviceToken, 'Temptation')).statusCode).toBe(204); + let profile = (await app().inject({ url: '/users/alice' })).json(); + expect(profile.nowPlaying).toBeNull(); + expect(profile.recent).toEqual([]); + + expect((await setSharing(token, null)).json().sharingPausedUntil).toBeNull(); + await report(deviceToken, 'Temptation'); + profile = (await app().inject({ url: '/users/alice' })).json(); + expect(profile.nowPlaying).toMatchObject({ track: { title: 'Temptation' }, isPlaying: true }); + }); + + it('resuming polls connections right away', async () => { + const { token, user } = await createUser(app(), 'alice'); + const conn = await upsertConnection({ userId: user.id, provider: 'lastfm', externalUserId: 'x', displayName: null }); + await setSharing(token, 'indefinitely'); + await redis.zadd('poll:due', Date.now() + 600_000, conn.id); + await setSharing(token, null); + expect(Number(await redis.zscore('poll:due', conn.id))).toBeLessThanOrEqual(Date.now()); + }); + + it('an expired pause counts as sharing', async () => { + const { token, user } = await createUser(app(), 'alice'); + await db.update(schema.users).set({ sharingPausedUntil: new Date(Date.now() - 1000) }).where(eq(schema.users.id, user.id)); + expect((await app().inject({ url: '/me', headers: bearer(token) })).json().sharingPausedUntil).toBeNull(); + await report(await deviceFor(token), 'Blue Monday'); + expect((await app().inject({ url: '/users/alice' })).json().nowPlaying.isPlaying).toBe(true); + }); + + it('rejects unknown durations', async () => { + const { token } = await createUser(app(), 'alice'); + expect((await setSharing(token, 'forever')).statusCode).toBe(400); + }); +}); diff --git a/packages/shared/src/client.ts b/packages/shared/src/client.ts index 13e07a8..8fb771c 100644 --- a/packages/shared/src/client.ts +++ b/packages/shared/src/client.ts @@ -6,6 +6,7 @@ import type { TrackSearchResultDTO, UserSummaryDTO, } from './dto'; +import type { DeviceDTO } from './devices'; import type { LoginInput, RegisterInput, SetFeaturedInput, UpdateMeInput } from './schemas'; export class ApiError extends Error { @@ -59,6 +60,7 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt me: () => request('/me'), updateMe: (input: UpdateMeInput) => request('/me', { method: 'PATCH', body: input }), deleteAccount: () => request('/me', { method: 'DELETE' }), + setSharing: (pause: '1h' | '8h' | 'indefinitely' | null) => request('/me/sharing', { method: 'PUT', body: { pause } }), setFeatured: (input: SetFeaturedInput) => request('/me/featured', { method: 'PUT', body: input }), clearFeatured: () => request('/me/featured', { method: 'DELETE' }), @@ -80,6 +82,12 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt connectJellyfin: (input: { serverUrl: string; username: string; password: string }) => request('/connections/jellyfin', { body: input }), disconnect: (provider: string) => request(`/connections/${q(provider)}`, { method: 'DELETE' }), + + devices: () => request('/me/devices'), + /** The token is returned once; it can only report playback for this user. */ + registerDevice: (input: { platform: 'android' | 'ios'; name: string }) => + request<{ device: DeviceDTO; token: string }>('/me/devices', { body: input }), + removeDevice: (id: string) => request(`/me/devices/${q(id)}`, { method: 'DELETE' }), }; } diff --git a/packages/shared/src/devices.ts b/packages/shared/src/devices.ts new file mode 100644 index 0000000..218cb13 --- /dev/null +++ b/packages/shared/src/devices.ts @@ -0,0 +1,63 @@ +import { z } from 'zod'; + +/** + * Android music apps whose media sessions we share by default (package → display name). + * Anything else (video, podcasts, browsers) is ignored unless the user opts in, so + * friends don't see YouTube videos or audiobooks in their feed. + */ +export const ANDROID_MUSIC_APPS: Record = { + 'com.spotify.music': 'Spotify', + 'com.google.android.apps.youtube.music': 'YouTube Music', + 'com.apple.android.music': 'Apple Music', + 'com.aspiro.tidal': 'TIDAL', + 'deezer.android.app': 'Deezer', + 'com.soundcloud.android': 'SoundCloud', + 'com.amazon.mp3': 'Amazon Music', + 'com.pandora.android': 'Pandora', + 'com.qobuz.music': 'Qobuz', + 'tv.plex.labs.plexamp': 'Plexamp', + 'com.unicornsonlsd.finamp': 'Finamp', + 'app.symfonium.android': 'Symfonium', + 'com.bandcamp.android': 'Bandcamp', + 'com.anghami': 'Anghami', + 'com.jio.media.jiobeats': 'JioSaavn', + 'com.maxmpz.audioplayer': 'Poweramp', + 'org.videolan.vlc': 'VLC', + 'com.kapp.youtube.final': 'YMusic', + 'app.revanced.android.apps.youtube.music': 'YouTube Music', +}; + +export const registerDeviceSchema = z.object({ + platform: z.enum(['android', 'ios']), + name: z.string().trim().min(1).max(100), +}); + +const reportedTrack = z.object({ + title: z.string().trim().min(1).max(500), + artist: z.string().trim().max(500), + album: z.string().trim().max(500).nullish(), + durationMs: z.number().int().positive().max(24 * 3600_000).nullish(), +}); + +/** What a phone reports when playback changes in a music app. */ +export const devicePlaybackSchema = z + .object({ + /** Android package name (or iOS bundle id) of the playing app. */ + app: z.string().trim().min(1).max(200), + /** The app's label as the OS reports it; used for apps we don't know. */ + appName: z.string().trim().max(100).nullish(), + state: z.enum(['playing', 'paused', 'stopped']), + track: reportedTrack.nullish(), + positionMs: z.number().int().min(0).nullish(), + }) + .refine((v) => v.state === 'stopped' || v.track, { message: 'track is required unless stopped', path: ['track'] }); + +export type DevicePlaybackInput = z.infer; + +export interface DeviceDTO { + id: string; + platform: 'android' | 'ios'; + name: string; + lastSeenAt: string | null; + createdAt: string; +} diff --git a/packages/shared/src/dto.test.ts b/packages/shared/src/dto.test.ts index dec2167..4e45ed4 100644 --- a/packages/shared/src/dto.test.ts +++ b/packages/shared/src/dto.test.ts @@ -7,6 +7,7 @@ function item(username: string, opts: { pinnedAt?: string; isPlaying?: boolean; track: { id: 't', title: 't', artist: 'a', album: null, artworkUrl: null, durationMs: null, isrc: null, links: {} }, isPlaying: opts.isPlaying ?? false, source: 'spotify', + sourceApp: null, progressMs: null, updatedAt: opts.updatedAt ?? '2026-01-01T00:00:00.000Z', pinnedAt: opts.pinnedAt ?? null, diff --git a/packages/shared/src/dto.ts b/packages/shared/src/dto.ts index 37a7a8d..a9b2bd3 100644 --- a/packages/shared/src/dto.ts +++ b/packages/shared/src/dto.ts @@ -23,6 +23,8 @@ export interface MeDTO extends UserSummaryDTO { email: string; bio: string | null; preferredService: StreamingService | null; + /** ISO time sharing resumes, or null when sharing. Far future means "until turned back on". */ + sharingPausedUntil: string | null; plan: Plan; entitlements: Entitlements; } @@ -32,6 +34,8 @@ export interface NowPlayingDTO { track: TrackDTO; isPlaying: boolean; source: NowPlayingProvider; + /** The app it's playing in, when a phone reported it (e.g. "Spotify"). */ + sourceApp: string | null; progressMs: number | null; updatedAt: string; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7408290..dfaacb4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,3 +4,4 @@ export * from './schemas'; export * from './plans'; export * from './client'; export * from './time'; +export * from './devices'; diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index f149ed1..6a67415 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -47,3 +47,7 @@ export const jellyfinConnectSchema = z.object({ username: z.string().trim().min(1).max(128), password: z.string().max(256).default(''), }); + +/** Pause sharing: for an hour, until tomorrow, until turned back on, or resume (null). */ +export const PAUSE_OPTIONS = ['1h', '8h', 'indefinitely'] as const; +export const setSharingSchema = z.object({ pause: z.enum(PAUSE_OPTIONS).nullable() }); diff --git a/packages/shared/src/services.ts b/packages/shared/src/services.ts index f5a6ce1..4693bcf 100644 --- a/packages/shared/src/services.ts +++ b/packages/shared/src/services.ts @@ -30,7 +30,7 @@ export const SERVICE_LABELS: Record { beforeEach(() => { @@ -18,3 +18,14 @@ describe('timeAgo', () => { expect(timeAgo(iso)).toBe(expected); }); }); + +describe('pausedUntilLabel', () => { + const now = new Date(2026, 9, 3, 14, 0); + it('says "until you turn it back on" for indefinite pauses', () => { + expect(pausedUntilLabel(new Date(2126, 0, 1).toISOString(), now)).toBe('until you turn it back on'); + }); + it('shows a time for today and "tomorrow" past midnight', () => { + expect(pausedUntilLabel(new Date(2026, 9, 3, 15, 0).toISOString(), now)).toMatch(/^until \d/); + expect(pausedUntilLabel(new Date(2026, 9, 4, 1, 0).toISOString(), now)).toMatch(/^until tomorrow /); + }); +}); diff --git a/packages/shared/src/time.ts b/packages/shared/src/time.ts index f1f8b3f..8abc5bf 100644 --- a/packages/shared/src/time.ts +++ b/packages/shared/src/time.ts @@ -5,3 +5,11 @@ export function timeAgo(iso: string): string { if (s < 86400) return `${Math.floor(s / 3600)}h ago`; return `${Math.floor(s / 86400)}d ago`; } + +/** "until 5:30 PM", "until tomorrow 1:00 AM", or "until you turn it back on" for an indefinite pause. */ +export function pausedUntilLabel(iso: string, now = new Date()): string { + const until = new Date(iso); + if (until.getTime() - now.getTime() > 30 * 86_400_000) return 'until you turn it back on'; + const time = until.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + return until.toDateString() === now.toDateString() ? `until ${time}` : `until tomorrow ${time}`; +}