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);
|
||||
});
|
||||
});
|
||||
@@ -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<MeDTO>('/me'),
|
||||
updateMe: (input: UpdateMeInput) => request<MeDTO>('/me', { method: 'PATCH', body: input }),
|
||||
deleteAccount: () => request('/me', { method: 'DELETE' }),
|
||||
setSharing: (pause: '1h' | '8h' | 'indefinitely' | null) => request<MeDTO>('/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<ConnectionDTO>('/connections/jellyfin', { body: input }),
|
||||
disconnect: (provider: string) => request(`/connections/${q(provider)}`, { method: 'DELETE' }),
|
||||
|
||||
devices: () => request<DeviceDTO[]>('/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' }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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<typeof devicePlaybackSchema>;
|
||||
|
||||
export interface DeviceDTO {
|
||||
id: string;
|
||||
platform: 'android' | 'ios';
|
||||
name: string;
|
||||
lastSeenAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './schemas';
|
||||
export * from './plans';
|
||||
export * from './client';
|
||||
export * from './time';
|
||||
export * from './devices';
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -30,7 +30,7 @@ export const SERVICE_LABELS: Record<StreamingService | NowPlayingProvider, strin
|
||||
listenbrainz: 'ListenBrainz',
|
||||
plex: 'Plex',
|
||||
jellyfin: 'Jellyfin',
|
||||
android_media_session: 'Android',
|
||||
android_media_session: 'your phone',
|
||||
};
|
||||
|
||||
/** Fallback when we have no direct link: a search on that service. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { timeAgo } from './time';
|
||||
import { pausedUntilLabel, timeAgo } from './time';
|
||||
|
||||
describe('timeAgo', () => {
|
||||
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 /);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user