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 { getDatabase } from "../../shared/database/index.js";
|
||||||
import { pgVoiceRecordingsTable } from "../../shared/index.js";
|
import { pgVoiceRecordingsTable } from "../../shared/index.js";
|
||||||
import { createChildLogger } from "../../shared/logger/index.js";
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
@@ -20,6 +31,7 @@ export interface RecordingRow {
|
|||||||
upload_error: string | null;
|
upload_error: string | null;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
uploaded_at: number | null;
|
uploaded_at: number | null;
|
||||||
|
transcription: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedRecordings {
|
export interface PaginatedRecordings {
|
||||||
@@ -28,27 +40,67 @@ export interface PaginatedRecordings {
|
|||||||
hasMore: boolean;
|
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 {
|
export class RecordingsService {
|
||||||
async getRecent(
|
async getRecent(
|
||||||
limit = 50,
|
limit = 50,
|
||||||
filters?: { channelId?: string; userId?: string; cursor?: string },
|
filters: RecordingFilters = {},
|
||||||
): Promise<PaginatedRecordings> {
|
): Promise<PaginatedRecordings> {
|
||||||
logger.info({ limit }, "getRecent called");
|
logger.info({ limit, filters }, "getRecent called");
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
const conditions: SQL[] = [];
|
const conditions: SQL[] = [];
|
||||||
|
|
||||||
if (filters?.cursor) {
|
if (filters.cursor) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
lt(pgVoiceRecordingsTable.created_at, Number(filters.cursor)),
|
lt(pgVoiceRecordingsTable.created_at, Number(filters.cursor)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (filters?.channelId) {
|
if (filters.channelId) {
|
||||||
conditions.push(eq(pgVoiceRecordingsTable.channel_id, filters.channelId));
|
conditions.push(eq(pgVoiceRecordingsTable.channel_id, filters.channelId));
|
||||||
}
|
}
|
||||||
if (filters?.userId) {
|
if (filters.userId) {
|
||||||
conditions.push(eq(pgVoiceRecordingsTable.user_id, 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;
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
|
||||||
@@ -68,6 +120,7 @@ export class RecordingsService {
|
|||||||
upload_error: pgVoiceRecordingsTable.upload_error,
|
upload_error: pgVoiceRecordingsTable.upload_error,
|
||||||
created_at: pgVoiceRecordingsTable.created_at,
|
created_at: pgVoiceRecordingsTable.created_at,
|
||||||
uploaded_at: pgVoiceRecordingsTable.uploaded_at,
|
uploaded_at: pgVoiceRecordingsTable.uploaded_at,
|
||||||
|
transcription: pgVoiceRecordingsTable.transcription,
|
||||||
})
|
})
|
||||||
.from(pgVoiceRecordingsTable)
|
.from(pgVoiceRecordingsTable)
|
||||||
.where(where)
|
.where(where)
|
||||||
@@ -89,6 +142,43 @@ export class RecordingsService {
|
|||||||
.delete(pgVoiceRecordingsTable)
|
.delete(pgVoiceRecordingsTable)
|
||||||
.where(eq(pgVoiceRecordingsTable.id, id));
|
.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();
|
export const recordingsService = new RecordingsService();
|
||||||
|
|||||||
@@ -296,6 +296,9 @@ const recordingsRouter = {
|
|||||||
channelId: z.string().optional(),
|
channelId: z.string().optional(),
|
||||||
userId: z.string().optional(),
|
userId: z.string().optional(),
|
||||||
cursor: 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 }) =>
|
.handler(({ input }) =>
|
||||||
@@ -303,12 +306,16 @@ const recordingsRouter = {
|
|||||||
channelId: input.channelId,
|
channelId: input.channelId,
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
cursor: input.cursor,
|
cursor: input.cursor,
|
||||||
|
q: input.q,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
delete: os.input(z.object({ id: z.string() })).handler(async ({ input }) => {
|
delete: os.input(z.object({ id: z.string() })).handler(async ({ input }) => {
|
||||||
await recordingsService.deleteById(input.id);
|
await recordingsService.deleteById(input.id);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
|
summary: os.handler(async () => recordingsService.getSummary()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Analysis (search) ──────────────────────────────────────────────
|
// ── Analysis (search) ──────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user