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:
2026-10-03 17:37:30 -04:00
co-authored by Claude Opus 5.5
parent 6317004004
commit 70fc7efbbe
24 changed files with 2500 additions and 7 deletions
+14
View File
@@ -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");
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "sharing_paused_until" timestamp with time zone;
+978
View File
@@ -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": {}
}
}
+984
View File
@@ -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": {}
}
}
+14
View File
@@ -29,6 +29,20 @@
"when": 1791057194458, "when": 1791057194458,
"tag": "0003_small_taskmaster", "tag": "0003_small_taskmaster",
"breakpoints": true "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
} }
] ]
} }
+2
View File
@@ -11,6 +11,7 @@ import { HttpError } from './lib/http';
import { redis } from './lib/redis'; import { redis } from './lib/redis';
import { authRoutes } from './routes/auth'; import { authRoutes } from './routes/auth';
import { connectionRoutes } from './routes/connections'; import { connectionRoutes } from './routes/connections';
import { deviceRoutes } from './routes/devices';
import { meRoutes } from './routes/me'; import { meRoutes } from './routes/me';
import { realtimeRoutes } from './routes/realtime'; import { realtimeRoutes } from './routes/realtime';
import { trackRoutes } from './routes/tracks'; 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(userRoutes);
await app.register(trackRoutes); await app.register(trackRoutes);
await app.register(connectionRoutes); await app.register(connectionRoutes);
await app.register(deviceRoutes);
await app.register(realtimeRoutes); await app.register(realtimeRoutes);
// registered as its own plugin so its permissive body parsers stay scoped to webhook routes // registered as its own plugin so its permissive body parsers stay scoped to webhook routes
await app.register(webhookRoutes); await app.register(webhookRoutes);
+22
View File
@@ -26,6 +26,8 @@ export const users = pgTable('users', {
avatarUrl: varchar('avatar_url', { length: 500 }), avatarUrl: varchar('avatar_url', { length: 500 }),
preferredService: text('preferred_service').$type<StreamingService>(), preferredService: text('preferred_service').$type<StreamingService>(),
/** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */ /** 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'), plan: text('plan').$type<Plan>().notNull().default('free'),
planExpiresAt: timestamp('plan_expires_at', { withTimezone: true }), planExpiresAt: timestamp('plan_expires_at', { withTimezone: true }),
createdAt: createdAt(), createdAt: createdAt(),
@@ -134,6 +136,8 @@ export const nowPlaying = pgTable(
.notNull() .notNull()
.references(() => tracks.id), .references(() => tracks.id),
source: text('source').$type<NowPlayingProvider>().notNull(), 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(), isPlaying: boolean('is_playing').notNull(),
progressMs: integer('progress_ms'), progressMs: integer('progress_ms'),
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(), 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)], (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. */ /** Recent plays per user, for "Recently played" on profiles. Trimmed to the latest LISTEN_HISTORY_LIMIT. */
export const listens = pgTable( export const listens = pgTable(
'listens', 'listens',
+122
View File
@@ -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();
},
);
}
+20 -2
View File
@@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify'; 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 { db, schema } from '../db';
import type { User } from '../db/schema'; import type { User } from '../db/schema';
import { SESSION_COOKIE, requireUser } from '../lib/auth'; import { SESSION_COOKIE, requireUser } from '../lib/auth';
@@ -8,7 +8,9 @@ import { effectivePlan, entitlementsFor } from '../lib/entitlements';
import { HttpError } from '../lib/http'; import { HttpError } from '../lib/http';
import { deezerTrack } from '../providers/catalog'; import { deezerTrack } from '../providers/catalog';
import { enqueueResolve } from '../services/queues'; 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'; import { upsertTrack } from '../services/tracks';
export const toMeDTO = (u: User): MeDTO => ({ export const toMeDTO = (u: User): MeDTO => ({
@@ -19,6 +21,7 @@ export const toMeDTO = (u: User): MeDTO => ({
email: u.email, email: u.email,
bio: u.bio, bio: u.bio,
preferredService: u.preferredService, preferredService: u.preferredService,
sharingPausedUntil: isSharingPaused(u) ? u.sharingPausedUntil!.toISOString() : null,
plan: effectivePlan(u), plan: effectivePlan(u),
entitlements: entitlementsFor(u), entitlements: entitlementsFor(u),
}); });
@@ -46,6 +49,21 @@ export async function meRoutes(app: FastifyInstance) {
return { ok: true }; 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) => { app.put('/me/featured', async (req) => {
const user = requireUser(req); const user = requireUser(req);
const input = setFeaturedSchema.parse(req.body); const input = setFeaturedSchema.parse(req.body);
+8
View File
@@ -50,3 +50,11 @@ export async function watchUsers(userIds: string[]) {
); );
await pollSoon(rows.map((r) => r.id)); 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);
}
+21 -1
View File
@@ -14,6 +14,10 @@ export const toUserSummary = (u: Pick<User, 'id' | 'username' | 'displayName' |
avatarUrl: u.avatarUrl, 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[]> { export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]> {
if (userIds.length === 0) return []; if (userIds.length === 0) return [];
const rows = await db const rows = await db
@@ -26,8 +30,9 @@ export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]>
.map(({ np, user }) => ({ .map(({ np, user }) => ({
user: toUserSummary(user), user: toUserSummary(user),
track: tracks.get(np.trackId)!, 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, source: np.source,
sourceApp: np.sourceApp,
progressMs: np.progressMs, progressMs: np.progressMs,
updatedAt: np.updatedAt.toISOString(), 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() })); 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) { export async function publishNowPlaying(userId: string) {
const [dto] = await getNowPlaying([userId]); const [dto] = await getNowPlaying([userId]);
if (dto) await redis.publish(nowPlayingChannel(userId), JSON.stringify(dto)); if (dto) await redis.publish(nowPlayingChannel(userId), JSON.stringify(dto));
@@ -78,6 +96,7 @@ export async function applyPollResult(
userId: string, userId: string,
source: NowPlayingProvider, source: NowPlayingProvider,
result: PollResult | null, result: PollResult | null,
opts: { sourceApp?: string | null } = {},
): Promise<boolean> { ): Promise<boolean> {
const [current] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, userId)); const [current] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, userId));
const now = new Date(); const now = new Date();
@@ -102,6 +121,7 @@ export async function applyPollResult(
const values = { const values = {
trackId: track.id, trackId: track.id,
source, source,
sourceApp: opts.sourceApp ?? null,
isPlaying: true, isPlaying: true,
progressMs: result.progressMs, progressMs: result.progressMs,
updatedAt: now, updatedAt: now,
+8 -1
View File
@@ -6,7 +6,7 @@ import { closeConnections } from './lib/lifecycle';
import { createRedis } from './lib/redis'; import { createRedis } from './lib/redis';
import { POLLED_PROVIDERS, pollingProviders } from './providers'; import { POLLED_PROVIDERS, pollingProviders } from './providers';
import { ProviderAuthError, ProviderRateLimitError } from './providers/types'; 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 { POLL_QUEUE, RESOLVE_QUEUE, pollQueue } from './services/queues';
import { resolveTrackLinks } from './services/resolver'; import { resolveTrackLinks } from './services/resolver';
import { import {
@@ -34,6 +34,13 @@ async function pollConnection(connectionId: string) {
} }
const { conn, user } = row; 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 { try {
const result = await provider.poll(conn); const result = await provider.poll(conn);
const playing = await applyPollResult(user.id, conn.provider, result); const playing = await applyPollResult(user.id, conn.provider, result);
+122
View File
@@ -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);
});
});
+1 -1
View File
@@ -17,7 +17,7 @@ function assertTestTargets() {
export async function resetState() { export async function resetState() {
assertTestTargets(); 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(); await redis.flushdb();
} }
+81
View File
@@ -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);
});
});
+8
View File
@@ -6,6 +6,7 @@ import type {
TrackSearchResultDTO, TrackSearchResultDTO,
UserSummaryDTO, UserSummaryDTO,
} from './dto'; } from './dto';
import type { DeviceDTO } from './devices';
import type { LoginInput, RegisterInput, SetFeaturedInput, UpdateMeInput } from './schemas'; import type { LoginInput, RegisterInput, SetFeaturedInput, UpdateMeInput } from './schemas';
export class ApiError extends Error { export class ApiError extends Error {
@@ -59,6 +60,7 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
me: () => request<MeDTO>('/me'), me: () => request<MeDTO>('/me'),
updateMe: (input: UpdateMeInput) => request<MeDTO>('/me', { method: 'PATCH', body: input }), updateMe: (input: UpdateMeInput) => request<MeDTO>('/me', { method: 'PATCH', body: input }),
deleteAccount: () => request('/me', { method: 'DELETE' }), 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 }), setFeatured: (input: SetFeaturedInput) => request('/me/featured', { method: 'PUT', body: input }),
clearFeatured: () => request('/me/featured', { method: 'DELETE' }), 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 }) => connectJellyfin: (input: { serverUrl: string; username: string; password: string }) =>
request<ConnectionDTO>('/connections/jellyfin', { body: input }), request<ConnectionDTO>('/connections/jellyfin', { body: input }),
disconnect: (provider: string) => request(`/connections/${q(provider)}`, { method: 'DELETE' }), 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' }),
}; };
} }
+63
View File
@@ -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;
}
+1
View File
@@ -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: {} }, track: { id: 't', title: 't', artist: 'a', album: null, artworkUrl: null, durationMs: null, isrc: null, links: {} },
isPlaying: opts.isPlaying ?? false, isPlaying: opts.isPlaying ?? false,
source: 'spotify', source: 'spotify',
sourceApp: null,
progressMs: null, progressMs: null,
updatedAt: opts.updatedAt ?? '2026-01-01T00:00:00.000Z', updatedAt: opts.updatedAt ?? '2026-01-01T00:00:00.000Z',
pinnedAt: opts.pinnedAt ?? null, pinnedAt: opts.pinnedAt ?? null,
+4
View File
@@ -23,6 +23,8 @@ export interface MeDTO extends UserSummaryDTO {
email: string; email: string;
bio: string | null; bio: string | null;
preferredService: StreamingService | null; preferredService: StreamingService | null;
/** ISO time sharing resumes, or null when sharing. Far future means "until turned back on". */
sharingPausedUntil: string | null;
plan: Plan; plan: Plan;
entitlements: Entitlements; entitlements: Entitlements;
} }
@@ -32,6 +34,8 @@ export interface NowPlayingDTO {
track: TrackDTO; track: TrackDTO;
isPlaying: boolean; isPlaying: boolean;
source: NowPlayingProvider; source: NowPlayingProvider;
/** The app it's playing in, when a phone reported it (e.g. "Spotify"). */
sourceApp: string | null;
progressMs: number | null; progressMs: number | null;
updatedAt: string; updatedAt: string;
} }
+1
View File
@@ -4,3 +4,4 @@ export * from './schemas';
export * from './plans'; export * from './plans';
export * from './client'; export * from './client';
export * from './time'; export * from './time';
export * from './devices';
+4
View File
@@ -47,3 +47,7 @@ export const jellyfinConnectSchema = z.object({
username: z.string().trim().min(1).max(128), username: z.string().trim().min(1).max(128),
password: z.string().max(256).default(''), 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() });
+1 -1
View File
@@ -30,7 +30,7 @@ export const SERVICE_LABELS: Record<StreamingService | NowPlayingProvider, strin
listenbrainz: 'ListenBrainz', listenbrainz: 'ListenBrainz',
plex: 'Plex', plex: 'Plex',
jellyfin: 'Jellyfin', jellyfin: 'Jellyfin',
android_media_session: 'Android', android_media_session: 'your phone',
}; };
/** Fallback when we have no direct link: a search on that service. */ /** Fallback when we have no direct link: a search on that service. */
+12 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { timeAgo } from './time'; import { pausedUntilLabel, timeAgo } from './time';
describe('timeAgo', () => { describe('timeAgo', () => {
beforeEach(() => { beforeEach(() => {
@@ -18,3 +18,14 @@ describe('timeAgo', () => {
expect(timeAgo(iso)).toBe(expected); 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 /);
});
});
+8
View File
@@ -5,3 +5,11 @@ export function timeAgo(iso: string): string {
if (s < 86400) return `${Math.floor(s / 3600)}h ago`; if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d 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}`;
}