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:
@@ -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();
|
||||
|
||||
@@ -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) ──────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user