diff --git a/apps/api/src/media-env.ts b/apps/api/src/media-env.ts new file mode 100644 index 0000000..701f9ee --- /dev/null +++ b/apps/api/src/media-env.ts @@ -0,0 +1,19 @@ +import { mediaConfigFromEnv } from "@rakazo/adapters"; + +export interface MediaEnv { + /** Generic sidecar connection; unset keeps Media Studio hidden (except the scripted emulator). */ + mediaWorkerUrl: string | undefined; + mediaWorkerToken: string | undefined; + /** Absolute directory of private platform profiles that override the builtin ones. */ + mediaProfilesDir: string; +} + +/** Media Studio settings, read through the adapters' single media config parser. */ +export function readMediaEnv(source: NodeJS.ProcessEnv = process.env): MediaEnv { + const config = mediaConfigFromEnv(source); + return { + mediaWorkerUrl: config.workerUrl ?? undefined, + mediaWorkerToken: config.workerToken ?? undefined, + mediaProfilesDir: config.profilesDir, + }; +} diff --git a/apps/api/src/media-fakes.ts b/apps/api/src/media-fakes.ts new file mode 100644 index 0000000..ac7ec5d --- /dev/null +++ b/apps/api/src/media-fakes.ts @@ -0,0 +1,386 @@ +import type { MediaInferenceProvider } from "@rakazo/adapter-kit"; +import type { + MediaActor, + MediaIngestService, + MediaMarketingService, + MediaPrivacyService, + MediaRenderService, + MediaServiceDeps, + MediaServices, +} from "@rakazo/adapters"; +import type { PlatformProfile } from "@rakazo/contracts"; +import { mediaRpcShapes } from "@rakazo/contracts"; +import type { MediaRepos, PrismaClient } from "@rakazo/db"; + +/* + * In-memory stand-ins for the media service slices, for API tests only. They + * record what the router hands them and do the minimum through the real + * repositories; nothing here renders, detects, or publishes. + */ + +export interface MediaServiceCall { + service: "ingest" | "privacy" | "render" | "marketing"; + method: string; + actor: MediaActor; + input: unknown; +} + +export interface FakeMediaServicesOptions { + repos: MediaRepos; + mediaRoot: string; + prisma?: PrismaClient; + inference?: MediaInferenceProvider | null; + profiles?: PlatformProfile[]; + /** Render id → absolute output path the fake render service reports. */ + renderOutputs?: Map; + onUploadCompleted?: ( + actor: MediaActor, + input: { assetId: string; tempPath: string; sha256: string; fileName: string }, + ) => Promise; +} + +export function fakeMediaServices(options: FakeMediaServicesOptions): { + services: MediaServices; + calls: MediaServiceCall[]; +} { + const calls: MediaServiceCall[] = []; + const { repos } = options; + const record = ( + service: MediaServiceCall["service"], + method: string, + actor: MediaActor, + input: unknown, + ) => { + calls.push({ service, method, actor: { spaceId: actor.spaceId, userId: actor.userId }, input }); + }; + + const ingest: MediaIngestService = { + async pushItems(actor, input) { + record("ingest", "pushItems", actor, input); + return { created: 0, updated: 0, unchanged: 0, deleted: 0 }; + }, + async requestSync(actor, sourceId) { + record("ingest", "requestSync", actor, sourceId); + }, + async uploadCompleted(actor, input) { + record("ingest", "uploadCompleted", actor, input); + await options.onUploadCompleted?.(actor, input); + }, + async recluster(actor) { + record("ingest", "recluster", actor, null); + }, + async removeSource(actor, sourceId) { + record("ingest", "removeSource", actor, sourceId); + await repos.removeSource(actor.spaceId, sourceId); + }, + }; + + const privacy: MediaPrivacyService = { + async prepareSources() { + throw new Error("prepareSources is not part of the API fakes"); + }, + async gate() { + throw new Error("gate is not part of the API fakes"); + }, + async createMark(actor, input) { + record("privacy", "createMark", actor, input); + return repos.createMark(actor.spaceId, input); + }, + async updateMark(actor, input) { + record("privacy", "updateMark", actor, input); + const { markId, ...patch } = input; + return repos.updateMark(actor.spaceId, markId, patch); + }, + async removePerson(actor, personId) { + record("privacy", "removePerson", actor, personId); + await repos.removePersonCompletely(actor.spaceId, personId); + }, + }; + + const render: MediaRenderService = { + async start(actor, input) { + record("render", "start", actor, input); + return repos.createRender(actor.spaceId, { ...input, createdBy: actor.userId }); + }, + async cancel(actor, renderId) { + record("render", "cancel", actor, renderId); + return repos.cancelRender(actor.spaceId, renderId); + }, + async outputPath(actor, renderId, fileName) { + record("render", "outputPath", actor, { renderId, fileName }); + const file = options.renderOutputs?.get(renderId); + return file?.endsWith(`/${fileName}`) ? file : null; + }, + }; + + const marketing: MediaMarketingService = { + async autoPlan(actor, input) { + record("marketing", "autoPlan", actor, input); + return []; + }, + async submitPlan(actor, input) { + record("marketing", "submitPlan", actor, input); + return { ok: false, errors: [] }; + }, + async introVariants(actor, input) { + record("marketing", "introVariants", actor, input); + return []; + }, + async thumbnails(actor, input) { + record("marketing", "thumbnails", actor, input); + return []; + }, + async copy(actor, input) { + record("marketing", "copy", actor, input); + const render = await repos.getRender(actor.spaceId, input.renderId); + return { + profileId: render.profileId, + language: input.language, + title: "Title", + hook: "Hook", + caption: "Caption", + hashtags: [], + issues: [], + }; + }, + async createPublication(actor, input) { + record("marketing", "createPublication", actor, input); + return repos.createPublication(actor.spaceId, actor.userId, { + ...input, + trackingUrl: null, + scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : null, + }); + }, + async bundle(actor, publicationId) { + record("marketing", "bundle", actor, publicationId); + return repos.markPublicationBundled(actor.spaceId, publicationId, null); + }, + async cancelPublication(actor, publicationId) { + record("marketing", "cancelPublication", actor, publicationId); + return repos.cancelPublication(actor.spaceId, publicationId); + }, + async importMetricsCsv(actor, input) { + record("marketing", "importMetricsCsv", actor, input); + return { imported: 0, skipped: [] }; + }, + }; + + const deps = { + prisma: options.prisma, + repos, + jobs: { publish: async () => undefined }, + inference: options.inference ?? null, + renderer: { + id: "fake", + hardwareEncoders: async () => ["h264_fake"], + render: async () => { + throw new Error("render is not part of the API fakes"); + }, + }, + motion: { id: "fake", templates: () => [], render: async () => ({ path: "", hasAlpha: true }) }, + publisher: { id: "fake", publish: async () => ({ status: "bundled", externalUrl: null }) }, + shortener: null, + sources: {}, + mediaRoot: options.mediaRoot, + profiles: async () => ({ + profiles: new Map( + (options.profiles ?? []).map((profile) => [ + profile.id, + { profile, origin: "builtin" as const }, + ]), + ), + errors: [], + }), + artifacts: {}, + model: { generate: async () => null }, + publish: async () => undefined, + now: () => new Date("2026-10-01T00:00:00.000Z"), + } as unknown as MediaServiceDeps; + + return { services: { deps, ingest, privacy, render, marketing }, calls }; +} + +/** Every RPC path of the media contract, e.g. "sources/list". */ +export function mediaProcedurePaths(): string[] { + const paths: string[] = []; + const walk = (node: Record, prefix: string[]) => { + for (const [key, value] of Object.entries(node)) { + const child = value as Record; + if ("input" in child && "output" in child) paths.push([...prefix, key].join("/")); + else walk(child, [...prefix, key]); + } + }; + walk(mediaRpcShapes, []); + return paths; +} + +export interface MediaSampleIds { + sourceId: string; + assetId: string; + secondAssetId: string; + segmentId: string; + trackId: string; + personId: string; + markId: string; + collectionId: string; + brandKitId: string; + projectId: string; + planId: string; + renderId: string; + publicationId: string; + mappingId: string; + uploadId: string; +} + +/** + * How a procedure must treat another space's ids: refuse them as not found, + * answer only from the caller's own space, or be covered by a dedicated test. + */ +export type ForeignExpectation = "not_found" | "scoped" | "dedicated"; + +/** A valid input for every media procedure, built from one space's ids. */ +export function sampleMediaInputs( + ids: MediaSampleIds, + kit: unknown, +): Record { + const at = "2026-09-01T10:00:00.000Z"; + return { + capabilities: { input: {}, foreign: "scoped" }, + "sources/list": { input: {}, foreign: "scoped" }, + "sources/createFolder": { input: { label: "Footage", path: "/" }, foreign: "dedicated" }, + "sources/registerClient": { + input: { kind: "ios_library", label: "Phone", deviceId: "device-sample" }, + foreign: "scoped", + }, + "sources/pushItems": { + input: { sourceId: ids.sourceId, items: [], syncToken: null, complete: true }, + foreign: "not_found", + }, + "sources/sync": { input: { sourceId: ids.sourceId }, foreign: "not_found" }, + "sources/remove": { input: { sourceId: ids.sourceId }, foreign: "not_found" }, + "assets/search": { input: {}, foreign: "scoped" }, + "assets/get": { input: { assetId: ids.assetId }, foreign: "not_found" }, + "assets/setPaidOnly": { + input: { segmentIds: [ids.segmentId], paidOnly: true }, + foreign: "not_found", + }, + "assets/requestUpload": { input: { assetIds: [ids.assetId] }, foreign: "not_found" }, + "uploads/begin": { + input: { + assetId: ids.secondAssetId, + sizeBytes: 10, + sha256: "a".repeat(64), + fileName: "clip.mp4", + }, + foreign: "not_found", + }, + "uploads/status": { input: { uploadId: ids.uploadId }, foreign: "not_found" }, + "uploads/complete": { input: { uploadId: ids.uploadId }, foreign: "not_found" }, + "collections/list": { input: {}, foreign: "scoped" }, + "collections/get": { input: { collectionId: ids.collectionId }, foreign: "not_found" }, + "collections/rename": { + input: { collectionId: ids.collectionId, title: "Renamed" }, + foreign: "not_found", + }, + "collections/merge": { + input: { collectionIds: [ids.collectionId, `${ids.collectionId}x`] }, + foreign: "not_found", + }, + "collections/split": { + input: { collectionId: ids.collectionId, atAssetId: ids.assetId }, + foreign: "not_found", + }, + "collections/createManual": { + input: { title: "Picks", assetIds: [ids.assetId] }, + foreign: "not_found", + }, + "collections/recluster": { input: {}, foreign: "scoped" }, + "people/list": { input: {}, foreign: "scoped" }, + "people/update": { input: { personId: ids.personId, displayName: "X" }, foreign: "not_found" }, + "people/merge": { + input: { personIds: [ids.personId, `${ids.personId}x`] }, + foreign: "not_found", + }, + "people/assignTrack": { input: { trackId: ids.trackId, personId: null }, foreign: "not_found" }, + "people/recordConsent": { + input: { + personId: ids.personId, + consentArtifactId: null, + consentRecordedAt: at, + ageVerificationMethod: "platform_verified", + ageVerifiedAt: at, + }, + foreign: "not_found", + }, + "people/remove": { input: { personId: ids.personId }, foreign: "not_found" }, + "marks/list": { input: { assetId: ids.assetId }, foreign: "scoped" }, + "marks/create": { + input: { kind: "tattoo", assetId: ids.assetId, prompt: { tMs: 0 }, treatment: "blur" }, + foreign: "not_found", + }, + "marks/update": { input: { markId: ids.markId, label: "x" }, foreign: "not_found" }, + "marks/remove": { input: { markId: ids.markId }, foreign: "not_found" }, + "brandKits/list": { input: {}, foreign: "scoped" }, + "brandKits/upsert": { input: { id: ids.brandKitId, kit }, foreign: "not_found" }, + "brandKits/remove": { input: { id: ids.brandKitId }, foreign: "not_found" }, + "brandKits/templates": { input: {}, foreign: "scoped" }, + "brandKits/introVariants": { input: { id: ids.brandKitId }, foreign: "not_found" }, + "profiles/list": { input: {}, foreign: "scoped" }, + "profiles/get": { input: { profileId: "example-short-vertical" }, foreign: "scoped" }, + "projects/list": { input: {}, foreign: "scoped" }, + "projects/get": { input: { projectId: ids.projectId }, foreign: "not_found" }, + "projects/create": { + input: { + title: "Teaser", + goal: "teaser", + collectionIds: [ids.collectionId], + profileIds: ["example-short-vertical"], + }, + foreign: "not_found", + }, + "projects/update": { input: { projectId: ids.projectId, title: "X" }, foreign: "not_found" }, + "projects/remove": { input: { projectId: ids.projectId }, foreign: "not_found" }, + "projects/autoPlan": { input: { projectId: ids.projectId }, foreign: "not_found" }, + "plans/submit": { input: { projectId: ids.projectId, edl: {} }, foreign: "not_found" }, + "renders/start": { input: { planId: ids.planId, quality: "draft" }, foreign: "not_found" }, + "renders/get": { input: { renderId: ids.renderId }, foreign: "not_found" }, + "renders/cancel": { input: { renderId: ids.renderId }, foreign: "not_found" }, + "renders/thumbnails": { input: { renderId: ids.renderId }, foreign: "not_found" }, + "marketing/copy": { input: { renderId: ids.renderId }, foreign: "not_found" }, + "publications/list": { input: { projectId: ids.projectId }, foreign: "scoped" }, + "publications/create": { input: { renderId: ids.renderId }, foreign: "not_found" }, + "publications/bundle": { input: { publicationId: ids.publicationId }, foreign: "not_found" }, + "publications/markPublished": { + input: { publicationId: ids.publicationId, externalUrl: null }, + foreign: "not_found", + }, + "publications/cancel": { input: { publicationId: ids.publicationId }, foreign: "not_found" }, + "metrics/mappings": { input: {}, foreign: "scoped" }, + "metrics/saveMapping": { + input: { + id: ids.mappingId, + name: "Imported", + columns: { publication: "id", observedAt: "date" }, + publicationKey: "id", + }, + foreign: "not_found", + }, + "metrics/importCsv": { + input: { mappingId: ids.mappingId, csv: "id,date" }, + foreign: "not_found", + }, + "metrics/record": { + input: { + publicationId: ids.publicationId, + observedAt: at, + views: 1, + clicks: 0, + conversions: 0, + revenueCents: 0, + currency: "EUR", + }, + foreign: "not_found", + }, + "metrics/funnel": { input: { projectId: ids.projectId }, foreign: "not_found" }, + }; +} diff --git a/apps/api/src/media-http.ts b/apps/api/src/media-http.ts new file mode 100644 index 0000000..62fbdc2 --- /dev/null +++ b/apps/api/src/media-http.ts @@ -0,0 +1,266 @@ +import { createReadStream } from "node:fs"; +import { mkdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { Readable } from "node:stream"; +import type { MediaServices } from "@rakazo/adapters"; +import type { Actor } from "@rakazo/contracts"; +import { IsolationError, MediaStateConflictError } from "@rakazo/db"; +import { assertInside, PathOutsideRootError, resolveInside } from "@rakazo/media/node"; +import type { Context, Hono } from "hono"; +import { cancelBody } from "./http-body.js"; +import { UploadChunkError, writeUploadChunk } from "./media-uploads.js"; + +/* + * Byte routes Media Studio cannot express as JSON RPC: chunk uploads and + * streaming of proxies, thumbnails, renders, and bundles. Every id is checked + * against the actor's space before a path is derived, and every path is + * resolved inside the media root. + */ + +const ID = /^[A-Za-z0-9_-]{1,64}$/; +const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/; +const CHUNK_INDEX = /^[0-9]{1,7}$/; +/** A single chunk must arrive within this window; slow links resume per chunk. */ +const CHUNK_TIMEOUT_MS = 10 * 60_000; + +const CONTENT_TYPES: Record = { + ".mp4": "video/mp4", + ".m4v": "video/mp4", + ".mov": "video/quicktime", + ".webm": "video/webm", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".gif": "image/gif", + ".srt": "application/x-subrip", + ".vtt": "text/vtt; charset=utf-8", + ".json": "application/json", + ".md": "text/markdown; charset=utf-8", + ".txt": "text/plain; charset=utf-8", + ".zip": "application/zip", + ".m4a": "audio/mp4", + ".wav": "audio/wav", +}; + +export function mediaContentType(fileName: string): string { + return CONTENT_TYPES[path.extname(fileName).toLowerCase()] ?? "application/octet-stream"; +} + +export type ByteRange = { start: number; end: number }; + +/** + * Parse a single `bytes=` range. Returns null for no (or an ignorable + * multi-part) range and "unsatisfiable" when it cannot be served. + */ +export function parseByteRange( + header: string | undefined, + size: number, +): ByteRange | null | "unsatisfiable" { + if (!header) return null; + const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim()); + if (!match) return header.includes(",") ? null : "unsatisfiable"; + const [, first = "", last = ""] = match; + if (first === "" && last === "") return "unsatisfiable"; + if (first === "") { + const suffix = Number(last); + if (suffix === 0 || size === 0) return "unsatisfiable"; + return { start: Math.max(0, size - suffix), end: size - 1 }; + } + const start = Number(first); + const end = last === "" ? size - 1 : Math.min(Number(last), size - 1); + if (start >= size || end < start) return "unsatisfiable"; + return { start, end }; +} + +async function sendFile( + c: Context, + absolutePath: string, + options: { fileName: string; download?: boolean }, +): Promise { + const info = await stat(absolutePath).catch(() => null); + if (!info?.isFile()) return c.json({ error: "Not found" }, 404); + const size = info.size; + const headers: Record = { + "content-type": mediaContentType(options.fileName), + "accept-ranges": "bytes", + "cache-control": "private, max-age=300", + }; + if (options.download) { + headers["content-disposition"] = `attachment; filename="${options.fileName}"`; + } + const range = parseByteRange(c.req.header("range"), size); + if (range === "unsatisfiable") { + return new Response(null, { + status: 416, + headers: { ...headers, "content-range": `bytes */${size}` }, + }); + } + if (c.req.method === "HEAD") { + return new Response(null, { + status: 200, + headers: { ...headers, "content-length": String(size) }, + }); + } + const start = range?.start ?? 0; + const end = range?.end ?? size - 1; + const body = + size === 0 + ? null + : (Readable.toWeb( + createReadStream(absolutePath, { start, end }), + ) as ReadableStream); + return new Response(body, { + status: range ? 206 : 200, + headers: { + ...headers, + "content-length": String(size === 0 ? 0 : end - start + 1), + ...(range ? { "content-range": `bytes ${start}-${end}/${size}` } : {}), + }, + }); +} + +function mediaHttpError(c: Context, error: unknown): Response { + if (error instanceof IsolationError || error instanceof PathOutsideRootError) { + return c.json({ error: "Not found" }, 404); + } + if (error instanceof UploadChunkError) return c.json({ error: error.message }, error.status); + if (error instanceof MediaStateConflictError) return c.json({ error: error.message }, 409); + if ((error as NodeJS.ErrnoException | null)?.code === "ENOENT") { + return c.json({ error: "Not found" }, 404); + } + throw error; +} + +export function mountMediaHttpRoutes( + app: Hono, + deps: { services: MediaServices | null }, + resolveActor: (c: Context) => Promise, +) { + async function authorize( + c: Context, + ): Promise< + { actor: Actor; services: MediaServices; response?: undefined } | { response: Response } + > { + const actor = await resolveActor(c); + if (!actor) return { response: c.json({ error: "Unauthorized" }, 401) }; + if (!deps.services) + return { response: c.json({ error: "Media Studio is not configured" }, 404) }; + return { actor, services: deps.services }; + } + + async function mediaFile(services: MediaServices, relative: string | null) { + if (!relative) return null; + await mkdir(services.deps.mediaRoot, { recursive: true }); + return resolveInside(services.deps.mediaRoot, relative); + } + + app.put("/api/media/uploads/:uploadId/chunks/:index", async (c) => { + const auth = await authorize(c); + if (auth.response) { + cancelBody(c.req.raw.body); + return auth.response; + } + const uploadId = c.req.param("uploadId"); + const index = c.req.param("index"); + if (!ID.test(uploadId) || !CHUNK_INDEX.test(index)) { + cancelBody(c.req.raw.body); + return c.json({ error: "Not found" }, 404); + } + const lengthHeader = c.req.header("content-length"); + const declared = lengthHeader && /^[0-9]+$/.test(lengthHeader) ? Number(lengthHeader) : null; + const timeout = AbortSignal.timeout(CHUNK_TIMEOUT_MS); + const body = c.req.raw.body; + const onTimeout = () => cancelBody(body); + timeout.addEventListener("abort", onTimeout, { once: true }); + try { + const upload = await writeUploadChunk({ + services: auth.services, + spaceId: auth.actor.spaceId, + uploadId, + index: Number(index), + body, + declaredLength: declared, + }); + return c.json(upload); + } catch (error) { + cancelBody(body); + if (timeout.aborted) return c.json({ error: "Chunk took too long" }, 408); + return mediaHttpError(c, error); + } finally { + timeout.removeEventListener("abort", onTimeout); + } + }); + + app.get("/api/media/renders/:renderId/files/:fileName", async (c) => { + const auth = await authorize(c); + if (auth.response) return auth.response; + const renderId = c.req.param("renderId"); + const fileName = c.req.param("fileName"); + if (!ID.test(renderId) || !FILE_NAME.test(fileName) || fileName.includes("..")) { + return c.json({ error: "Not found" }, 404); + } + try { + const render = await auth.services.deps.repos.getRenderRecord(auth.actor.spaceId, renderId); + const download = c.req.query("download") === "1"; + // Only a render that passed its gate leaves the app as a download. + if (download && render.status !== "done") return c.json({ error: "Not found" }, 404); + const absolute = await auth.services.render.outputPath(auth.actor, renderId, fileName); + if (!absolute) return c.json({ error: "Not found" }, 404); + await mkdir(auth.services.deps.mediaRoot, { recursive: true }); + const inside = await assertInside( + await resolveInside( + auth.services.deps.mediaRoot, + `${auth.actor.spaceId}/renders/${renderId}`, + ), + absolute, + ); + return await sendFile(c, inside, { fileName, download }); + } catch (error) { + return mediaHttpError(c, error); + } + }); + + for (const kind of ["thumb", "proxy"] as const) { + app.get(`/api/media/assets/:assetId/${kind}`, async (c) => { + const auth = await authorize(c); + if (auth.response) return auth.response; + const assetId = c.req.param("assetId"); + if (!ID.test(assetId)) return c.json({ error: "Not found" }, 404); + try { + const asset = await auth.services.deps.repos.getAssetRecord(auth.actor.spaceId, assetId); + const relative = kind === "thumb" ? asset.thumbPath : asset.proxyPath; + const absolute = await mediaFile(auth.services, relative); + if (!absolute) return c.json({ error: "Not found" }, 404); + return await sendFile(c, absolute, { fileName: path.basename(absolute) }); + } catch (error) { + return mediaHttpError(c, error); + } + }); + } + + app.get("/api/media/bundles/:publicationId", async (c) => { + const auth = await authorize(c); + if (auth.response) return auth.response; + const publicationId = c.req.param("publicationId"); + if (!ID.test(publicationId)) return c.json({ error: "Not found" }, 404); + try { + const publication = await auth.services.deps.repos.getPublicationRecord( + auth.actor.spaceId, + publicationId, + ); + if (publication.bundleArtifactId) return c.json({ error: "Not found" }, 404); + const absolute = await mediaFile( + auth.services, + `${auth.actor.spaceId}/bundles/${publication.id}/bundle.zip`, + ); + if (!absolute) return c.json({ error: "Not found" }, 404); + return await sendFile(c, absolute, { + fileName: `bundle-${publication.id}.zip`, + download: true, + }); + } catch (error) { + return mediaHttpError(c, error); + } + }); +} diff --git a/apps/api/src/media-uploads.ts b/apps/api/src/media-uploads.ts new file mode 100644 index 0000000..2d52365 --- /dev/null +++ b/apps/api/src/media-uploads.ts @@ -0,0 +1,194 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { mkdir, open, rename, rm, stat } from "node:fs/promises"; +import path from "node:path"; +import type { MediaActor, MediaServices } from "@rakazo/adapters"; +import type { MediaUpload } from "@rakazo/contracts"; +import { MediaStateConflictError } from "@rakazo/db"; +import { resolveInside } from "@rakazo/media/node"; + +/* + * Resumable chunked uploads. Each chunk lands in its own file + * (/uploads//chunks/.part, written to a temp name and + * renamed, so a repeated or concurrent PUT of the same index is harmless). + * Completion concatenates the chunks in order into /uploads//file + * while hashing, verifies size and SHA-256, and hands the file to ingest. + */ + +export class UploadChunkError extends Error { + constructor( + readonly status: 400 | 409 | 413, + message: string, + ) { + super(message); + this.name = "UploadChunkError"; + } +} + +interface UploadRow { + id: string; + spaceId: string; + assetId: string; + sizeBytes: bigint; + chunkBytes: number; + sha256: string; + fileName: string; + status: string; +} + +export function uploadChunkCount(row: Pick): number { + return Number((row.sizeBytes + BigInt(row.chunkBytes) - 1n) / BigInt(row.chunkBytes)); +} + +/** Exact byte length chunk `index` must have: every chunk is full except the last. */ +export function expectedChunkBytes( + row: Pick, + index: number, +): number { + const count = uploadChunkCount(row); + if (index < count - 1) return row.chunkBytes; + return Number(row.sizeBytes - BigInt(row.chunkBytes) * BigInt(count - 1)); +} + +function uploadDir(row: Pick): string { + return `${row.spaceId}/uploads/${row.id}`; +} + +async function chunksDir(mediaRoot: string, row: UploadRow): Promise { + await mkdir(mediaRoot, { recursive: true }); + const dir = await resolveInside(mediaRoot, `${uploadDir(row)}/chunks`); + await mkdir(dir, { recursive: true }); + return dir; +} + +/** + * Stream one chunk body to disk without buffering more than one network read. + * Rejects bodies longer or shorter than the chunk's exact size. + */ +export async function writeUploadChunk(input: { + services: MediaServices; + spaceId: string; + uploadId: string; + index: number; + body: ReadableStream | null; + declaredLength: number | null; +}): Promise { + const { repos, mediaRoot } = input.services.deps; + const row = (await repos.getUploadRecord(input.spaceId, input.uploadId)) as UploadRow; + if (row.status !== "open") throw new UploadChunkError(409, "Upload is not open"); + const count = uploadChunkCount(row); + if (!Number.isInteger(input.index) || input.index < 0 || input.index >= count) { + throw new UploadChunkError(400, "Chunk index out of range"); + } + const expected = expectedChunkBytes(row, input.index); + if (input.declaredLength !== null && input.declaredLength > expected) { + throw new UploadChunkError(413, "Chunk is too large"); + } + if (!input.body) throw new UploadChunkError(400, "Chunk is empty"); + const dir = await chunksDir(mediaRoot, row); + const finalPath = path.join(dir, `${input.index}.part`); + const tempPath = path.join(dir, `${input.index}.${randomUUID()}.tmp`); + const handle = await open(tempPath, "wx"); + let written = 0; + let ok = false; + const reader = input.body.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + written += value.byteLength; + if (written > expected) { + void reader.cancel().catch(() => undefined); + throw new UploadChunkError(413, "Chunk is too large"); + } + await handle.write(value); + } + if (written !== expected) throw new UploadChunkError(400, "Chunk is incomplete"); + ok = true; + } finally { + reader.releaseLock(); + await handle.close(); + if (!ok) await rm(tempPath, { force: true }); + } + await rename(tempPath, finalPath); + return repos.markChunkReceived(input.spaceId, input.uploadId, input.index); +} + +const completing = new Set(); + +/** + * Assemble a fully received upload, verify it, hand it to ingest, and close it. + * A checksum mismatch fails the upload so the client starts a fresh one. + */ +export async function completeMediaUpload( + services: MediaServices, + actor: MediaActor, + uploadId: string, +): Promise { + const { repos, mediaRoot } = services.deps; + const row = (await repos.getUploadRecord(actor.spaceId, uploadId)) as UploadRow; + if (row.status === "complete") return repos.uploadStatus(actor.spaceId, uploadId); + if (row.status !== "open") throw new MediaStateConflictError("Upload is not open"); + const status = await repos.uploadStatus(actor.spaceId, uploadId); + const received = new Set(status.receivedChunks); + const count = uploadChunkCount(row); + for (let index = 0; index < count; index++) { + if (!received.has(index)) throw new MediaStateConflictError("Upload is missing chunks"); + } + if (completing.has(row.id)) throw new MediaStateConflictError("Upload is already completing"); + completing.add(row.id); + try { + const dir = await chunksDir(mediaRoot, row); + const relativeFile = `${uploadDir(row)}/file`; + const filePath = await resolveInside(mediaRoot, relativeFile); + const tempFile = await resolveInside(mediaRoot, `${uploadDir(row)}/file.${randomUUID()}.tmp`); + const hash = createHash("sha256"); + let total = 0; + const out = await open(tempFile, "wx"); + try { + for (let index = 0; index < count; index++) { + const chunkPath = path.join(dir, `${index}.part`); + const info = await stat(chunkPath).catch(() => null); + if (!info || info.size !== expectedChunkBytes(row, index)) { + throw new MediaStateConflictError("Upload is missing chunks"); + } + for await (const buffer of createReadStream(chunkPath)) { + hash.update(buffer as Buffer); + total += (buffer as Buffer).byteLength; + await out.write(buffer as Buffer); + } + } + } catch (error) { + await out.close(); + await rm(tempFile, { force: true }); + throw error; + } + await out.close(); + const sha256 = hash.digest("hex"); + if (sha256 !== row.sha256 || BigInt(total) !== row.sizeBytes) { + await rm(await resolveInside(mediaRoot, uploadDir(row)), { recursive: true, force: true }); + await repos.failUpload(actor.spaceId, uploadId); + throw new UploadChunkError(400, "Upload does not match its checksum"); + } + await rename(tempFile, filePath); + const before = (await repos.getAssetRecord(actor.spaceId, row.assetId)).storagePath; + await services.ingest.uploadCompleted(actor, { + assetId: row.assetId, + tempPath: filePath, + sha256, + fileName: row.fileName, + }); + // Ingest moves the file into the asset folder and records where; fall back + // to the assembled file when it left the bytes in place. + const after = (await repos.getAssetRecord(actor.spaceId, row.assetId)).storagePath; + const storagePath = after && after !== before ? after : relativeFile; + const done = await repos.completeUpload(actor.spaceId, uploadId, { storagePath }); + await rm(dir, { recursive: true, force: true }); + if (storagePath !== relativeFile) { + await rm(await resolveInside(mediaRoot, uploadDir(row)), { recursive: true, force: true }); + } + return done; + } finally { + completing.delete(row.id); + } +} diff --git a/apps/api/src/media.ts b/apps/api/src/media.ts index 6b4ec4a..7361a12 100644 --- a/apps/api/src/media.ts +++ b/apps/api/src/media.ts @@ -1,113 +1,527 @@ +import { lstat, realpath, rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { implement, ORPCError } from "@orpc/server"; -import type { Actor } from "@rakazo/contracts"; -import { appContract } from "@rakazo/contracts"; +import { UnsupportedMediaOperationError } from "@rakazo/adapter-kit"; +import type { MediaServices } from "@rakazo/adapters"; +import type { Actor, MediaCapabilities, MediaSourceKind } from "@rakazo/contracts"; +import { appContract, MediaInferenceOperationSchema } from "@rakazo/contracts"; import type { PrismaClient } from "@rakazo/db"; +import { + InvalidMediaInputError, + InvalidProjectWorkspacePathError, + IsolationError, + lockMediaKey, + MediaStateConflictError, + mapMediaSource, + resolveProjectWorkspaceDirectory, +} from "@rakazo/db"; +import { summarizeProfile } from "@rakazo/media"; +import { PathOutsideRootError, resolveInside } from "@rakazo/media/node"; +import { completeMediaUpload, UploadChunkError } from "./media-uploads.js"; export interface MediaRouterDeps { prisma: PrismaClient; + /** Null when Media Studio is not configured: everything but capabilities refuses. */ + services?: MediaServices | null; + /** DATA_DIR; a folder source may neither sit inside it nor contain it. */ dataDir: string; + /** Application install directory a folder source may not point into. */ + appRoot?: string; } -// Phase A scaffold: every procedure is replaced by its real implementation. -function notImplemented(): never { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Media Studio is not available yet" }); +export const MEDIA_NOT_CONFIGURED = "Media Studio is not configured"; + +const DEFAULT_APP_ROOT = fileURLToPath(new URL("../../../", import.meta.url)); + +/** Translate domain errors into RPC errors; everything else passes through. */ +export function mediaRpcError(error: unknown): unknown { + if (error instanceof ORPCError) return error; + if (error instanceof IsolationError) return new ORPCError("NOT_FOUND"); + if ( + error instanceof InvalidMediaInputError || + error instanceof InvalidProjectWorkspacePathError + ) { + return new ORPCError("BAD_REQUEST", { message: error.message }); + } + if (error instanceof PathOutsideRootError) { + return new ORPCError("BAD_REQUEST", { message: "That path is not allowed." }); + } + if (error instanceof MediaStateConflictError) { + return new ORPCError("CONFLICT", { message: error.message }); + } + if (error instanceof UploadChunkError) { + return new ORPCError(error.status === 409 ? "CONFLICT" : "BAD_REQUEST", { + message: error.message, + }); + } + if (error instanceof UnsupportedMediaOperationError) { + return new ORPCError("PRECONDITION_FAILED", { message: error.message }); + } + return error; } -export function createMediaRouter(_deps: MediaRouterDeps) { +export function unconfiguredCapabilities(): MediaCapabilities { + const unavailable = { + available: false, + experimental: false, + model: null, + license: null, + commercialUse: null, + }; + return { + configured: false, + operations: Object.fromEntries( + MediaInferenceOperationSchema.options.map((operation) => [operation, unavailable]), + ) as MediaCapabilities["operations"], + accelerator: "none", + hardwareEncoders: [], + }; +} + +function overlaps(a: string, b: string): boolean { + const inside = (root: string, candidate: string) => { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + }; + return inside(a, b) || inside(b, a); +} + +async function realOrResolved(target: string): Promise { + return realpath(target).catch(() => path.resolve(target)); +} + +/** + * A host folder for a folder source: absolute, an existing directory, not a + * symlink, and apart from DATA_DIR and the application itself. Returns the real path. + */ +export async function validateMediaFolder( + input: string, + forbidden: { dataDir: string; appRoot: string }, +): Promise { + const trimmed = input.trim(); + if (!path.isAbsolute(trimmed)) { + throw new InvalidMediaInputError("Use an absolute folder path."); + } + const info = await lstat(trimmed).catch(() => null); + if (!info) throw new InvalidMediaInputError("That folder does not exist or is not readable."); + if (info.isSymbolicLink()) + throw new InvalidMediaInputError("Choose the folder, not a link to it."); + const resolved = await resolveProjectWorkspaceDirectory(trimmed); + if (path.parse(resolved).root === resolved) { + throw new InvalidMediaInputError("Choose a specific folder."); + } + const dataDir = await realOrResolved(forbidden.dataDir); + const appRoot = await realOrResolved(forbidden.appRoot); + const relativeToApp = path.relative(appRoot, resolved); + const insideApp = + relativeToApp === "" || (!relativeToApp.startsWith("..") && !path.isAbsolute(relativeToApp)); + if (overlaps(dataDir, resolved) || insideApp) { + throw new InvalidMediaInputError("That folder belongs to evOo itself."); + } + return resolved; +} + +async function removeMediaPath(mediaRoot: string, relative: string | null): Promise { + if (!relative) return; + await rm(await resolveInside(mediaRoot, relative), { recursive: true, force: true }); +} + +export function createMediaRouter(deps: MediaRouterDeps) { const os = implement(appContract.media).$context<{ actor: Actor | null; signal?: AbortSignal }>(); const authed = os.use(async ({ context, next }) => { if (!context.actor) throw new ORPCError("UNAUTHORIZED"); - return next({ context: { ...context, actor: context.actor } }); + try { + return await next({ context: { ...context, actor: context.actor } }); + } catch (error) { + throw mediaRpcError(error); + } }); + const media = authed.use(async ({ next }) => { + if (!deps.services) { + throw new ORPCError("PRECONDITION_FAILED", { message: MEDIA_NOT_CONFIGURED }); + } + return next({ context: { services: deps.services } }); + }); + const forbiddenRoots = { dataDir: deps.dataDir, appRoot: deps.appRoot ?? DEFAULT_APP_ROOT }; + + async function knownProfiles(services: MediaServices, profileIds: string[] | undefined) { + if (!profileIds) return; + const { profiles } = await services.deps.profiles(); + const unknown = profileIds.filter((id) => !profiles.has(id)); + if (unknown.length > 0) { + throw new InvalidMediaInputError(`Unknown profile: ${unknown.join(", ")}`); + } + } return os.router({ - capabilities: authed.capabilities.handler(notImplemented), + capabilities: authed.capabilities.handler(async ({ context }) => { + const services = deps.services; + if (!services) return unconfiguredCapabilities(); + const hardwareEncoders = await services.deps.renderer.hardwareEncoders().catch(() => []); + const inference = services.deps.inference; + if (!inference) return { ...unconfiguredCapabilities(), hardwareEncoders }; + const capabilities = await inference + .capabilities(context.signal) + .catch(() => ({ ...unconfiguredCapabilities(), configured: true })); + return { ...capabilities, configured: true, hardwareEncoders }; + }), sources: { - list: authed.sources.list.handler(notImplemented), - createFolder: authed.sources.createFolder.handler(notImplemented), - registerClient: authed.sources.registerClient.handler(notImplemented), - pushItems: authed.sources.pushItems.handler(notImplemented), - sync: authed.sources.sync.handler(notImplemented), - remove: authed.sources.remove.handler(notImplemented), + list: media.sources.list.handler(({ context }) => + context.services.deps.repos.listSources(context.actor.spaceId), + ), + createFolder: media.sources.createFolder.handler(async ({ context, input }) => { + // A folder lives on the host, and the host is the deployment owner's machine. + if (!context.actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN"); + const folder = await validateMediaFolder(input.path, forbiddenRoots); + const { repos } = context.services.deps; + const source = await repos.createSource(context.actor.spaceId, { + kind: "folder", + label: input.label, + ownerUserId: context.actor.userId, + config: { path: folder }, + }); + await context.services.ingest.requestSync(context.actor, source.id); + return source; + }), + registerClient: media.sources.registerClient.handler(async ({ context, input }) => { + const { spaceId, userId } = context.actor; + const kind: MediaSourceKind = input.kind; + // One source per device: re-registering returns the existing one. + return deps.prisma.$transaction(async (tx) => { + await lockMediaKey(tx, `source:${spaceId}:${kind}:${input.deviceId}`); + const existing = await tx.mediaSource.findFirst({ + where: { spaceId, kind, config: { path: ["deviceId"], equals: input.deviceId } }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + include: { _count: { select: { assets: true } } }, + }); + if (existing) return mapMediaSource(existing, existing._count.assets); + const created = await tx.mediaSource.create({ + data: { + spaceId, + kind, + label: input.label, + ownerUserId: userId, + config: { deviceId: input.deviceId }, + }, + }); + return mapMediaSource(created, 0); + }); + }), + pushItems: media.sources.pushItems.handler(async ({ context, input }) => { + await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId); + return context.services.ingest.pushItems(context.actor, input); + }), + sync: media.sources.sync.handler(async ({ context, input }) => { + await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId); + await context.services.ingest.requestSync(context.actor, input.sourceId); + return { ok: true as const }; + }), + remove: media.sources.remove.handler(async ({ context, input }) => { + await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId); + await context.services.ingest.removeSource(context.actor, input.sourceId); + return { ok: true as const }; + }), }, assets: { - search: authed.assets.search.handler(notImplemented), - get: authed.assets.get.handler(notImplemented), - setPaidOnly: authed.assets.setPaidOnly.handler(notImplemented), - requestUpload: authed.assets.requestUpload.handler(notImplemented), + search: media.assets.search.handler(({ context, input }) => + context.services.deps.repos.searchAssets(context.actor.spaceId, input), + ), + get: media.assets.get.handler(({ context, input }) => + context.services.deps.repos.getAsset(context.actor.spaceId, input.assetId), + ), + setPaidOnly: media.assets.setPaidOnly.handler(async ({ context, input }) => { + await context.services.deps.repos.setSegmentsPaidOnly( + context.actor.spaceId, + input.segmentIds, + input.paidOnly, + ); + return { ok: true as const }; + }), + requestUpload: media.assets.requestUpload.handler(async ({ context, input }) => ({ + awaiting: await context.services.deps.repos.requestUpload( + context.actor.spaceId, + input.assetIds, + ), + })), }, uploads: { - begin: authed.uploads.begin.handler(notImplemented), - status: authed.uploads.status.handler(notImplemented), - complete: authed.uploads.complete.handler(notImplemented), + begin: media.uploads.begin.handler(async ({ context, input }) => { + const { repos } = context.services.deps; + const asset = await repos.getAssetRecord(context.actor.spaceId, input.assetId); + if (asset.storage !== "remote" && asset.ingestState !== "awaiting_upload") { + throw new MediaStateConflictError("This item does not need an upload"); + } + return repos.beginUpload(context.actor.spaceId, { + assetId: input.assetId, + userId: context.actor.userId, + sizeBytes: input.sizeBytes, + sha256: input.sha256, + fileName: input.fileName, + }); + }), + status: media.uploads.status.handler(({ context, input }) => + context.services.deps.repos.uploadStatus(context.actor.spaceId, input.uploadId), + ), + complete: media.uploads.complete.handler(({ context, input }) => + completeMediaUpload(context.services, context.actor, input.uploadId), + ), }, collections: { - list: authed.collections.list.handler(notImplemented), - get: authed.collections.get.handler(notImplemented), - rename: authed.collections.rename.handler(notImplemented), - merge: authed.collections.merge.handler(notImplemented), - split: authed.collections.split.handler(notImplemented), - createManual: authed.collections.createManual.handler(notImplemented), - recluster: authed.collections.recluster.handler(notImplemented), + list: media.collections.list.handler(({ context, input }) => + context.services.deps.repos.listCollections(context.actor.spaceId, input), + ), + get: media.collections.get.handler(({ context, input }) => + context.services.deps.repos.getCollection(context.actor.spaceId, input.collectionId), + ), + rename: media.collections.rename.handler(({ context, input }) => + context.services.deps.repos.renameCollection( + context.actor.spaceId, + input.collectionId, + input.title, + ), + ), + merge: media.collections.merge.handler(({ context, input }) => + context.services.deps.repos.mergeCollections(context.actor.spaceId, input.collectionIds), + ), + split: media.collections.split.handler(({ context, input }) => + context.services.deps.repos.splitCollection( + context.actor.spaceId, + input.collectionId, + input.atAssetId, + ), + ), + createManual: media.collections.createManual.handler(({ context, input }) => + context.services.deps.repos.createManualCollection( + context.actor.spaceId, + input.title, + input.assetIds, + ), + ), + recluster: media.collections.recluster.handler(async ({ context }) => { + await context.services.ingest.recluster(context.actor); + return { ok: true as const }; + }), }, people: { - list: authed.people.list.handler(notImplemented), - update: authed.people.update.handler(notImplemented), - merge: authed.people.merge.handler(notImplemented), - assignTrack: authed.people.assignTrack.handler(notImplemented), - recordConsent: authed.people.recordConsent.handler(notImplemented), - remove: authed.people.remove.handler(notImplemented), + list: media.people.list.handler(({ context }) => + context.services.deps.repos.listPeople(context.actor.spaceId), + ), + update: media.people.update.handler(({ context, input }) => + context.services.deps.repos.updatePerson(context.actor.spaceId, input.personId, { + displayName: input.displayName, + isSelf: input.isSelf, + confirmed: input.confirmed, + anonymizeByDefault: input.anonymizeByDefault, + }), + ), + merge: media.people.merge.handler(({ context, input }) => + context.services.deps.repos.mergePeople(context.actor.spaceId, input.personIds), + ), + assignTrack: media.people.assignTrack.handler(({ context, input }) => + context.services.deps.repos.assignTrack( + context.actor.spaceId, + input.trackId, + input.personId, + ), + ), + // The only consent write path: the signed-in member is the recorder. + recordConsent: media.people.recordConsent.handler(({ context, input }) => + context.services.deps.repos.recordConsent( + { spaceId: context.actor.spaceId, recordedByUserId: context.actor.userId }, + input, + ), + ), + remove: media.people.remove.handler(async ({ context, input }) => { + await context.services.deps.repos.getPerson(context.actor.spaceId, input.personId); + await context.services.privacy.removePerson(context.actor, input.personId); + return { ok: true as const }; + }), }, marks: { - list: authed.marks.list.handler(notImplemented), - create: authed.marks.create.handler(notImplemented), - update: authed.marks.update.handler(notImplemented), - remove: authed.marks.remove.handler(notImplemented), + list: media.marks.list.handler(({ context, input }) => + context.services.deps.repos.listMarks(context.actor.spaceId, { + ...(input.personId ? { personId: input.personId } : {}), + ...(input.assetId ? { assetId: input.assetId } : {}), + }), + ), + create: media.marks.create.handler(async ({ context, input }) => { + const { repos } = context.services.deps; + await repos.getAssetRecord(context.actor.spaceId, input.assetId); + if (input.personId) await repos.getPerson(context.actor.spaceId, input.personId); + return context.services.privacy.createMark(context.actor, input); + }), + update: media.marks.update.handler(async ({ context, input }) => { + const { repos } = context.services.deps; + await repos.getMark(context.actor.spaceId, input.markId); + if (input.personId) await repos.getPerson(context.actor.spaceId, input.personId); + return context.services.privacy.updateMark(context.actor, input); + }), + remove: media.marks.remove.handler(async ({ context, input }) => { + const { repos, mediaRoot } = context.services.deps; + const { maskDir } = await repos.removeMark(context.actor.spaceId, input.markId); + await removeMediaPath(mediaRoot, maskDir); + return { ok: true as const }; + }), }, brandKits: { - list: authed.brandKits.list.handler(notImplemented), - upsert: authed.brandKits.upsert.handler(notImplemented), - remove: authed.brandKits.remove.handler(notImplemented), - templates: authed.brandKits.templates.handler(notImplemented), - introVariants: authed.brandKits.introVariants.handler(notImplemented), + list: media.brandKits.list.handler(({ context }) => + context.services.deps.repos.listBrandKits(context.actor.spaceId), + ), + upsert: media.brandKits.upsert.handler(({ context, input }) => + context.services.deps.repos.upsertBrandKit(context.actor.spaceId, input), + ), + remove: media.brandKits.remove.handler(async ({ context, input }) => { + await context.services.deps.repos.removeBrandKit(context.actor.spaceId, input.id); + return { ok: true as const }; + }), + templates: media.brandKits.templates.handler(({ context }) => + context.services.deps.motion.templates(), + ), + introVariants: media.brandKits.introVariants.handler(async ({ context, input }) => { + await context.services.deps.repos.getBrandKit(context.actor.spaceId, input.id); + return context.services.marketing.introVariants(context.actor, { + brandKitId: input.id, + count: input.count, + }); + }), }, profiles: { - list: authed.profiles.list.handler(notImplemented), - get: authed.profiles.get.handler(notImplemented), + list: media.profiles.list.handler(async ({ context }) => { + const { profiles } = await context.services.deps.profiles(); + const now = context.services.deps.now(); + return [...profiles.values()].map(({ profile, origin }) => + summarizeProfile(profile, origin, now), + ); + }), + get: media.profiles.get.handler(async ({ context, input }) => { + const { profiles } = await context.services.deps.profiles(); + const entry = profiles.get(input.profileId); + if (!entry) throw new ORPCError("NOT_FOUND"); + return entry.profile; + }), }, projects: { - list: authed.projects.list.handler(notImplemented), - get: authed.projects.get.handler(notImplemented), - create: authed.projects.create.handler(notImplemented), - update: authed.projects.update.handler(notImplemented), - remove: authed.projects.remove.handler(notImplemented), - autoPlan: authed.projects.autoPlan.handler(notImplemented), + list: media.projects.list.handler(({ context, input }) => + context.services.deps.repos.listProjects(context.actor.spaceId, input), + ), + get: media.projects.get.handler(({ context, input }) => + context.services.deps.repos.getProject(context.actor.spaceId, input.projectId), + ), + create: media.projects.create.handler(async ({ context, input }) => { + await knownProfiles(context.services, input.profileIds); + return context.services.deps.repos.createProject( + context.actor.spaceId, + context.actor.userId, + input, + ); + }), + update: media.projects.update.handler(async ({ context, input }) => { + await knownProfiles(context.services, input.profileIds); + const { projectId, ...patch } = input; + return context.services.deps.repos.updateProject(context.actor.spaceId, projectId, patch); + }), + remove: media.projects.remove.handler(async ({ context, input }) => { + const { repos, mediaRoot } = context.services.deps; + const { spaceId } = context.actor; + await repos.getProjectRecord(spaceId, input.projectId); + const renders = await repos.listRenders(spaceId, input.projectId); + await repos.removeProject(spaceId, input.projectId); + for (const render of renders) + await removeMediaPath(mediaRoot, `${spaceId}/renders/${render.id}`); + return { ok: true as const }; + }), + autoPlan: media.projects.autoPlan.handler(async ({ context, input }) => { + await context.services.deps.repos.getProjectRecord(context.actor.spaceId, input.projectId); + await knownProfiles(context.services, input.profileIds); + return context.services.marketing.autoPlan(context.actor, input); + }), }, plans: { - submit: authed.plans.submit.handler(notImplemented), + submit: media.plans.submit.handler(async ({ context, input }) => { + await context.services.deps.repos.getProjectRecord(context.actor.spaceId, input.projectId); + return context.services.marketing.submitPlan(context.actor, { + projectId: input.projectId, + edl: input.edl, + rationale: input.rationale, + createdBy: "user", + }); + }), }, renders: { - start: authed.renders.start.handler(notImplemented), - get: authed.renders.get.handler(notImplemented), - cancel: authed.renders.cancel.handler(notImplemented), - thumbnails: authed.renders.thumbnails.handler(notImplemented), + start: media.renders.start.handler(async ({ context, input }) => { + await context.services.deps.repos.getPlan(context.actor.spaceId, input.planId); + return context.services.render.start(context.actor, input); + }), + get: media.renders.get.handler(({ context, input }) => + context.services.deps.repos.getRender(context.actor.spaceId, input.renderId), + ), + cancel: media.renders.cancel.handler(async ({ context, input }) => { + await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId); + return context.services.render.cancel(context.actor, input.renderId); + }), + thumbnails: media.renders.thumbnails.handler(async ({ context, input }) => { + await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId); + return context.services.marketing.thumbnails(context.actor, input); + }), }, marketing: { - copy: authed.marketing.copy.handler(notImplemented), + copy: media.marketing.copy.handler(async ({ context, input }) => { + await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId); + return context.services.marketing.copy(context.actor, input); + }), }, publications: { - list: authed.publications.list.handler(notImplemented), - create: authed.publications.create.handler(notImplemented), - bundle: authed.publications.bundle.handler(notImplemented), - markPublished: authed.publications.markPublished.handler(notImplemented), - cancel: authed.publications.cancel.handler(notImplemented), + list: media.publications.list.handler(({ context, input }) => + context.services.deps.repos.listPublications(context.actor.spaceId, input), + ), + create: media.publications.create.handler(async ({ context, input }) => { + await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId); + return context.services.marketing.createPublication(context.actor, input); + }), + bundle: media.publications.bundle.handler(async ({ context, input }) => { + await context.services.deps.repos.getPublication( + context.actor.spaceId, + input.publicationId, + ); + return context.services.marketing.bundle(context.actor, input.publicationId); + }), + markPublished: media.publications.markPublished.handler(({ context, input }) => + context.services.deps.repos.markPublicationPublished( + context.actor.spaceId, + input.publicationId, + input.externalUrl, + context.services.deps.now(), + ), + ), + cancel: media.publications.cancel.handler(async ({ context, input }) => { + await context.services.deps.repos.getPublication( + context.actor.spaceId, + input.publicationId, + ); + return context.services.marketing.cancelPublication(context.actor, input.publicationId); + }), }, metrics: { - mappings: authed.metrics.mappings.handler(notImplemented), - saveMapping: authed.metrics.saveMapping.handler(notImplemented), - importCsv: authed.metrics.importCsv.handler(notImplemented), - record: authed.metrics.record.handler(notImplemented), - funnel: authed.metrics.funnel.handler(notImplemented), + mappings: media.metrics.mappings.handler(({ context }) => + context.services.deps.repos.listMetricMappings(context.actor.spaceId), + ), + saveMapping: media.metrics.saveMapping.handler(({ context, input }) => + context.services.deps.repos.saveMetricMapping(context.actor.spaceId, input), + ), + importCsv: media.metrics.importCsv.handler(async ({ context, input }) => { + await context.services.deps.repos.getMetricMapping(context.actor.spaceId, input.mappingId); + return context.services.marketing.importMetricsCsv(context.actor, input); + }), + record: media.metrics.record.handler(({ context, input }) => + context.services.deps.repos.recordMetric(context.actor.spaceId, { + ...input, + source: "manual", + }), + ), + funnel: media.metrics.funnel.handler(({ context, input }) => + context.services.deps.repos.funnel(context.actor.spaceId, input.projectId), + ), }, }); }