Add phone playback reporting and pause sharing to the API
Phones that read system media sessions report playback themselves:
- POST /me/devices issues a per-phone token that can only call
POST /devices/now-playing; revocable, hashed at rest, 10 per user
- Reports name the app ("via Spotify"); unknown apps use the OS label
- Push-reported playback expires after 9 minutes of silence, since
phones send a heartbeat every 4 minutes while playing
Users can pause sharing (PUT /me/sharing: 1h, 8h, indefinitely). While
paused, connections aren't polled, phone reports are dropped, nothing is
added to history, and the current track switches to "played … ago".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -0,0 +1,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");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "sharing_paused_until" timestamp with time zone;
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -26,6 +26,8 @@ export const users = pgTable('users', {
|
||||
avatarUrl: varchar('avatar_url', { length: 500 }),
|
||||
preferredService: text('preferred_service').$type<StreamingService>(),
|
||||
/** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */
|
||||
/** 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<Plan>().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<NowPlayingProvider>().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',
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -50,3 +50,11 @@ export async function watchUsers(userIds: string[]) {
|
||||
);
|
||||
await pollSoon(rows.map((r) => r.id));
|
||||
}
|
||||
|
||||
export async function connectionIdsForUser(userId: string): Promise<string[]> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ export const toUserSummary = (u: Pick<User, 'id' | 'username' | 'displayName' |
|
||||
avatarUrl: u.avatarUrl,
|
||||
});
|
||||
|
||||
/** Phones report changes and a heartbeat every few minutes; if they go quiet (app killed, no signal), stop showing "listening now". */
|
||||
export const DEVICE_STALE_MS = 9 * 60_000;
|
||||
const PUSH_SOURCES = new Set<NowPlayingProvider>(['android_media_session']);
|
||||
|
||||
export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]> {
|
||||
if (userIds.length === 0) return [];
|
||||
const rows = await db
|
||||
@@ -26,8 +30,9 @@ export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]>
|
||||
.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<Recent
|
||||
return plays.map((p) => ({ track: tracks.get(p.trackId)!, source: p.source, playedAt: p.startedAt.toISOString() }));
|
||||
}
|
||||
|
||||
export const isSharingPaused = (user: Pick<User, 'sharingPausedUntil'>) =>
|
||||
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<boolean> {
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user