Brings in ffmpeg (probing, extractAudio) and the video operations. The transcribe registration now gets the ffmpeg audio tools, so video is transcribed through extractAudio with the demuxer forced from the MIME type, and long recordings are converted and uploaded in ranges. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
131 lines
4.4 KiB
TypeScript
131 lines
4.4 KiB
TypeScript
import { ORPCError } from "@orpc/server";
|
|
import type { MediaCapabilities, MediaRuntime } from "@rakazo/adapter-kit";
|
|
import type { EncryptedSecretStore } from "@rakazo/adapters";
|
|
import {
|
|
loadMediaSettings,
|
|
MediaJobRejectedError,
|
|
MediaSettingsInvalidError,
|
|
mediaSettingsView,
|
|
saveMediaSettings,
|
|
} from "@rakazo/adapters";
|
|
import type { Actor, MediaJobStartInput, MediaSettingsUpdate } from "@rakazo/contracts";
|
|
import { MediaOperationSchema } from "@rakazo/contracts";
|
|
import type { PrismaClient } from "@rakazo/db";
|
|
import { resolveThreadTarget } from "./thread-target.js";
|
|
|
|
export interface MediaRouteDeps {
|
|
prisma: PrismaClient;
|
|
secrets: EncryptedSecretStore;
|
|
media?: MediaRuntime;
|
|
}
|
|
|
|
/** Media backends run on the deployment owner's own server, so only they configure them. */
|
|
function requireDeploymentOwner(actor: Actor) {
|
|
if (!actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
|
|
}
|
|
|
|
function requireMedia(deps: MediaRouteDeps): MediaRuntime {
|
|
if (!deps.media) throw new ORPCError("NOT_IMPLEMENTED");
|
|
return deps.media;
|
|
}
|
|
|
|
export async function getMediaSettings(deps: MediaRouteDeps, actor: Actor) {
|
|
requireDeploymentOwner(actor);
|
|
return mediaSettingsView(await loadMediaSettings(deps));
|
|
}
|
|
|
|
export async function updateMediaSettings(
|
|
deps: MediaRouteDeps,
|
|
actor: Actor,
|
|
input: MediaSettingsUpdate,
|
|
) {
|
|
requireDeploymentOwner(actor);
|
|
try {
|
|
return await saveMediaSettings(deps, input);
|
|
} catch (error) {
|
|
if (error instanceof MediaSettingsInvalidError) {
|
|
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts an operation a user picked on a card (export, transcribe, edit) in the same conversation.
|
|
* The thread is authorized like every thread procedure; the runtime checks that the operation's
|
|
* source artifacts belong to the actor, and the job can only ever read the actor's artifacts.
|
|
*/
|
|
export async function startMediaJob(deps: MediaRouteDeps, actor: Actor, input: MediaJobStartInput) {
|
|
const media = requireMedia(deps);
|
|
const target = await resolveThreadTarget(deps.prisma, actor, input);
|
|
const source = await deps.prisma.message.findFirst({
|
|
where: { id: input.messageId, threadId: target.threadId },
|
|
select: { botId: true },
|
|
});
|
|
if (!source) throw new ORPCError("NOT_FOUND");
|
|
const bots =
|
|
target.kind === "bot"
|
|
? [target.botId]
|
|
: [
|
|
...(target.leadBotId && target.memberBotIds.includes(target.leadBotId)
|
|
? [target.leadBotId]
|
|
: []),
|
|
...target.memberBotIds,
|
|
];
|
|
const botId = source.botId && bots.includes(source.botId) ? source.botId : bots[0];
|
|
if (!botId) throw new ORPCError("NOT_FOUND");
|
|
try {
|
|
return await media.start({
|
|
operation: input.operation,
|
|
input: input.input,
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
botId,
|
|
groupId: target.kind === "group" ? target.groupId : null,
|
|
threadId: target.threadId,
|
|
runId: null,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof MediaJobRejectedError) {
|
|
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** Booleans only: which operations the actor's space can run, never how they are configured. */
|
|
export async function getMediaCapabilities(
|
|
deps: MediaRouteDeps,
|
|
actor: Actor,
|
|
): Promise<MediaCapabilities> {
|
|
if (deps.media) return deps.media.capabilities(actor.spaceId);
|
|
return Object.fromEntries(
|
|
MediaOperationSchema.options.map((operation) => [operation, false]),
|
|
) as MediaCapabilities;
|
|
}
|
|
|
|
async function ownedJobId(deps: MediaRouteDeps, actor: Actor, jobId: string): Promise<string> {
|
|
const job = await deps.prisma.mediaJob.findFirst({
|
|
where: { id: jobId, spaceId: actor.spaceId, userId: actor.userId },
|
|
select: { id: true },
|
|
});
|
|
if (!job) throw new ORPCError("NOT_FOUND");
|
|
return job.id;
|
|
}
|
|
|
|
export async function getMediaJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
|
|
const media = requireMedia(deps);
|
|
const snapshot = await media.get(await ownedJobId(deps, actor, jobId));
|
|
if (!snapshot) throw new ORPCError("NOT_FOUND");
|
|
return snapshot;
|
|
}
|
|
|
|
export async function cancelMediaJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
|
|
const media = requireMedia(deps);
|
|
const id = await ownedJobId(deps, actor, jobId);
|
|
await media.cancel(id);
|
|
const snapshot = await media.get(id);
|
|
if (!snapshot) throw new ORPCError("NOT_FOUND");
|
|
return snapshot;
|
|
}
|