feat(backend): recordings — expose transcription, keyword search, date range, speaker summary

- 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
This commit is contained in:
asepharyana
2026-08-30 13:31:39 +07:00
parent a3a91aa2ce
commit 22c09ecd28
2 changed files with 103 additions and 6 deletions
@@ -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<PaginatedRecordings> {
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<SpeakerSummary[]> {
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<number>`count(*)::int`,
total_bytes: sql<number>`coalesce(sum(${t.size_bytes}), 0)`,
words: sql<number>`coalesce(sum(array_length(string_to_array(${t.transcription}, ' '), 1)), 0)::int`,
transcribed: sql<number>`count(${t.transcription})::int`,
last_at: sql<number>`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();
+7
View File
@@ -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) ──────────────────────────────────────────────