Add recently played to profiles
A play is recorded when a new track starts (pausing and resuming doesn't count; returning after 10+ minutes does). Profiles show the last 10, excluding the track playing now; 50 are kept per user. Recent plays are free on every plan; the paid flag is renamed to fullHistory. 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,11 @@
|
|||||||
|
CREATE TABLE "listens" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"track_id" uuid NOT NULL,
|
||||||
|
"source" text NOT NULL,
|
||||||
|
"started_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "listens" ADD CONSTRAINT "listens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "listens" ADD CONSTRAINT "listens_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "listens_user_started_idx" ON "listens" USING btree ("user_id","started_at" DESC NULLS LAST);
|
||||||
@@ -0,0 +1,877 @@
|
|||||||
|
{
|
||||||
|
"id": "fb3fef85-3893-4f8d-b4b9-3fab12952d4e",
|
||||||
|
"prevId": "56c8e712-1bb1-48c0-b84d-e8cbaa6f1dc9",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.connections": {
|
||||||
|
"name": "connections",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"name": "provider",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"external_user_id": {
|
||||||
|
"name": "external_user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"name": "display_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_enc": {
|
||||||
|
"name": "access_token_enc",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_enc": {
|
||||||
|
"name": "refresh_token_enc",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"token_expires_at": {
|
||||||
|
"name": "token_expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'active'"
|
||||||
|
},
|
||||||
|
"last_error": {
|
||||||
|
"name": "last_error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"last_polled_at": {
|
||||||
|
"name": "last_polled_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"name": "meta",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"webhook_secret": {
|
||||||
|
"name": "webhook_secret",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"connections_user_provider_idx": {
|
||||||
|
"name": "connections_user_provider_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "provider",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"connections_user_id_users_id_fk": {
|
||||||
|
"name": "connections_user_id_users_id_fk",
|
||||||
|
"tableFrom": "connections",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"connections_webhook_secret_unique": {
|
||||||
|
"name": "connections_webhook_secret_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"webhook_secret"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.featured_songs": {
|
||||||
|
"name": "featured_songs",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"track_id": {
|
||||||
|
"name": "track_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"name": "comment",
|
||||||
|
"type": "varchar(280)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"featured_songs_user_id_users_id_fk": {
|
||||||
|
"name": "featured_songs_user_id_users_id_fk",
|
||||||
|
"tableFrom": "featured_songs",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"featured_songs_track_id_tracks_id_fk": {
|
||||||
|
"name": "featured_songs_track_id_tracks_id_fk",
|
||||||
|
"tableFrom": "featured_songs",
|
||||||
|
"tableTo": "tracks",
|
||||||
|
"columnsFrom": [
|
||||||
|
"track_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.follows": {
|
||||||
|
"name": "follows",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"follower_id": {
|
||||||
|
"name": "follower_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"followee_id": {
|
||||||
|
"name": "followee_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"pinned_at": {
|
||||||
|
"name": "pinned_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"follows_followee_idx": {
|
||||||
|
"name": "follows_followee_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "followee_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"follows_follower_id_users_id_fk": {
|
||||||
|
"name": "follows_follower_id_users_id_fk",
|
||||||
|
"tableFrom": "follows",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"follower_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"follows_followee_id_users_id_fk": {
|
||||||
|
"name": "follows_followee_id_users_id_fk",
|
||||||
|
"tableFrom": "follows",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"followee_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"follows_follower_id_followee_id_pk": {
|
||||||
|
"name": "follows_follower_id_followee_id_pk",
|
||||||
|
"columns": [
|
||||||
|
"follower_id",
|
||||||
|
"followee_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.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
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,13 @@
|
|||||||
"when": 1791034836624,
|
"when": 1791034836624,
|
||||||
"tag": "0002_spotty_gorilla_man",
|
"tag": "0002_spotty_gorilla_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791057194458,
|
||||||
|
"tag": "0003_small_taskmaster",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -142,6 +142,23 @@ export const nowPlaying = pgTable(
|
|||||||
(t) => [index('now_playing_track_idx').on(t.trackId)],
|
(t) => [index('now_playing_track_idx').on(t.trackId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Recent plays per user, for "Recently played" on profiles. Trimmed to the latest LISTEN_HISTORY_LIMIT. */
|
||||||
|
export const listens = pgTable(
|
||||||
|
'listens',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
trackId: uuid('track_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => tracks.id),
|
||||||
|
source: text('source').$type<NowPlayingProvider>().notNull(),
|
||||||
|
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [index('listens_user_started_idx').on(t.userId, t.startedAt.desc())],
|
||||||
|
);
|
||||||
|
|
||||||
export const featuredSongs = pgTable('featured_songs', {
|
export const featuredSongs = pgTable('featured_songs', {
|
||||||
userId: uuid('user_id')
|
userId: uuid('user_id')
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { entitlementsFor } from '../lib/entitlements';
|
|||||||
import { HttpError } from '../lib/http';
|
import { HttpError } from '../lib/http';
|
||||||
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
|
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
|
||||||
import { watchUsers } from '../services/connections';
|
import { watchUsers } from '../services/connections';
|
||||||
import { getNowPlaying, toUserSummary } from '../services/nowPlaying';
|
import { getNowPlaying, getRecentPlays, toUserSummary } from '../services/nowPlaying';
|
||||||
import { getTrackDTOs } from '../services/tracks';
|
import { getTrackDTOs } from '../services/tracks';
|
||||||
|
|
||||||
async function findByUsername(username: string) {
|
async function findByUsername(username: string) {
|
||||||
@@ -31,7 +31,7 @@ export async function userRoutes(app: FastifyInstance) {
|
|||||||
app.get<{ Params: { username: string } }>('/users/:username', async (req): Promise<ProfileDTO> => {
|
app.get<{ Params: { username: string } }>('/users/:username', async (req): Promise<ProfileDTO> => {
|
||||||
const user = await findByUsername(req.params.username);
|
const user = await findByUsername(req.params.username);
|
||||||
const viewer = req.user;
|
const viewer = req.user;
|
||||||
const [[followers], [following], [follow], [featured], nowPlaying] = await Promise.all([
|
const [[followers], [following], [follow], [featured], nowPlaying, recent] = await Promise.all([
|
||||||
db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followeeId, user.id)),
|
db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followeeId, user.id)),
|
||||||
db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followerId, user.id)),
|
db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followerId, user.id)),
|
||||||
viewer
|
viewer
|
||||||
@@ -42,6 +42,7 @@ export async function userRoutes(app: FastifyInstance) {
|
|||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
db.select().from(schema.featuredSongs).where(eq(schema.featuredSongs.userId, user.id)),
|
db.select().from(schema.featuredSongs).where(eq(schema.featuredSongs.userId, user.id)),
|
||||||
getNowPlaying([user.id]),
|
getNowPlaying([user.id]),
|
||||||
|
getRecentPlays(user.id),
|
||||||
]);
|
]);
|
||||||
const featuredTrack = featured ? (await getTrackDTOs([featured.trackId])).get(featured.trackId) : undefined;
|
const featuredTrack = featured ? (await getTrackDTOs([featured.trackId])).get(featured.trackId) : undefined;
|
||||||
await watchUsers([user.id]);
|
await watchUsers([user.id]);
|
||||||
@@ -58,6 +59,7 @@ export async function userRoutes(app: FastifyInstance) {
|
|||||||
? { track: featuredTrack, comment: featured.comment, updatedAt: featured.updatedAt.toISOString() }
|
? { track: featuredTrack, comment: featured.comment, updatedAt: featured.updatedAt.toISOString() }
|
||||||
: null,
|
: null,
|
||||||
nowPlaying: nowPlaying[0] ?? null,
|
nowPlaying: nowPlaying[0] ?? null,
|
||||||
|
recent,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { eq, inArray } from 'drizzle-orm';
|
import { and, desc, eq, inArray, lt } from 'drizzle-orm';
|
||||||
import type { NowPlayingDTO, NowPlayingProvider, UserSummaryDTO } from '@musicshare/shared';
|
import type { NowPlayingDTO, NowPlayingProvider, RecentPlayDTO, UserSummaryDTO } 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 { nowPlayingChannel, redis } from '../lib/redis';
|
import { nowPlayingChannel, redis } from '../lib/redis';
|
||||||
@@ -34,6 +34,40 @@ export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]>
|
|||||||
.sort((a, b) => Number(b.isPlaying) - Number(a.isPlaying) || b.updatedAt.localeCompare(a.updatedAt));
|
.sort((a, b) => Number(b.isPlaying) - Number(a.isPlaying) || b.updatedAt.localeCompare(a.updatedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Plays kept per user. Profiles show the latest few; the rest leaves room for a future history page. */
|
||||||
|
export const LISTEN_HISTORY_LIMIT = 50;
|
||||||
|
/** Coming back to the same track after this long counts as a new play. */
|
||||||
|
const REPLAY_GAP_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
async function recordListen(userId: string, trackId: string, source: NowPlayingProvider, at: Date) {
|
||||||
|
await db.insert(schema.listens).values({ userId, trackId, source, startedAt: at });
|
||||||
|
// Trim to the newest LISTEN_HISTORY_LIMIT rows for this user.
|
||||||
|
const [cutoff] = await db
|
||||||
|
.select({ startedAt: schema.listens.startedAt })
|
||||||
|
.from(schema.listens)
|
||||||
|
.where(eq(schema.listens.userId, userId))
|
||||||
|
.orderBy(desc(schema.listens.startedAt))
|
||||||
|
.offset(LISTEN_HISTORY_LIMIT - 1)
|
||||||
|
.limit(1);
|
||||||
|
if (cutoff) {
|
||||||
|
await db.delete(schema.listens).where(and(eq(schema.listens.userId, userId), lt(schema.listens.startedAt, cutoff.startedAt)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecentPlays(userId: string, limit = 10): Promise<RecentPlayDTO[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.listens)
|
||||||
|
.where(eq(schema.listens.userId, userId))
|
||||||
|
.orderBy(desc(schema.listens.startedAt))
|
||||||
|
.limit(limit + 1);
|
||||||
|
// Don't repeat the track that's playing right now.
|
||||||
|
const [current] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, userId));
|
||||||
|
const plays = current?.isPlaying && rows[0]?.trackId === current.trackId ? rows.slice(1) : rows.slice(0, limit);
|
||||||
|
const tracks = await getTrackDTOs(plays.map((p) => p.trackId));
|
||||||
|
return plays.map((p) => ({ track: tracks.get(p.trackId)!, source: p.source, playedAt: p.startedAt.toISOString() }));
|
||||||
|
}
|
||||||
|
|
||||||
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));
|
||||||
@@ -63,6 +97,8 @@ export async function applyPollResult(
|
|||||||
|
|
||||||
const track = await upsertTrack(result.track);
|
const track = await upsertTrack(result.track);
|
||||||
const changed = !current || current.trackId !== track.id || !current.isPlaying;
|
const changed = !current || current.trackId !== track.id || !current.isPlaying;
|
||||||
|
const newPlay =
|
||||||
|
!current || current.trackId !== track.id || (!current.isPlaying && now.getTime() - current.updatedAt.getTime() > REPLAY_GAP_MS);
|
||||||
const values = {
|
const values = {
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
source,
|
source,
|
||||||
@@ -76,6 +112,7 @@ export async function applyPollResult(
|
|||||||
.values({ userId, ...values })
|
.values({ userId, ...values })
|
||||||
.onConflictDoUpdate({ target: schema.nowPlaying.userId, set: values });
|
.onConflictDoUpdate({ target: schema.nowPlaying.userId, set: values });
|
||||||
|
|
||||||
|
if (newPlay) await recordListen(userId, track.id, source, now);
|
||||||
if (changed) {
|
if (changed) {
|
||||||
await publishNowPlaying(userId);
|
await publishNowPlaying(userId);
|
||||||
const [t] = await db
|
const [t] = await db
|
||||||
|
|||||||
@@ -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 restart identity cascade`;
|
await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens restart identity cascade`;
|
||||||
await redis.flushdb();
|
await redis.flushdb();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { db, schema } from '../src/db';
|
||||||
|
import { LISTEN_HISTORY_LIMIT, applyPollResult, getRecentPlays } from '../src/services/nowPlaying';
|
||||||
|
import { createUser, useTestApp } from './helpers';
|
||||||
|
|
||||||
|
const app = useTestApp();
|
||||||
|
|
||||||
|
const playing = (title: string) => ({ track: { title, artist: 'Joy Division' }, isPlaying: true, progressMs: 0 });
|
||||||
|
const titles = (plays: { track: { title: string } }[]) => plays.map((p) => p.track.title);
|
||||||
|
|
||||||
|
describe('listening history', () => {
|
||||||
|
it('records one play per track change, not per poll', async () => {
|
||||||
|
const { user } = await createUser(app(), 'bob');
|
||||||
|
for (const title of ['Disorder', 'Disorder', 'Disorder', 'Shadowplay', 'Shadowplay']) {
|
||||||
|
await applyPollResult(user.id, 'plex', playing(title));
|
||||||
|
}
|
||||||
|
expect(await db.select().from(schema.listens)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes what's playing right now from recent plays, but includes it once stopped", async () => {
|
||||||
|
const { user } = await createUser(app(), 'bob');
|
||||||
|
await applyPollResult(user.id, 'plex', playing('Disorder'));
|
||||||
|
await applyPollResult(user.id, 'plex', playing('Shadowplay'));
|
||||||
|
expect(titles(await getRecentPlays(user.id))).toEqual(['Disorder']);
|
||||||
|
|
||||||
|
await applyPollResult(user.id, 'plex', null);
|
||||||
|
expect(titles(await getRecentPlays(user.id))).toEqual(['Shadowplay', 'Disorder']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pausing and resuming is the same play; coming back after a long break is a new one', async () => {
|
||||||
|
const { user } = await createUser(app(), 'bob');
|
||||||
|
await applyPollResult(user.id, 'plex', playing('Atmosphere'));
|
||||||
|
await applyPollResult(user.id, 'plex', { ...playing('Atmosphere'), isPlaying: false });
|
||||||
|
await applyPollResult(user.id, 'plex', playing('Atmosphere'));
|
||||||
|
expect(await db.select().from(schema.listens)).toHaveLength(1);
|
||||||
|
|
||||||
|
await applyPollResult(user.id, 'plex', { ...playing('Atmosphere'), isPlaying: false });
|
||||||
|
await db.update(schema.nowPlaying).set({ updatedAt: new Date(Date.now() - 11 * 60_000) }).where(eq(schema.nowPlaying.userId, user.id));
|
||||||
|
await applyPollResult(user.id, 'plex', playing('Atmosphere'));
|
||||||
|
expect(await db.select().from(schema.listens)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`keeps only the latest ${LISTEN_HISTORY_LIMIT} plays per user`, async () => {
|
||||||
|
const { user } = await createUser(app(), 'bob');
|
||||||
|
const other = await createUser(app(), 'carol');
|
||||||
|
await applyPollResult(other.user.id, 'plex', playing('Not bob'));
|
||||||
|
for (let i = 0; i < LISTEN_HISTORY_LIMIT + 5; i++) await applyPollResult(user.id, 'plex', playing(`Track ${i}`));
|
||||||
|
|
||||||
|
const rows = await db.select().from(schema.listens).where(eq(schema.listens.userId, user.id));
|
||||||
|
expect(rows).toHaveLength(LISTEN_HISTORY_LIMIT);
|
||||||
|
expect(await db.select().from(schema.listens).where(eq(schema.listens.userId, other.user.id))).toHaveLength(1);
|
||||||
|
// newest kept, oldest dropped
|
||||||
|
const recent = await getRecentPlays(user.id, 3);
|
||||||
|
expect(titles(recent)).toEqual([`Track ${LISTEN_HISTORY_LIMIT + 3}`, `Track ${LISTEN_HISTORY_LIMIT + 2}`, `Track ${LISTEN_HISTORY_LIMIT + 1}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows on the profile, newest first, capped at 10', async () => {
|
||||||
|
const { user } = await createUser(app(), 'bob');
|
||||||
|
for (let i = 0; i < 12; i++) await applyPollResult(user.id, 'plex', playing(`Track ${i}`));
|
||||||
|
const profile = (await app().inject({ url: '/users/bob' })).json();
|
||||||
|
expect(profile.nowPlaying.track.title).toBe('Track 11');
|
||||||
|
expect(profile.recent).toHaveLength(10);
|
||||||
|
expect(profile.recent[0]).toMatchObject({ track: { title: 'Track 10' }, source: 'plex', playedAt: expect.any(String) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deleted with the account', async () => {
|
||||||
|
const { user, token } = await createUser(app(), 'bob');
|
||||||
|
await applyPollResult(user.id, 'plex', playing('Disorder'));
|
||||||
|
await app().inject({ method: 'DELETE', url: '/me', headers: { authorization: `Bearer ${token}` } });
|
||||||
|
expect(await db.select().from(schema.listens)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import { useSession } from '@/lib/session';
|
|||||||
import { colors, space } from '@/lib/theme';
|
import { colors, space } from '@/lib/theme';
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
import { NowPlayingCard } from './NowPlayingCard';
|
import { NowPlayingCard } from './NowPlayingCard';
|
||||||
|
import { RecentPlays } from './RecentPlays';
|
||||||
import { Screen } from './Screen';
|
import { Screen } from './Screen';
|
||||||
import { TrackCard } from './TrackCard';
|
import { TrackCard } from './TrackCard';
|
||||||
import { Body, Button, Card, Heading, Row, Title } from './ui';
|
import { Body, Button, Card, Heading, Row, Title } from './ui';
|
||||||
@@ -22,7 +23,9 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
|
|||||||
|
|
||||||
useRealtime(true, (msg) => {
|
useRealtime(true, (msg) => {
|
||||||
if (msg.type === 'now_playing' && profile && msg.data.user.id === profile.user.id) {
|
if (msg.type === 'now_playing' && profile && msg.data.user.id === profile.user.id) {
|
||||||
mutate({ ...profile, nowPlaying: msg.data }, { revalidate: false });
|
// A new track moves the previous one into "Recently played", so refetch; otherwise patch in place.
|
||||||
|
const newTrack = msg.data.track.id !== profile.nowPlaying?.track.id;
|
||||||
|
mutate({ ...profile, nowPlaying: msg.data }, { revalidate: newTrack });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,6 +107,8 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
|
|||||||
) : (
|
) : (
|
||||||
<Body muted>Nothing playing right now.</Body>
|
<Body muted>Nothing playing right now.</Body>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<RecentPlays plays={profile.recent} preferred={me?.preferredService} />
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { openUrl, timeAgo, type RecentPlayDTO, type StreamingService } from '@musicshare/shared';
|
||||||
|
import { showError } from '@/lib/errors';
|
||||||
|
import { colors, radius, space } from '@/lib/theme';
|
||||||
|
import { Card, Heading } from './ui';
|
||||||
|
|
||||||
|
export function RecentPlays({ plays, preferred }: { plays: RecentPlayDTO[]; preferred: StreamingService | null | undefined }) {
|
||||||
|
if (plays.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Card style={{ gap: 0 }}>
|
||||||
|
<Heading style={{ marginBottom: space.sm }}>Recently played</Heading>
|
||||||
|
{plays.map((p, i) => (
|
||||||
|
<Pressable
|
||||||
|
key={`${p.playedAt}-${i}`}
|
||||||
|
disabled={!preferred}
|
||||||
|
accessibilityRole={preferred ? 'link' : undefined}
|
||||||
|
onPress={() => preferred && Linking.openURL(openUrl(p.track, preferred).url).catch((e) => showError(e, "Couldn't open link"))}
|
||||||
|
style={({ pressed }) => [styles.row, i > 0 && styles.divider, pressed && { opacity: 0.6 }]}>
|
||||||
|
{p.track.artworkUrl ? (
|
||||||
|
<Image source={p.track.artworkUrl} style={styles.art} />
|
||||||
|
) : (
|
||||||
|
<View style={[styles.art, styles.artEmpty]}>
|
||||||
|
<Text style={{ color: colors.muted }}>♪</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
|
{p.track.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.artist} numberOfLines={1}>
|
||||||
|
{p.track.artist}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.time}>{timeAgo(p.playedAt)}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.sm },
|
||||||
|
divider: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border },
|
||||||
|
art: { width: 40, height: 40, borderRadius: radius.sm, backgroundColor: colors.surface2 },
|
||||||
|
artEmpty: { alignItems: 'center', justifyContent: 'center' },
|
||||||
|
title: { color: colors.text, fontWeight: '600', fontSize: 14 },
|
||||||
|
artist: { color: colors.muted, fontSize: 13 },
|
||||||
|
time: { color: colors.muted, fontSize: 12 },
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import useSWR from 'swr';
|
|||||||
import type { ProfileDTO } from '@musicshare/shared';
|
import type { ProfileDTO } from '@musicshare/shared';
|
||||||
import { Avatar } from '@/components/Avatar';
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
||||||
|
import { RecentPlays } from '@/components/RecentPlays';
|
||||||
import { TrackCard } from '@/components/TrackCard';
|
import { TrackCard } from '@/components/TrackCard';
|
||||||
import { api, fetcher, useMe } from '@/lib/api';
|
import { api, fetcher, useMe } from '@/lib/api';
|
||||||
import { useRealtime } from '@/lib/useRealtime';
|
import { useRealtime } from '@/lib/useRealtime';
|
||||||
@@ -20,7 +21,9 @@ export default function ProfilePage() {
|
|||||||
// Live updates arrive for people you follow (and yourself)
|
// Live updates arrive for people you follow (and yourself)
|
||||||
useRealtime(Boolean(me), (msg) => {
|
useRealtime(Boolean(me), (msg) => {
|
||||||
if (msg.type === 'now_playing' && profile && msg.data.user.id === profile.user.id) {
|
if (msg.type === 'now_playing' && profile && msg.data.user.id === profile.user.id) {
|
||||||
mutate({ ...profile, nowPlaying: msg.data }, { revalidate: false });
|
// A new track moves the previous one into "Recently played", so refetch; otherwise patch in place.
|
||||||
|
const newTrack = msg.data.track.id !== profile.nowPlaying?.track.id;
|
||||||
|
mutate({ ...profile, nowPlaying: msg.data }, { revalidate: newTrack });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,6 +97,8 @@ export default function ProfilePage() {
|
|||||||
) : (
|
) : (
|
||||||
<p className="muted">Nothing playing right now.</p>
|
<p className="muted">Nothing playing right now.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<RecentPlays plays={profile.recent} preferred={preferred} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { SERVICE_LABELS, openUrl, timeAgo, type RecentPlayDTO, type StreamingService } from '@musicshare/shared';
|
||||||
|
|
||||||
|
export function RecentPlays({ plays, preferred }: { plays: RecentPlayDTO[]; preferred: StreamingService | null | undefined }) {
|
||||||
|
if (plays.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Recently played</h2>
|
||||||
|
<ul className="recent">
|
||||||
|
{plays.map((p, i) => {
|
||||||
|
const target = preferred ? openUrl(p.track, preferred) : null;
|
||||||
|
const row = (
|
||||||
|
<>
|
||||||
|
{p.track.artworkUrl ? <img className="artwork-sm" src={p.track.artworkUrl} alt="" /> : <div className="artwork-sm artwork-empty">♪</div>}
|
||||||
|
<span className="recent-text">
|
||||||
|
<span className="recent-title">{p.track.title}</span>
|
||||||
|
<span className="muted">{p.track.artist}</span>
|
||||||
|
</span>
|
||||||
|
<span className="muted recent-time">{timeAgo(p.playedAt)}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<li key={`${p.playedAt}-${i}`}>
|
||||||
|
{target ? (
|
||||||
|
<a
|
||||||
|
className="recent-row"
|
||||||
|
href={target.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title={`${target.exact ? 'Open' : 'Search'} in ${SERVICE_LABELS[preferred!]}`}
|
||||||
|
>
|
||||||
|
{row}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="recent-row">{row}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,6 +58,12 @@ export interface FeaturedSongDTO {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecentPlayDTO {
|
||||||
|
track: TrackDTO;
|
||||||
|
source: NowPlayingProvider;
|
||||||
|
playedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProfileDTO {
|
export interface ProfileDTO {
|
||||||
user: UserSummaryDTO & { bio: string | null };
|
user: UserSummaryDTO & { bio: string | null };
|
||||||
followerCount: number;
|
followerCount: number;
|
||||||
@@ -67,6 +73,8 @@ export interface ProfileDTO {
|
|||||||
isSelf: boolean;
|
isSelf: boolean;
|
||||||
featured: FeaturedSongDTO | null;
|
featured: FeaturedSongDTO | null;
|
||||||
nowPlaying: NowPlayingDTO | null;
|
nowPlaying: NowPlayingDTO | null;
|
||||||
|
/** Most recent first, not including the track playing right now. */
|
||||||
|
recent: RecentPlayDTO[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConnectionDTO {
|
export interface ConnectionDTO {
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ export interface Entitlements {
|
|||||||
maxConnections: number;
|
maxConnections: number;
|
||||||
/** Friends you can pin to the top of your feed. */
|
/** Friends you can pin to the top of your feed. */
|
||||||
maxPinnedFriends: number;
|
maxPinnedFriends: number;
|
||||||
/** Placeholder for future paid features. */
|
/** Full listening history page (profiles show recent plays on every plan). Placeholder for a paid feature. */
|
||||||
listeningHistory: boolean;
|
fullHistory: boolean;
|
||||||
profileThemes: boolean;
|
profileThemes: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PLAN_ENTITLEMENTS: Record<Plan, Entitlements> = {
|
export const PLAN_ENTITLEMENTS: Record<Plan, Entitlements> = {
|
||||||
free: { activePollSeconds: 15, maxConnections: 3, maxPinnedFriends: 3, listeningHistory: false, profileThemes: false },
|
free: { activePollSeconds: 15, maxConnections: 3, maxPinnedFriends: 3, fullHistory: false, profileThemes: false },
|
||||||
plus: { activePollSeconds: 5, maxConnections: 10, maxPinnedFriends: 20, listeningHistory: true, profileThemes: true },
|
plus: { activePollSeconds: 5, maxConnections: 10, maxPinnedFriends: 20, fullHistory: true, profileThemes: true },
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user