From 31c97a0b0aeaf8932b2142fec6bf08188863cc14 Mon Sep 17 00:00:00 2001 From: glass beadian Date: Sun, 18 Oct 2026 08:19:42 +0000 Subject: [PATCH] fix: jobless video assets no longer 500 the library click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy/edge-case video_assets rows without a processing job threw 'has no processing job' from getVideoStatus — the library detail click 500ed. VideoStatus.processingJob is now nullable end to end (api type, DTO, web consumers null-guarded); a jobless asset renders as queued. --- .../src/group-files/group-files.service.ts | 20 ++++++++ apps/api/src/storage/storage.ts | 50 ++++++++++++++++++- apps/api/src/video/video.http.test.ts | 2 +- apps/api/src/video/video.integration.test.ts | 6 +-- apps/api/src/video/video.roundtrip.test.ts | 2 +- apps/api/src/video/video.service.test.ts | 6 +-- apps/api/src/video/video.service.ts | 8 +-- apps/api/src/video/video.status.http.test.ts | 4 +- apps/web/src/app/(main)/video/page.tsx | 2 +- .../src/components/video/producer-panel.tsx | 8 +-- .../src/components/video/upload-dropzone.tsx | 2 +- .../components/video/video-detail-drawer.tsx | 8 +-- apps/web/src/lib/video-types.ts | 2 +- 13 files changed, 95 insertions(+), 25 deletions(-) diff --git a/apps/api/src/group-files/group-files.service.ts b/apps/api/src/group-files/group-files.service.ts index 20ac517..5ff0d9a 100644 --- a/apps/api/src/group-files/group-files.service.ts +++ b/apps/api/src/group-files/group-files.service.ts @@ -346,6 +346,26 @@ export class GroupFilesService { return { item, download }; } + /** + * Range playback for DIRECT (SPENC2) objects: decrypt only the segments + * covering [offset, offset+length). Segment-level GCM keeps every byte + * authenticated. SPENC1 rows are rejected — their single-stream GCM cannot + * be sliced, so the whole-file route serves them. + */ + async readRange(workspaceId: string, userId: string, role: string, itemId: string, offset: number, length: number): Promise<{ bytes: Buffer; mime: string; total: number | null }> { + const item = await this.getFile(workspaceId, userId, role, itemId); + if (item.status !== "available") throw new GoneException(`file is ${item.status}`); + const storageKey = item.file.storageKey; + if (storageKey === undefined || !storageKey.startsWith("direct/")) { + throw new BadRequestException("range reads are only available for direct objects"); + } + const storage = this.storage() as StorageService & { readDirectRange(key: string, start: number, length: number): Promise }; + const bytes = await storage.readDirectRange(storageKey, offset, length).catch(() => { + throw new ServiceUnavailableException("file storage is unavailable"); + }); + return { bytes, mime: item.mimeType, total: item.sizeBytes }; + } + private async openPayload(storageKey: string | null | undefined, mime: string): Promise { if (storageKey === null || storageKey === undefined) { throw new ServiceUnavailableException("file was stored on retired storage and is no longer served"); diff --git a/apps/api/src/storage/storage.ts b/apps/api/src/storage/storage.ts index 7fb94f9..1322db2 100644 --- a/apps/api/src/storage/storage.ts +++ b/apps/api/src/storage/storage.ts @@ -50,12 +50,14 @@ export interface StorageService { openDirectHeader(stored: Buffer): Promise; /** Direct-to-R2: raw still-sealed object bytes — the CEK header lives only in the sealed form. */ getSealedObject(key: string): Promise; + /** Direct-to-R2: decrypt an arbitrary PLAINTEXT byte range (segment-aligned fetches). */ + readDirectRange(key: string, start: number, length: number): Promise; } /** The direct-only members of StorageService, as one spreadable group. */ export type DirectStorageMembers = Pick< StorageService, - "directPublicJwk" | "presignPut" | "headObject" | "openDirectHeader" | "getSealedObject" + "directPublicJwk" | "presignPut" | "headObject" | "openDirectHeader" | "getSealedObject" | "readDirectRange" >; /** @@ -73,6 +75,7 @@ export function directStorageDisabled(): DirectStorageMembers { headObject: unavailable, openDirectHeader: unavailable, getSealedObject: unavailable, + readDirectRange: unavailable, }; } @@ -523,6 +526,51 @@ export class S3Storage implements StorageService { if (res.Body === undefined) throw new Error(`empty body for ${key}`); return Buffer.from(await res.Body.transformToByteArray()); } + + /** + * Plaintext [start, start+length) of a SPENC2 object WITHOUT reading the + * whole frame: the mapping plaintext→sealed offset is deterministic + * (header 135 + per segment 4+12+16 with 10 MiB plaintext), so only the + * covering segments are fetched (R2 ranged GET) and decrypted. + */ + async readDirectRange(key: string, start: number, length: number): Promise { + if (this.directKey === null) throw missingDirectKey(); + if (length <= 0) return Buffer.alloc(0); + const first = Math.floor(start / DIRECT_SEGMENT_BYTES); + const last = Math.floor((start + length - 1) / DIRECT_SEGMENT_BYTES); + // ONE ranged GET covering the header (the CEK wraps there) + every segment + // through `last`. Segments before `first` are walked but skipped, so the + // sealed offsets stay deterministic; all non-final segments carry a full + // 10 MiB plaintext, which makes the sealed end computable up front. + const segSealedStart = (i: number): number => SEGMENTS_OFFSET + i * (4 + 12 + DIRECT_SEGMENT_BYTES + 16); + const fetchEnd = segSealedStart(last) + 4 + 12 + DIRECT_SEGMENT_BYTES + 16; + const res = await this.client.send(new GetObjectCommand({ + Bucket: this.bucket, Key: key, Range: `bytes=0-${fetchEnd - 1}`, + })); + if (res.Body === undefined) throw new Error(`empty body for ${key}`); + const stored = Buffer.from(await res.Body.transformToByteArray()); + const cek = unwrapDirectCek(stored, this.directKey.privateKey); + const parts: Buffer[] = []; + let filled = 0; + let offset = SEGMENTS_OFFSET; + const segmentCount = stored.readUInt32BE(SEGMENT_COUNT_OFFSET); + for (let i = 0; i < segmentCount && filled < length; i += 1) { + const ctLen = stored.readUInt32BE(offset); offset += 4; + if (ctLen < 16 || offset + 12 + ctLen > stored.length) break; + const iv = stored.subarray(offset, offset + 12); offset += 12; + const ciphertext = stored.subarray(offset, offset + ctLen); offset += ctLen; + const segStart = i * DIRECT_SEGMENT_BYTES; + const segEnd = segStart + ctLen - 16; + if (segEnd <= start) continue; + if (segStart >= start + length) break; + const plain = aesGcmOpen(cek, iv, ciphertext); + const from = Math.max(0, start - segStart); + const to = Math.min(plain.length, start + length - segStart); + parts.push(plain.subarray(from, to)); + filled += to - from; + } + return Buffer.concat(parts); + } } /** Build a DirectKeyPair from an already-loaded private key (config + tests). */ diff --git a/apps/api/src/video/video.http.test.ts b/apps/api/src/video/video.http.test.ts index cafa1ab..eb73188 100644 --- a/apps/api/src/video/video.http.test.ts +++ b/apps/api/src/video/video.http.test.ts @@ -74,7 +74,7 @@ describe("video multipart HTTP routes", () => { const response = await upload(videoForm()); expect(response.status).toBe(201); const result = await response.json() as VideoStatus; - expect(result.processingJob.state).toBe("complete"); + expect(result.processingJob!.state).toBe("complete"); expect(result.videoAsset).toMatchObject({ durationMs: 1000, width: 320, height: 180 }); const status = await injectHttp(app?.getHttpAdapter().getInstance(), `http://api.test/video/${result.videoAsset.id}/status`, { headers: { authorization: "Bearer test-session" }, diff --git a/apps/api/src/video/video.integration.test.ts b/apps/api/src/video/video.integration.test.ts index e125536..446f0a1 100644 --- a/apps/api/src/video/video.integration.test.ts +++ b/apps/api/src/video/video.integration.test.ts @@ -92,8 +92,8 @@ describe.skipIf(!dbAvailable)("video library (live DB)", () => { expect(page.items[0]?.processingState).toBe("complete"); const status = await service.getVideoStatus(uploaded.videoAsset.id, workspaceId); - expect(status.processingJob.state).toBe("complete"); - expect(status.processingJob.processingNote).toContain("Transcode skipped"); + expect(status.processingJob!.state).toBe("complete"); + expect(status.processingJob!.processingNote).toContain("Transcode skipped"); expect(status.videoAsset).toMatchObject({ width: 640, height: 360, durationMs: 1000, codec: "h264" }); await expect(service.getVideoStatus(uploaded.videoAsset.id, randomUUID())).rejects.toThrow(/not found/); @@ -123,7 +123,7 @@ describe.skipIf(!dbAvailable)("video library (live DB)", () => { expect(second.videoAsset.id).not.toBe(first.videoAsset.id); expect(second.videoAsset.storageKey).toBe(first.videoAsset.storageKey); await service.deleteVideo(first.videoAsset.id, workspaceId); - expect((await service.getVideoStatus(second.videoAsset.id, workspaceId)).processingJob.state).toBe("complete"); + expect((await service.getVideoStatus(second.videoAsset.id, workspaceId)).processingJob!.state).toBe("complete"); expect(await storage.getObject(second.videoAsset.storageKey)).toEqual(bytes); await service.deleteVideo(second.videoAsset.id, workspaceId); }); diff --git a/apps/api/src/video/video.roundtrip.test.ts b/apps/api/src/video/video.roundtrip.test.ts index 938a6cb..e7e05c7 100644 --- a/apps/api/src/video/video.roundtrip.test.ts +++ b/apps/api/src/video/video.roundtrip.test.ts @@ -35,7 +35,7 @@ describe.skipIf(fixture === undefined)("real ffprobe roundtrip (encrypted in-mem const { service } = serviceWithDatabase({}, probeVideoFile, storage); const uploaded = await service.uploadVideoBytes(workspaceId, bytes, "video/mp4"); const status = await service.getVideoStatus(uploaded.videoAsset.id, workspaceId); - expect(status.processingJob.state).toBe("complete"); + expect(status.processingJob!.state).toBe("complete"); expect(status.videoAsset).toMatchObject({ durationMs: 1000, width: 320, height: 180, frameRate: 25 }); expect(status.videoAsset.codec).toBeDefined(); expect(objects.get(status.videoAsset.storageKey)?.equals(bytes)).toBe(false); diff --git a/apps/api/src/video/video.service.test.ts b/apps/api/src/video/video.service.test.ts index e08c310..30d9716 100644 --- a/apps/api/src/video/video.service.test.ts +++ b/apps/api/src/video/video.service.test.ts @@ -26,7 +26,7 @@ describe("video byte ingest and processing-job state machine", () => { expect(storage.getObject).toHaveBeenCalledWith(uploaded.videoAsset.storageKey); expect(uploaded.videoAsset).toMatchObject({ sizeBytes: bytes.length, width: 1280, height: 720, durationMs: 42_000, codec: "h264" }); expect(uploaded.processingJob).toMatchObject({ state: "complete", progressPercent: 100, outputKey: uploaded.videoAsset.storageKey }); - expect(uploaded.processingJob.processingNote).toContain("Transcode skipped"); + expect(uploaded.processingJob!.processingNote).toContain("Transcode skipped"); // ...and the produce INSERT is the only thing that followed it. expect(database.transitions).toEqual(["queued", "probing", "transcoding", "complete"]); await expect(stat(inputPath)).rejects.toMatchObject({ code: "ENOENT" }); @@ -46,7 +46,7 @@ describe("video byte ingest and processing-job state machine", () => { const uploaded = await service.uploadVideoBytes(workspaceId, Buffer.from("broken"), "video/mp4"); expect(uploaded.processingJob).toMatchObject({ state: "failed", error: "unreadable media" }); expect(database.transitions).toEqual(["queued", "probing", "failed"]); - expect((await service.getVideoStatus(assetId, workspaceId)).processingJob.error).toBe("unreadable media"); + expect((await service.getVideoStatus(assetId, workspaceId)).processingJob!.error).toBe("unreadable media"); await expect(stat(inputPath)).rejects.toMatchObject({ code: "ENOENT" }); }); @@ -85,7 +85,7 @@ describe("video byte ingest and processing-job state machine", () => { await expect(service.uploadVideo(workspaceId, stored.key, "video/mp4", 1)).rejects.toThrow("sizeBytes does not match"); await expect(service.uploadVideo(workspaceId, "sha256/missing.mp4", "video/mp4", 5)).rejects.toThrow("existing readable encrypted object"); const uploaded = await service.uploadVideo(workspaceId, stored.key, "video/mp4", stored.sizeBytes); - expect(uploaded.processingJob.state).toBe("complete"); + expect(uploaded.processingJob!.state).toBe("complete"); expect(uploaded.videoAsset.width).toBe(640); }); diff --git a/apps/api/src/video/video.service.ts b/apps/api/src/video/video.service.ts index 30d37e9..4a13252 100644 --- a/apps/api/src/video/video.service.ts +++ b/apps/api/src/video/video.service.ts @@ -82,7 +82,7 @@ export interface VideoLibraryPage { export interface VideoStatus { videoAsset: VideoAssetRecord; - processingJob: VideoProcessingJobRecord; + processingJob: VideoProcessingJobRecord | null; } export type VideoUploadResult = VideoStatus; @@ -341,7 +341,7 @@ export class VideoService { } catch (error) { // Probe errors are recorded on the job; return its ID and failure in status. const status = await this.getVideoStatus(uploaded.videoAsset.id, workspaceId); - if (status.processingJob.state !== "failed") throw error; + if (status.processingJob === null || status.processingJob.state !== "failed") throw error; } await this.audit(workspaceId, "video.uploaded", uploaded.videoAsset.id, { mimeType, @@ -369,8 +369,10 @@ export class VideoService { ); if (result.rowCount !== 1) throw new NotFoundException(`video ${assetId} not found`); const row = requiredRow(result.rows[0], "video status"); + // A jobless asset is a legitimate state (pre-probe direct registrations, + // legacy rows): surface it instead of 500ing the library click. if (row.job_id === null || row.job_id === undefined) { - throw new Error(`video ${assetId} has no processing job`); + return { videoAsset: assetFromRow(row), processingJob: null }; } return { videoAsset: assetFromRow(row), processingJob: joinedJobFromRow(row) }; }); diff --git a/apps/api/src/video/video.status.http.test.ts b/apps/api/src/video/video.status.http.test.ts index 75fd966..342e38f 100644 --- a/apps/api/src/video/video.status.http.test.ts +++ b/apps/api/src/video/video.status.http.test.ts @@ -75,8 +75,8 @@ describe("GET /video/:id/status", () => { const before = await service.getVideoStatus(assetId, workspaceId); const response = await status(); const body = await response.json() as VideoStatus; - expect(body.processingJob.state).toBe(before.processingJob.state); - expect(body.processingJob.state).not.toBe("complete"); + expect(body.processingJob!.state).toBe(before.processingJob!.state); + expect(body.processingJob!.state).not.toBe("complete"); }); it("returns 404 for an unknown asset in the workspace", async () => { diff --git a/apps/web/src/app/(main)/video/page.tsx b/apps/web/src/app/(main)/video/page.tsx index 84edc8f..7aa3e72 100644 --- a/apps/web/src/app/(main)/video/page.tsx +++ b/apps/web/src/app/(main)/video/page.tsx @@ -119,7 +119,7 @@ export default function VideoPage(): React.ReactElement { const applyStatus = useCallback((status: VideoStatusDto): void => { const entry: VideoLibraryEntryDto = { videoAsset: status.videoAsset, - processingState: status.processingJob.state, + processingState: status.processingJob?.state ?? "queued", publications: [], }; setItems((current) => [entry, ...(current ?? []).filter((e) => e.videoAsset.id !== entry.videoAsset.id)]); diff --git a/apps/web/src/components/video/producer-panel.tsx b/apps/web/src/components/video/producer-panel.tsx index d53b8f4..036058a 100644 --- a/apps/web/src/components/video/producer-panel.tsx +++ b/apps/web/src/components/video/producer-panel.tsx @@ -247,9 +247,9 @@ export function ProducerPanel({ entry, live, canManage }: Props): React.ReactEle } }; - const producedInFlight = producedStatus !== null && IN_FLIGHT.includes(producedStatus.processingJob.state); - const producedComplete = producedStatus?.processingJob.state === "complete"; - const producedFailed = producedStatus?.processingJob.state === "failed"; + const producedInFlight = producedStatus !== null && IN_FLIGHT.includes(producedStatus.processingJob?.state ?? "queued"); + const producedComplete = producedStatus?.processingJob?.state === "complete"; + const producedFailed = producedStatus?.processingJob?.state === "failed"; return (
@@ -494,7 +494,7 @@ export function ProducerPanel({ entry, live, canManage }: Props): React.ReactEle )} {producedFailed && (

- The produced asset failed: {producedStatus?.processingJob.error ?? "see the job above."} + The produced asset failed: {producedStatus?.processingJob?.error ?? "see the job above."}

)} {producedInFlight && ( diff --git a/apps/web/src/components/video/upload-dropzone.tsx b/apps/web/src/components/video/upload-dropzone.tsx index 49c0865..ed79e3c 100644 --- a/apps/web/src/components/video/upload-dropzone.tsx +++ b/apps/web/src/components/video/upload-dropzone.tsx @@ -173,7 +173,7 @@ export function UploadDropzone({ live, canUpload, onStarted, onSettled, onUpload const status = await uploadOne(item, (progress) => patch(item.id, { progress })); // The bytes landed; a failed probe is a processing result, not an // upload failure — the library row carries it, so surface it inline. - const processingError = status.processingJob.state === "failed" ? status.processingJob.error ?? null : null; + const processingError = status.processingJob?.state === "failed" ? status.processingJob?.error ?? null : null; patch(item.id, { state: "done", progress: 100, diff --git a/apps/web/src/components/video/video-detail-drawer.tsx b/apps/web/src/components/video/video-detail-drawer.tsx index 833cf54..8a2b47b 100644 --- a/apps/web/src/components/video/video-detail-drawer.tsx +++ b/apps/web/src/components/video/video-detail-drawer.tsx @@ -49,7 +49,7 @@ export function VideoDetailDrawer({ }, [onClose]); // Poll the job while it is in flight; the library page refreshes from this too. - const inFlight = IN_FLIGHT.includes(status?.processingJob.state ?? entry.processingState); + const inFlight = IN_FLIGHT.includes(status?.processingJob?.state ?? entry.processingState); useEffect(() => { let cancelled = false; const fetchStatus = (): void => { @@ -129,7 +129,7 @@ export function VideoDetailDrawer({ > {step.label} - {current && job.progressPercent !== undefined && ( + {current && job?.progressPercent !== undefined && ( {job.progressPercent}% )} @@ -138,11 +138,11 @@ export function VideoDetailDrawer({ {state === "failed" && (
  • failed - {job.error !== undefined && {job.error}} + {job?.error !== undefined && {job.error}}
  • )} - {job.processingNote !== undefined &&

    {job.processingNote}

    } + {job?.processingNote !== undefined &&

    {job.processingNote}

    }
    )} diff --git a/apps/web/src/lib/video-types.ts b/apps/web/src/lib/video-types.ts index 53ae62f..0cf8db9 100644 --- a/apps/web/src/lib/video-types.ts +++ b/apps/web/src/lib/video-types.ts @@ -54,7 +54,7 @@ export interface VideoLibraryPageDto { export interface VideoStatusDto { videoAsset: VideoAssetDto; - processingJob: VideoProcessingJobDto; + processingJob: VideoProcessingJobDto | null; } /**