From 22c09ecd28c10284032e5034f61eaae3dbe5224d Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sun, 30 Aug 2026 13:31:39 +0700 Subject: [PATCH] =?UTF-8?q?feat(backend):=20recordings=20=E2=80=94=20expos?= =?UTF-8?q?e=20transcription,=20keyword=20search,=20date=20range,=20speake?= =?UTF-8?q?r=20summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getRecent now returns transcription; new filters q (ILIKE on transcription + username) and startDate/endDate (created_at range, epoch ms) - new recordings.summary: per-speaker leaderboard (clips, est_duration_s from 128kbps bytes, transcribed words, last activity) - oRPC list input + summary procedure wired --- .../modules/recordings/recordings.service.ts | 102 ++++++++++++++++-- services/backend/src/orpc/router.ts | 7 ++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/services/backend/src/modules/recordings/recordings.service.ts b/services/backend/src/modules/recordings/recordings.service.ts index 6538e1fc..47b06acb 100644 --- a/services/backend/src/modules/recordings/recordings.service.ts +++ b/services/backend/src/modules/recordings/recordings.service.ts @@ -1,4 +1,15 @@ -import { and, desc, eq, lt, type SQL } from "drizzle-orm"; +import { + and, + desc, + eq, + gte, + ilike, + lt, + lte, + or, + type SQL, + sql, +} from "drizzle-orm"; import { getDatabase } from "../../shared/database/index.js"; import { pgVoiceRecordingsTable } from "../../shared/index.js"; import { createChildLogger } from "../../shared/logger/index.js"; @@ -20,6 +31,7 @@ export interface RecordingRow { upload_error: string | null; created_at: number; uploaded_at: number | null; + transcription: string | null; } export interface PaginatedRecordings { @@ -28,27 +40,67 @@ export interface PaginatedRecordings { hasMore: boolean; } +export interface RecordingFilters { + channelId?: string; + userId?: string; + cursor?: string; + /** keyword search against transcription + username (ILIKE) */ + q?: string; + /** created_at lower bound (epoch ms) */ + startDate?: number; + /** created_at upper bound (epoch ms) */ + endDate?: number; +} + +export interface SpeakerSummary { + user_id: string; + username: string; + avatar_url: string | null; + clips: number; + /** summed MP3 bytes; ~128kbps → est duration in seconds */ + est_duration_s: number; + words: number; + transcribed: number; + last_at: number; +} + export class RecordingsService { async getRecent( limit = 50, - filters?: { channelId?: string; userId?: string; cursor?: string }, + filters: RecordingFilters = {}, ): Promise { - logger.info({ limit }, "getRecent called"); + logger.info({ limit, filters }, "getRecent called"); const db = getDatabase(); const conditions: SQL[] = []; - if (filters?.cursor) { + if (filters.cursor) { conditions.push( lt(pgVoiceRecordingsTable.created_at, Number(filters.cursor)), ); } - if (filters?.channelId) { + if (filters.channelId) { conditions.push(eq(pgVoiceRecordingsTable.channel_id, filters.channelId)); } - if (filters?.userId) { + if (filters.userId) { conditions.push(eq(pgVoiceRecordingsTable.user_id, filters.userId)); } + if (filters.q) { + const like = `%${filters.q}%`; + const qOr = or( + ilike(pgVoiceRecordingsTable.transcription, like), + ilike(pgVoiceRecordingsTable.username, like), + ); + if (qOr) conditions.push(qOr); + } + if (filters.startDate) { + conditions.push( + gte(pgVoiceRecordingsTable.created_at, filters.startDate), + ); + } + if (filters.endDate) { + conditions.push(lte(pgVoiceRecordingsTable.created_at, filters.endDate)); + } const where = conditions.length > 0 ? and(...conditions) : undefined; @@ -68,6 +120,7 @@ export class RecordingsService { upload_error: pgVoiceRecordingsTable.upload_error, created_at: pgVoiceRecordingsTable.created_at, uploaded_at: pgVoiceRecordingsTable.uploaded_at, + transcription: pgVoiceRecordingsTable.transcription, }) .from(pgVoiceRecordingsTable) .where(where) @@ -89,6 +142,43 @@ export class RecordingsService { .delete(pgVoiceRecordingsTable) .where(eq(pgVoiceRecordingsTable.id, id)); } + + /** + * Speaker leaderboard: aggregate clips / est. duration / transcribed words + * per user. `est_duration_s` derives from summed MP3 bytes at the uploader's + * 128 kbps transcode rate; `words` sums transcription word counts. + */ + async getSummary(): Promise { + const db = getDatabase(); + const t = pgVoiceRecordingsTable; + + const rows = await db + .select({ + user_id: t.user_id, + username: t.username, + avatar_url: t.avatar_url, + clips: sql`count(*)::int`, + total_bytes: sql`coalesce(sum(${t.size_bytes}), 0)`, + words: sql`coalesce(sum(array_length(string_to_array(${t.transcription}, ' '), 1)), 0)::int`, + transcribed: sql`count(${t.transcription})::int`, + last_at: sql`max(${t.created_at})`, + }) + .from(t) + .groupBy(t.user_id, t.username, t.avatar_url) + .orderBy(sql`clips desc, last_at desc`); + + return rows.map((r) => ({ + user_id: r.user_id, + username: r.username, + avatar_url: r.avatar_url, + clips: r.clips, + // 128 kbps = 128000 bps → bytes*8/128000 = seconds + est_duration_s: Math.round((r.total_bytes * 8) / 128000), + words: r.words, + transcribed: r.transcribed, + last_at: r.last_at, + })); + } } export const recordingsService = new RecordingsService(); diff --git a/services/backend/src/orpc/router.ts b/services/backend/src/orpc/router.ts index b22602c1..9c960c8d 100644 --- a/services/backend/src/orpc/router.ts +++ b/services/backend/src/orpc/router.ts @@ -296,6 +296,9 @@ const recordingsRouter = { channelId: z.string().optional(), userId: z.string().optional(), cursor: z.string().optional(), + q: z.string().optional(), + startDate: z.coerce.number().int().optional(), + endDate: z.coerce.number().int().optional(), }), ) .handler(({ input }) => @@ -303,12 +306,16 @@ const recordingsRouter = { channelId: input.channelId, userId: input.userId, cursor: input.cursor, + q: input.q, + startDate: input.startDate, + endDate: input.endDate, }), ), delete: os.input(z.object({ id: z.string() })).handler(async ({ input }) => { await recordingsService.deleteById(input.id); return { ok: true }; }), + summary: os.handler(async () => recordingsService.getSummary()), }; // ── Analysis (search) ──────────────────────────────────────────────