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:
2026-10-03 16:02:38 -04:00
co-authored by Claude Opus 5.5
parent 87353ccca3
commit ece9c4fd64
14 changed files with 1147 additions and 11 deletions
+39 -2
View File
@@ -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<NowPlayingDTO[]>
.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) {
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