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:
@@ -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<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', {
|
||||
userId: uuid('user_id')
|
||||
.primaryKey()
|
||||
|
||||
@@ -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<ProfileDTO> => {
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user