From ece9c4fd649334ad1fb557c13fa060e175dcfebf Mon Sep 17 00:00:00 2001 From: chris Date: Sat, 3 Oct 2026 16:02:38 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8 --- apps/api/drizzle/0003_small_taskmaster.sql | 11 + apps/api/drizzle/meta/0003_snapshot.json | 877 +++++++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 17 + apps/api/src/routes/users.ts | 6 +- apps/api/src/services/nowPlaying.ts | 41 +- apps/api/test/helpers.ts | 2 +- apps/api/test/history.test.ts | 73 ++ apps/mobile/src/components/ProfileView.tsx | 7 +- apps/mobile/src/components/RecentPlays.tsx | 50 ++ apps/web/src/app/u/[username]/page.tsx | 7 +- apps/web/src/components/RecentPlays.tsx | 44 ++ packages/shared/src/dto.ts | 8 + packages/shared/src/plans.ts | 8 +- 14 files changed, 1147 insertions(+), 11 deletions(-) create mode 100644 apps/api/drizzle/0003_small_taskmaster.sql create mode 100644 apps/api/drizzle/meta/0003_snapshot.json create mode 100644 apps/api/test/history.test.ts create mode 100644 apps/mobile/src/components/RecentPlays.tsx create mode 100644 apps/web/src/components/RecentPlays.tsx diff --git a/apps/api/drizzle/0003_small_taskmaster.sql b/apps/api/drizzle/0003_small_taskmaster.sql new file mode 100644 index 0000000..b89f2ef --- /dev/null +++ b/apps/api/drizzle/0003_small_taskmaster.sql @@ -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); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0003_snapshot.json b/apps/api/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..042105e --- /dev/null +++ b/apps/api/drizzle/meta/0003_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index dd3deea..e7e4ccc 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1791034836624, "tag": "0002_spotty_gorilla_man", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1791057194458, + "tag": "0003_small_taskmaster", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index ba25499..8ff889e 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -142,6 +142,23 @@ export const nowPlaying = pgTable( (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().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', { userId: uuid('user_id') .primaryKey() diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts index c9abd51..ed7dd75 100644 --- a/apps/api/src/routes/users.ts +++ b/apps/api/src/routes/users.ts @@ -7,7 +7,7 @@ import { entitlementsFor } from '../lib/entitlements'; import { HttpError } from '../lib/http'; import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis'; import { watchUsers } from '../services/connections'; -import { getNowPlaying, toUserSummary } from '../services/nowPlaying'; +import { getNowPlaying, getRecentPlays, toUserSummary } from '../services/nowPlaying'; import { getTrackDTOs } from '../services/tracks'; 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 => { const user = await findByUsername(req.params.username); 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.followerId, user.id)), viewer @@ -42,6 +42,7 @@ export async function userRoutes(app: FastifyInstance) { : Promise.resolve([]), db.select().from(schema.featuredSongs).where(eq(schema.featuredSongs.userId, user.id)), getNowPlaying([user.id]), + getRecentPlays(user.id), ]); const featuredTrack = featured ? (await getTrackDTOs([featured.trackId])).get(featured.trackId) : undefined; await watchUsers([user.id]); @@ -58,6 +59,7 @@ export async function userRoutes(app: FastifyInstance) { ? { track: featuredTrack, comment: featured.comment, updatedAt: featured.updatedAt.toISOString() } : null, nowPlaying: nowPlaying[0] ?? null, + recent, }; }); diff --git a/apps/api/src/services/nowPlaying.ts b/apps/api/src/services/nowPlaying.ts index 999f0ea..4ea95eb 100644 --- a/apps/api/src/services/nowPlaying.ts +++ b/apps/api/src/services/nowPlaying.ts @@ -1,5 +1,5 @@ -import { eq, inArray } from 'drizzle-orm'; -import type { NowPlayingDTO, NowPlayingProvider, UserSummaryDTO } from '@musicshare/shared'; +import { and, desc, eq, inArray, lt } from 'drizzle-orm'; +import type { NowPlayingDTO, NowPlayingProvider, RecentPlayDTO, UserSummaryDTO } from '@musicshare/shared'; import { db, schema } from '../db'; import type { User } from '../db/schema'; import { nowPlayingChannel, redis } from '../lib/redis'; @@ -34,6 +34,40 @@ export async function getNowPlaying(userIds: string[]): Promise .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 { + 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) { const [dto] = await getNowPlaying([userId]); 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 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 = { trackId: track.id, source, @@ -76,6 +112,7 @@ export async function applyPollResult( .values({ userId, ...values }) .onConflictDoUpdate({ target: schema.nowPlaying.userId, set: values }); + if (newPlay) await recordListen(userId, track.id, source, now); if (changed) { await publishNowPlaying(userId); const [t] = await db diff --git a/apps/api/test/helpers.ts b/apps/api/test/helpers.ts index da32c5a..d9d1b8d 100644 --- a/apps/api/test/helpers.ts +++ b/apps/api/test/helpers.ts @@ -17,7 +17,7 @@ function assertTestTargets() { export async function resetState() { assertTestTargets(); - await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs restart identity cascade`; + await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens restart identity cascade`; await redis.flushdb(); } diff --git a/apps/api/test/history.test.ts b/apps/api/test/history.test.ts new file mode 100644 index 0000000..276e301 --- /dev/null +++ b/apps/api/test/history.test.ts @@ -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); + }); +}); diff --git a/apps/mobile/src/components/ProfileView.tsx b/apps/mobile/src/components/ProfileView.tsx index 37870f6..622f63d 100644 --- a/apps/mobile/src/components/ProfileView.tsx +++ b/apps/mobile/src/components/ProfileView.tsx @@ -10,6 +10,7 @@ import { useSession } from '@/lib/session'; import { colors, space } from '@/lib/theme'; import { Avatar } from './Avatar'; import { NowPlayingCard } from './NowPlayingCard'; +import { RecentPlays } from './RecentPlays'; import { Screen } from './Screen'; import { TrackCard } from './TrackCard'; 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) => { 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 ) : ( Nothing playing right now. )} + + ); } diff --git a/apps/mobile/src/components/RecentPlays.tsx b/apps/mobile/src/components/RecentPlays.tsx new file mode 100644 index 0000000..5bbcc81 --- /dev/null +++ b/apps/mobile/src/components/RecentPlays.tsx @@ -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 ( + + Recently played + {plays.map((p, i) => ( + 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 ? ( + + ) : ( + + ♪ + + )} + + + {p.track.title} + + + {p.track.artist} + + + {timeAgo(p.playedAt)} + + ))} + + ); +} + +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 }, +}); diff --git a/apps/web/src/app/u/[username]/page.tsx b/apps/web/src/app/u/[username]/page.tsx index 8b6ecfc..a7c44bd 100644 --- a/apps/web/src/app/u/[username]/page.tsx +++ b/apps/web/src/app/u/[username]/page.tsx @@ -7,6 +7,7 @@ import useSWR from 'swr'; import type { ProfileDTO } from '@musicshare/shared'; import { Avatar } from '@/components/Avatar'; import { NowPlayingCard } from '@/components/NowPlayingCard'; +import { RecentPlays } from '@/components/RecentPlays'; import { TrackCard } from '@/components/TrackCard'; import { api, fetcher, useMe } from '@/lib/api'; import { useRealtime } from '@/lib/useRealtime'; @@ -20,7 +21,9 @@ export default function ProfilePage() { // Live updates arrive for people you follow (and yourself) useRealtime(Boolean(me), (msg) => { 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() { ) : (

Nothing playing right now.

)} + + ); } diff --git a/apps/web/src/components/RecentPlays.tsx b/apps/web/src/components/RecentPlays.tsx new file mode 100644 index 0000000..420caec --- /dev/null +++ b/apps/web/src/components/RecentPlays.tsx @@ -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 ( +
+

Recently played

+
    + {plays.map((p, i) => { + const target = preferred ? openUrl(p.track, preferred) : null; + const row = ( + <> + {p.track.artworkUrl ? :
    ♪
    } + + {p.track.title} + {p.track.artist} + + {timeAgo(p.playedAt)} + + ); + return ( +
  • + {target ? ( + + {row} + + ) : ( +
    {row}
    + )} +
  • + ); + })} +
+
+ ); +} diff --git a/packages/shared/src/dto.ts b/packages/shared/src/dto.ts index cef1d75..37a7a8d 100644 --- a/packages/shared/src/dto.ts +++ b/packages/shared/src/dto.ts @@ -58,6 +58,12 @@ export interface FeaturedSongDTO { updatedAt: string; } +export interface RecentPlayDTO { + track: TrackDTO; + source: NowPlayingProvider; + playedAt: string; +} + export interface ProfileDTO { user: UserSummaryDTO & { bio: string | null }; followerCount: number; @@ -67,6 +73,8 @@ export interface ProfileDTO { isSelf: boolean; featured: FeaturedSongDTO | null; nowPlaying: NowPlayingDTO | null; + /** Most recent first, not including the track playing right now. */ + recent: RecentPlayDTO[]; } export interface ConnectionDTO { diff --git a/packages/shared/src/plans.ts b/packages/shared/src/plans.ts index 2fd1254..4fadf39 100644 --- a/packages/shared/src/plans.ts +++ b/packages/shared/src/plans.ts @@ -11,12 +11,12 @@ export interface Entitlements { maxConnections: number; /** Friends you can pin to the top of your feed. */ maxPinnedFriends: number; - /** Placeholder for future paid features. */ - listeningHistory: boolean; + /** Full listening history page (profiles show recent plays on every plan). Placeholder for a paid feature. */ + fullHistory: boolean; profileThemes: boolean; } export const PLAN_ENTITLEMENTS: Record = { - free: { activePollSeconds: 15, maxConnections: 3, maxPinnedFriends: 3, listeningHistory: false, profileThemes: false }, - plus: { activePollSeconds: 5, maxConnections: 10, maxPinnedFriends: 20, listeningHistory: true, profileThemes: true }, + free: { activePollSeconds: 15, maxConnections: 3, maxPinnedFriends: 3, fullHistory: false, profileThemes: false }, + plus: { activePollSeconds: 5, maxConnections: 10, maxPinnedFriends: 20, fullHistory: true, profileThemes: true }, };