diff --git a/apps/server/public/openapi.json b/apps/server/public/openapi.json index ee99b5115..ced414f0e 100644 --- a/apps/server/public/openapi.json +++ b/apps/server/public/openapi.json @@ -806,17 +806,12 @@ "type": "null" } ] - }, - "downloadUrl": { - "type": "string", - "minLength": 1 } }, "required": [ "fileName", "contentType", - "size", - "downloadUrl" + "size" ] }, { @@ -1173,17 +1168,12 @@ "type": "null" } ] - }, - "downloadUrl": { - "type": "string", - "minLength": 1 } }, "required": [ "fileName", "contentType", - "size", - "downloadUrl" + "size" ] }, { @@ -7320,6 +7310,9 @@ "post": { "operationId": "jobs.list", "summary": "list", + "tags": [ + "Jobs" + ], "requestBody": { "required": true, "content": { @@ -7544,17 +7537,12 @@ "type": "null" } ] - }, - "downloadUrl": { - "type": "string", - "minLength": 1 } }, "required": [ "fileName", "contentType", - "size", - "downloadUrl" + "size" ] }, { @@ -7604,6 +7592,9 @@ "post": { "operationId": "jobs.get", "summary": "get", + "tags": [ + "Jobs" + ], "requestBody": { "required": true, "content": { @@ -7808,17 +7799,12 @@ "type": "null" } ] - }, - "downloadUrl": { - "type": "string", - "minLength": 1 } }, "required": [ "fileName", "contentType", - "size", - "downloadUrl" + "size" ] }, { @@ -7852,10 +7838,54 @@ } } }, + "/jobs/downloadArtifact": { + "post": { + "operationId": "jobs.downloadArtifact", + "summary": "downloadArtifact", + "tags": [ + "Jobs" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, "/jobs/retry": { "post": { "operationId": "jobs.retry", "summary": "retry", + "tags": [ + "Jobs" + ], "requestBody": { "required": true, "content": { @@ -8060,17 +8090,12 @@ "type": "null" } ] - }, - "downloadUrl": { - "type": "string", - "minLength": 1 } }, "required": [ "fileName", "contentType", - "size", - "downloadUrl" + "size" ] }, { @@ -8108,6 +8133,9 @@ "post": { "operationId": "jobs.cancel", "summary": "cancel", + "tags": [ + "Jobs" + ], "requestBody": { "required": true, "content": { @@ -8312,17 +8340,12 @@ "type": "null" } ] - }, - "downloadUrl": { - "type": "string", - "minLength": 1 } }, "required": [ "fileName", "contentType", - "size", - "downloadUrl" + "size" ] }, { @@ -8360,6 +8383,9 @@ "post": { "operationId": "jobs.events", "summary": "events", + "tags": [ + "Jobs" + ], "responses": { "200": { "description": "OK", diff --git a/apps/server/scripts/generate-swagger-spec.ts b/apps/server/scripts/generate-swagger-spec.ts index afbf190fe..92b98262f 100644 --- a/apps/server/scripts/generate-swagger-spec.ts +++ b/apps/server/scripts/generate-swagger-spec.ts @@ -55,6 +55,7 @@ const routerTagMap: Record = { downloads: "Downloads", directories: "Directories", ai: "AI", + jobs: "Jobs", utils: "Utilities", }; @@ -97,6 +98,17 @@ async function generateOpenAPISpec() { // デフォルトの概要(operationIdを綺麗にする) operation.summary = opId.split(".").pop() || opId; } + + if (opId === "jobs.downloadArtifact" && operation.responses?.[200]) { + operation.responses[200] = { + description: "OK", + content: { + "application/octet-stream": { + schema: { type: "string", format: "binary" }, + }, + }, + }; + } } } } diff --git a/apps/server/src/infrastructure/api-clients/shared/endpoints.ts b/apps/server/src/infrastructure/api-clients/shared/endpoints.ts deleted file mode 100644 index 45b088c35..000000000 --- a/apps/server/src/infrastructure/api-clients/shared/endpoints.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * API endpoint constants - * Centralized API endpoint definitions to avoid hardcoding - */ - -export const API_ENDPOINTS = { - // Sources - sources: "/api/sources", - sourceDetail: (sourceId: string) => `/api/sources/${sourceId}`, - sourceDump: (sourceId: string) => `/api/sources/${sourceId}/dump`, - sourceRestore: (sourceId: string) => `/api/sources/${sourceId}/restore`, - sourceImport: (sourceId: string) => `/api/sources/${sourceId}/import`, - - // Media - mediaList: (sourceId: string) => `/api/sources/${sourceId}`, - mediaDetails: (sourceId: string, mediaId: string) => - `/api/sources/${sourceId}/${mediaId}/details`, - mediaUpdate: (sourceId: string, mediaId: string) => - `/api/sources/${sourceId}/${mediaId}`, - mediaThumbnail: (sourceId: string, mediaId: string) => - `/api/sources/${sourceId}/thumbnail/${mediaId}`, - mediaUpload: (sourceId: string) => `/api/sources/${sourceId}/upload`, - mediaCopy: (sourceId: string, mediaId: string) => - `/api/sources/${sourceId}/${mediaId}/copy`, - mediaMove: (sourceId: string, mediaId: string) => - `/api/sources/${sourceId}/${mediaId}/move`, - - // Thumbnails - thumbnailGenerate: (sourceId: string) => - `/api/sources/${sourceId}/thumbnails/generate`, - thumbnailClear: (sourceId: string) => - `/api/sources/${sourceId}/thumbnails/clear`, - - // Tags - tags: "/api/tags", - - // Search - mediaSearch: (sourceId: string) => `/api/sources/${sourceId}/search`, - - // Utilities - fetchUrl: "/api/fetch-url", - downloads: "/api/downloads", - - // Projects - projects: "/api/projects", - mediaProjects: (sourceId: string, mediaId: string) => - `/api/sources/${sourceId}/${mediaId}/projects`, - - // IPs - ips: "/api/ips", - mediaIps: (sourceId: string, mediaId: string) => - `/api/sources/${sourceId}/${mediaId}/ips`, - - // Characters - characters: "/api/characters", - mediaCharacters: (sourceId: string, mediaId: string) => - `/api/sources/${sourceId}/${mediaId}/characters`, - - // AI - aiTag: "/api/ai/tag", -} as const; diff --git a/apps/server/src/infrastructure/api-clients/sources-api.ts b/apps/server/src/infrastructure/api-clients/sources-api.ts index 97510d728..4ea0aac72 100644 --- a/apps/server/src/infrastructure/api-clients/sources-api.ts +++ b/apps/server/src/infrastructure/api-clients/sources-api.ts @@ -2,9 +2,10 @@ * Media Sources API Client * Handles all API calls related to media sources * - * NOTE: Most operations use oRPC, but dump/import still use dedicated HTTP routes. + * Source operations use oRPC, including binary export artifacts. */ +import { downloadCompletedJobArtifact } from "@solid-imager/client"; import type { mediaSourceInfoSchema } from "@solid-imager/core/domain/sources/schemas"; import type { z } from "zod"; import { orpc } from "~/infrastructure/api-clients/orpc-client"; @@ -96,17 +97,12 @@ export async function fetchSourceDump( opts?: { includeImages?: boolean }, ): Promise { const includeImages = opts?.includeImages ?? false; - const url = `/api/sources/${id}/dump?mode=${mode}&includeImages=${includeImages}`; - const response = await fetch(url, { - method: "GET", + const job = await orpc.sources.enqueueExport({ + id, + mode, + includeImages, }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to download dump: ${response.status} ${errorText}`); - } - - return response.blob(); + return downloadCompletedJobArtifact(orpc.jobs, job.id); } export function restoreSource(id: string, data: unknown) { @@ -123,21 +119,7 @@ export function restoreSource(id: string, data: unknown) { * @returns Import result */ export async function importSourceZip(id: string, file: File) { - const url = `/api/sources/${id}/import`; - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/zip", - }, - body: file, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to import ZIP: ${response.status} ${errorText}`); - } - - return await response.json(); + return orpc.sources.importZip({ id, file }); } export async function importSourceNdjson(id: string, file: File) { @@ -148,18 +130,5 @@ export async function importSourceNdjson(id: string, file: File) { } export async function importSourceLanceDB(id: string, file: File) { - const url = `/api/sources/${id}/import-lancedb`; - const response = await fetch(url, { - method: "POST", - body: file, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Failed to import LanceDB: ${response.status} ${errorText}`, - ); - } - - return await response.json(); + return orpc.sources.importLanceDB({ id, file }); } diff --git a/apps/server/src/infrastructure/api/routers/jobs-router.ts b/apps/server/src/infrastructure/api/routers/jobs-router.ts index b120d4822..53c785c9a 100644 --- a/apps/server/src/infrastructure/api/routers/jobs-router.ts +++ b/apps/server/src/infrastructure/api/routers/jobs-router.ts @@ -1,3 +1,5 @@ +import { createReadStream } from "node:fs"; +import fs from "node:fs/promises"; import { eventIterator, ORPCError, os } from "@orpc/server"; import { isBatchParentJobType, @@ -14,10 +16,12 @@ import { } from "@solid-imager/core/domain/sources/events"; import { and, count, desc, eq } from "drizzle-orm"; import { z } from "zod"; +import { isJobTransferPath } from "~/application/services/job-transfer-storage"; import { db } from "~/infrastructure/db"; import { jobs } from "~/infrastructure/db/schema"; import { RealtimeEventBus } from "~/infrastructure/events/realtime-event-bus"; import { JobRepository } from "~/infrastructure/repositories/job-repository"; +import { nodeStreamToWebReadable } from "~/infrastructure/utils/stream-utils"; const PublicJobFailureMessage = "Job failed"; @@ -81,7 +85,6 @@ export function toJobDto(job: Job) { fileName: job.artifactFileName, contentType: job.artifactContentType, size: job.artifactSize ?? null, - downloadUrl: `/api/jobs/${job.id}/artifact`, } : null, }; @@ -125,6 +128,54 @@ export const jobsRouter = { return toJobDto(job); }), + downloadArtifact: os + .meta({ + openapi: { + tags: ["Jobs"], + summary: "Download job artifact", + description: "Stream a completed job artifact", + }, + }) + .input(jobIdRequestSchema) + .output(z.instanceof(ReadableStream)) + .handler(async ({ input }) => { + const job = await JobRepository.findById(input.id); + if ( + job?.status !== "completed" || + !job.artifactPath || + !job.artifactFileName || + !job.artifactContentType || + !isJobTransferPath(job.id, job.artifactPath) + ) { + throw new ORPCError("NOT_FOUND", { + message: "Artifact not found", + }); + } + + if (job.artifactExpiresAt && job.artifactExpiresAt <= new Date()) { + throw new ORPCError("NOT_FOUND", { + message: "Artifact not found", + }); + } + + let stat: Awaited>; + try { + stat = await fs.stat(job.artifactPath); + } catch { + throw new ORPCError("NOT_FOUND", { + message: "Artifact not found", + }); + } + + if (!stat.isFile()) { + throw new ORPCError("NOT_FOUND", { + message: "Artifact not found", + }); + } + + return nodeStreamToWebReadable(createReadStream(job.artifactPath)); + }), + retry: os .input(jobIdRequestSchema) .output(jobDtoSchema) diff --git a/apps/server/src/routes/api/jobs.$jobId.artifact.ts b/apps/server/src/routes/api/jobs.$jobId.artifact.ts deleted file mode 100644 index 18567726f..000000000 --- a/apps/server/src/routes/api/jobs.$jobId.artifact.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { createReadStream } from "node:fs"; -import fs from "node:fs/promises"; -import { createFileRoute } from "@tanstack/solid-router"; -import { services } from "~/application/registry"; -import { isJobTransferPath } from "~/application/services/job-transfer-storage"; -import type { ServerRouteContext } from "~/infrastructure/router/route-types"; -import { bootstrapServerRoute } from "~/infrastructure/server-route-bootstrap"; -import { nodeStreamToWebReadable } from "~/infrastructure/utils/stream-utils"; - -export const Route = createFileRoute("/api/jobs/$jobId/artifact")({ - server: { - handlers: { - GET: async ({ params }: ServerRouteContext<{ jobId: string }>) => { - bootstrapServerRoute(); - const job = await services.getJobRepository().findById(params.jobId); - if ( - job?.status !== "completed" || - !job.artifactPath || - !job.artifactFileName || - !job.artifactContentType || - !isJobTransferPath(job.id, job.artifactPath) - ) { - return new Response("Artifact not found", { status: 404 }); - } - - if (job.artifactExpiresAt && job.artifactExpiresAt <= new Date()) { - return new Response("Artifact expired", { status: 410 }); - } - - let stat: Awaited>; - try { - stat = await fs.stat(job.artifactPath); - } catch { - return new Response("Artifact not found", { status: 404 }); - } - - return new Response( - nodeStreamToWebReadable(createReadStream(job.artifactPath)), - { - headers: { - "Cache-Control": "private, max-age=3600", - "Content-Length": String(stat.size), - "Content-Type": job.artifactContentType, - "Content-Disposition": `attachment; filename="${encodeURIComponent(job.artifactFileName)}"`, - }, - }, - ); - }, - }, - }, -}); diff --git a/apps/server/src/routes/api/sources.$mediaSourceId.dump.ts b/apps/server/src/routes/api/sources.$mediaSourceId.dump.ts deleted file mode 100644 index a21a6d1c3..000000000 --- a/apps/server/src/routes/api/sources.$mediaSourceId.dump.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createFileRoute } from "@tanstack/solid-router"; -import { BackupService } from "~/application/services/backup-service"; -import { initServices } from "~/infrastructure/bootstrap"; -import type { ServerRouteContext } from "~/infrastructure/router/route-types"; -import { asDumpStream } from "~/infrastructure/utils/stream-utils"; - -export const Route = createFileRoute("/api/sources/$mediaSourceId/dump")({ - server: { - handlers: { - GET: async ({ - params, - request, - }: ServerRouteContext<{ mediaSourceId: string }>) => { - initServices(); - - const { mediaSourceId } = params; - const { searchParams } = new URL(request.url); - const rawMode = searchParams.get("mode"); - const mode = - rawMode === "zip" || rawMode === "lancedb" ? rawMode : "json"; - const includeImages = searchParams.get("includeImages") === "true"; - const result = await BackupService.createDump(mediaSourceId, mode, { - includeImages, - }); - - if (mode === "zip") { - return new Response(asDumpStream(result), { - headers: { - "Content-Type": "application/x-tar", - "Content-Disposition": `attachment; filename="source-${mediaSourceId}-dump.tar"`, - }, - }); - } - - if (mode === "lancedb") { - return new Response(asDumpStream(result), { - headers: { - "Content-Type": "application/x-tar", - "Content-Disposition": `attachment; filename="source-${mediaSourceId}-dump-lancedb.tar"`, - }, - }); - } - - return new Response(asDumpStream(result), { - headers: { - "Content-Type": "application/x-ndjson", - "Content-Disposition": `attachment; filename="source-${mediaSourceId}-dump.ndjson"`, - }, - }); - }, - }, - }, -}); diff --git a/apps/server/src/routes/api/sources.$mediaSourceId.import-lancedb.ts b/apps/server/src/routes/api/sources.$mediaSourceId.import-lancedb.ts deleted file mode 100644 index fcb53627b..000000000 --- a/apps/server/src/routes/api/sources.$mediaSourceId.import-lancedb.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { createFileRoute } from "@tanstack/solid-router"; -import { BackupService } from "~/application/services/backup-service"; -import { initServices } from "~/infrastructure/bootstrap"; -import type { ServerRouteContext } from "~/infrastructure/router/route-types"; -import { webReadableToNodeStream } from "~/infrastructure/utils/stream-utils"; - -export const Route = createFileRoute( - "/api/sources/$mediaSourceId/import-lancedb", -)({ - server: { - handlers: { - POST: async ({ - params, - request, - }: ServerRouteContext<{ mediaSourceId: string }>) => { - initServices(); - - const { randomUUID } = await import("node:crypto"); - const fs = await import("node:fs"); - const path = await import("node:path"); - const { pipeline } = await import("node:stream/promises"); - - const tempDir = path.join(process.cwd(), ".cache", "lancedb-restore"); - await fs.promises.mkdir(tempDir, { recursive: true }); - const tempFilePath = path.join( - tempDir, - `import-lancedb-route-${randomUUID()}.tar`, - ); - - try { - if (!request.body) { - return new Response("Missing request body", { status: 400 }); - } - - await pipeline( - webReadableToNodeStream(request.body), - fs.createWriteStream(tempFilePath), - ); - - return Response.json( - await BackupService.importLanceDB( - params.mediaSourceId, - tempFilePath, - ), - ); - } finally { - try { - await fs.promises.unlink(tempFilePath); - } catch { - // ignore temp file cleanup failures - } - } - }, - }, - }, -}); diff --git a/apps/server/src/routes/api/sources.$mediaSourceId.import.ts b/apps/server/src/routes/api/sources.$mediaSourceId.import.ts deleted file mode 100644 index 13b78b9c6..000000000 --- a/apps/server/src/routes/api/sources.$mediaSourceId.import.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { createFileRoute } from "@tanstack/solid-router"; -import { BackupService } from "~/application/services/backup-service"; -import { initServices } from "~/infrastructure/bootstrap"; -import type { ServerRouteContext } from "~/infrastructure/router/route-types"; -import { webReadableToNodeStream } from "~/infrastructure/utils/stream-utils"; - -export const Route = createFileRoute("/api/sources/$mediaSourceId/import")({ - server: { - handlers: { - POST: async ({ - params, - request, - }: ServerRouteContext<{ mediaSourceId: string }>) => { - initServices(); - - const { randomUUID } = await import("node:crypto"); - const fs = await import("node:fs"); - const path = await import("node:path"); - const { pipeline } = await import("node:stream/promises"); - - const tempDir = path.join(process.cwd(), ".cache", "import"); - await fs.promises.mkdir(tempDir, { recursive: true }); - const tempFilePath = path.join( - tempDir, - `import-route-${randomUUID()}.tar`, - ); - - try { - if (!request.body) { - return new Response("Missing request body", { status: 400 }); - } - - await pipeline( - webReadableToNodeStream(request.body), - fs.createWriteStream(tempFilePath), - ); - - return Response.json( - await BackupService.importSourceTar( - params.mediaSourceId, - tempFilePath, - ), - ); - } finally { - try { - await fs.promises.unlink(tempFilePath); - } catch { - // ignore temp file cleanup failures - } - } - }, - }, - }, -}); diff --git a/apps/server/src/routes/v2/jobs.tsx b/apps/server/src/routes/v2/jobs.tsx index 432790ee6..eae613495 100644 --- a/apps/server/src/routes/v2/jobs.tsx +++ b/apps/server/src/routes/v2/jobs.tsx @@ -62,6 +62,30 @@ function V2JobsRoute() { throw error; } }} + onDownload={async (job) => { + if (!job.artifact) return; + try { + const stream = await orpc.jobs.downloadArtifact({ id: job.id }); + const blob = await new Response(stream, { + headers: { "content-type": job.artifact.contentType }, + }).blob(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = job.artifact.fileName; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 0); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to download artifact", + ); + throw error; + } + }} state={() => toQueryUiState(jobsQuery)} /> ); diff --git a/apps/server/src/tests/unit/infrastructure/api-clients/sources-api-ext.test.ts b/apps/server/src/tests/unit/infrastructure/api-clients/sources-api-ext.test.ts index 0cde2aebb..d524c7de9 100644 --- a/apps/server/src/tests/unit/infrastructure/api-clients/sources-api-ext.test.ts +++ b/apps/server/src/tests/unit/infrastructure/api-clients/sources-api-ext.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { fetchSourceDump, importSourceZip, @@ -9,9 +9,11 @@ import { vi.mock("~/infrastructure/api-clients/orpc-client", () => ({ orpc: { sources: { - dump: vi.fn(), + enqueueExport: vi.fn(), + importZip: vi.fn(), restore: vi.fn(), }, + jobs: { downloadArtifact: vi.fn(), get: vi.fn() }, }, getBaseUrl: vi.fn(() => "/api/rpc"), })); @@ -19,80 +21,86 @@ vi.mock("~/infrastructure/api-clients/orpc-client", () => ({ import { orpc } from "~/infrastructure/api-clients/orpc-client"; describe("Sources API Client Extensions", () => { - const originalFetch = global.fetch; - - beforeEach(() => { - global.fetch = vi.fn() as any; - }); - afterEach(() => { - global.fetch = originalFetch; vi.clearAllMocks(); }); - it("should call direct endpoint for json mode", async () => { + it("should enqueue and download a completed json export", async () => { const id = "test-source-id"; - const mockDumpData = { defined: true }; - const mockBlob = new Blob([JSON.stringify(mockDumpData)], { + const mockBlob = new Blob(["dump"], { type: "application/json", }); - - (global.fetch as any).mockResolvedValue({ - ok: true, - blob: () => Promise.resolve(mockBlob), + ((orpc.sources as any).enqueueExport as any).mockResolvedValue({ + id: "export-job-id", + }); + ((orpc.jobs as any).get as any).mockResolvedValue({ + status: "completed", + artifact: { + fileName: "dump.ndjson", + contentType: "application/x-ndjson", + }, }); + ((orpc.jobs as any).downloadArtifact as any).mockResolvedValue( + new Blob([mockBlob]).stream(), + ); const result = await fetchSourceDump(id, "json"); - expect(global.fetch).toHaveBeenCalledWith( - `/api/sources/${id}/dump?mode=json&includeImages=false`, + expect((orpc.sources as any).enqueueExport).toHaveBeenCalledWith({ + id, + mode: "json", + includeImages: false, + }); + expect((orpc.jobs as any).get).toHaveBeenCalledWith( + { id: "export-job-id" }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect((orpc.jobs as any).downloadArtifact).toHaveBeenCalledWith( { - method: "GET", + id: "export-job-id", }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), ); - expect(result).toBe(mockBlob); + expect(await result.text()).toBe("dump"); }); - it("should call direct endpoint for zip mode", async () => { + it("should pass the selected mode to export jobs", async () => { const id = "test-source-id"; const mockBlob = new Blob(["zip content"], { type: "application/zip" }); - - (global.fetch as any).mockResolvedValue({ - ok: true, - blob: () => Promise.resolve(mockBlob), + ((orpc.sources as any).enqueueExport as any).mockResolvedValue({ + id: "export-job-id", }); + ((orpc.jobs as any).get as any).mockResolvedValue({ + status: "completed", + artifact: { fileName: "dump.tar", contentType: "application/x-tar" }, + }); + ((orpc.jobs as any).downloadArtifact as any).mockResolvedValue( + new Blob([mockBlob]).stream(), + ); const result = await fetchSourceDump(id, "zip"); - expect(global.fetch).toHaveBeenCalledWith( - `/api/sources/${id}/dump?mode=zip&includeImages=false`, - { - method: "GET", - }, - ); - expect(result).toBe(mockBlob); + expect((orpc.sources as any).enqueueExport).toHaveBeenCalledWith({ + id, + mode: "zip", + includeImages: false, + }); + expect(await result.text()).toBe("zip content"); }); - it("should call import endpoint with binary body", async () => { + it("should upload imports through oRPC", async () => { const id = "test-source-id"; const mockFile = new File(["zip content"], "test.zip", { type: "application/zip", }); - const mockResponse = { success: true }; - - (global.fetch as any).mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); + const mockResponse = { importedCount: 1 }; + ((orpc.sources as any).importZip as any).mockResolvedValue(mockResponse); const result = await importSourceZip(id, mockFile); - expect(global.fetch).toHaveBeenCalledWith(`/api/sources/${id}/import`, { - method: "POST", - headers: { - "Content-Type": "application/zip", - }, - body: mockFile, + expect((orpc.sources as any).importZip).toHaveBeenCalledWith({ + id, + file: mockFile, }); expect(result).toEqual(mockResponse); }); diff --git a/apps/tauri/src/infrastructure/api-clients/sources-api.ts b/apps/tauri/src/infrastructure/api-clients/sources-api.ts index d36e58e51..c5e0bda27 100644 --- a/apps/tauri/src/infrastructure/api-clients/sources-api.ts +++ b/apps/tauri/src/infrastructure/api-clients/sources-api.ts @@ -7,28 +7,17 @@ export { updateMediaSource, } from "~/api/sources-api"; -import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; +import { downloadCompletedJobArtifact } from "@solid-imager/client"; import { client } from "~/orpc-client"; -const isDev = import.meta.env.DEV; -const API_BASE = isDev - ? window.location.origin - : import.meta.env.VITE_API_URL || "http://192.168.1.150:3000"; - -const apiFetch = isDev ? fetch : tauriFetch; - export async function fetchSourceDump( id: string, mode: "json" | "zip" | "lancedb" = "json", opts?: { includeImages?: boolean }, ): Promise { const includeImages = opts?.includeImages ?? false; - const url = `${API_BASE}/api/sources/${id}/dump?mode=${mode}&includeImages=${includeImages}`; - const response = await apiFetch(url, { method: "GET" }); - if (!response.ok) { - throw new Error(`Failed to download dump: ${response.status}`); - } - return response.blob(); + const job = await client.sources.enqueueExport({ id, mode, includeImages }); + return downloadCompletedJobArtifact(client.jobs, job.id); } export async function restoreSource( @@ -46,15 +35,7 @@ export async function restoreSource( } export async function importSourceZip(id: string, file: File) { - const url = `${API_BASE}/api/sources/${id}/import`; - const response = await apiFetch(url, { - method: "POST", - body: file, - }); - if (!response.ok) { - throw new Error(`Failed to import ZIP: ${response.status}`); - } - return await response.json(); + return client.sources.importZip({ id, file }); } export async function importSourceNdjson(id: string, file: File) { @@ -65,15 +46,7 @@ export async function importSourceNdjson(id: string, file: File) { } export async function importSourceLanceDB(id: string, file: File) { - const url = `${API_BASE}/api/sources/${id}/import-lancedb`; - const response = await apiFetch(url, { - method: "POST", - body: file, - }); - if (!response.ok) { - throw new Error(`Failed to import LanceDB: ${response.status}`); - } - return await response.json(); + return client.sources.importLanceDB({ id, file }); } export function parseRestoreFile(file: File): Promise { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 930997142..6d7c81fb9 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -2,3 +2,8 @@ export type { ContractRouterClient } from "@orpc/contract"; export { APIError, isTransientApiError } from "./api-error"; export type { ClientOptions } from "./create-client"; export { createClient } from "./create-client"; +export { + downloadCompletedJobArtifact, + type ExportJob, + type JobArtifactClient, +} from "./job-artifact"; diff --git a/packages/client/src/job-artifact.ts b/packages/client/src/job-artifact.ts new file mode 100644 index 000000000..2278bb45c --- /dev/null +++ b/packages/client/src/job-artifact.ts @@ -0,0 +1,66 @@ +export type ExportJob = { + id: string; + status: "pending" | "in_progress" | "completed" | "failed" | "cancelled"; + error: string | null; + artifact: { contentType: string } | null; +}; + +export type JobArtifactClient = { + get: ( + input: { id: string }, + options?: { signal?: AbortSignal }, + ) => Promise; + downloadArtifact: ( + input: { id: string }, + options?: { signal?: AbortSignal }, + ) => Promise>; +}; + +const DEFAULT_EXPORT_TIMEOUT_MS = 5 * 60 * 1000; + +export async function downloadCompletedJobArtifact( + jobs: JobArtifactClient, + jobId: string, + options: { signal?: AbortSignal; timeoutMs?: number } = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_EXPORT_TIMEOUT_MS; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const signal = options.signal + ? AbortSignal.any([options.signal, controller.signal]) + : controller.signal; + + try { + for (;;) { + if (signal.aborted) { + throw new Error("Export download was cancelled or timed out"); + } + const job = await jobs.get({ id: jobId }, { signal }); + if (job.status === "completed") { + if (!job.artifact) { + throw new Error("Export completed without an artifact"); + } + const stream = await jobs.downloadArtifact({ id: jobId }, { signal }); + return new Response(stream as ReadableStream, { + headers: { "content-type": job.artifact.contentType }, + }).blob(); + } + if (job.status === "failed" || job.status === "cancelled") { + throw new Error(job.error ?? `Export job ${job.status}`); + } + await new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(pollDelay); + reject(new Error("Export download was cancelled or timed out")); + }; + const pollDelay = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, 500); + signal.addEventListener("abort", onAbort, { once: true }); + }); + } + } finally { + clearTimeout(timeout); + } +} diff --git a/packages/core/src/domain/contract/jobs.contract.ts b/packages/core/src/domain/contract/jobs.contract.ts index 1ff0ca9bc..f68ead20c 100644 --- a/packages/core/src/domain/contract/jobs.contract.ts +++ b/packages/core/src/domain/contract/jobs.contract.ts @@ -1,4 +1,5 @@ import { eventIterator, oc } from "@orpc/contract"; +import { z } from "zod"; import { jobDtoSchema, jobIdRequestSchema, @@ -10,6 +11,9 @@ import { jobEventSchema } from "../sources/events"; export const jobsContract = { list: oc.input(jobListRequestSchema).output(jobListResponseSchema), get: oc.input(jobIdRequestSchema).output(jobDtoSchema), + downloadArtifact: oc + .input(jobIdRequestSchema) + .output(z.file().mime("application/octet-stream")), retry: oc.input(jobIdRequestSchema).output(jobDtoSchema), cancel: oc.input(jobIdRequestSchema).output(jobDtoSchema), events: oc.output(eventIterator(jobEventSchema)), diff --git a/packages/core/src/domain/jobs/schemas.ts b/packages/core/src/domain/jobs/schemas.ts index e3fd94aa1..ce98baa7a 100644 --- a/packages/core/src/domain/jobs/schemas.ts +++ b/packages/core/src/domain/jobs/schemas.ts @@ -52,7 +52,6 @@ export const jobDtoSchema = z.object({ fileName: z.string(), contentType: z.string(), size: z.number().int().nonnegative().nullable(), - downloadUrl: z.string().min(1), }) .nullable(), }); diff --git a/packages/ui/src/screens/v2-jobs-screen.tsx b/packages/ui/src/screens/v2-jobs-screen.tsx index dd02a133e..23ed4c3cd 100644 --- a/packages/ui/src/screens/v2-jobs-screen.tsx +++ b/packages/ui/src/screens/v2-jobs-screen.tsx @@ -61,6 +61,7 @@ export type V2JobsScreenProps = { onRefresh: () => void | Promise; onRetry: (jobId: string) => void | Promise; onCancel: (jobId: string) => void | Promise; + onDownload: (job: JobDto) => void | Promise; state: Accessor>; }; @@ -220,10 +221,12 @@ function JobsInspector(props: { class?: string; job: JobDto | undefined; onCancel: (jobId: string) => void | Promise; + onDownload: (job: JobDto) => void | Promise; onRetry: (jobId: string) => void | Promise; }) { const [isRetrying, setIsRetrying] = createSignal(false); const [isCancelling, setIsCancelling] = createSignal(false); + const [isDownloading, setIsDownloading] = createSignal(false); const retry = async () => { const job = props.job; @@ -237,6 +240,18 @@ function JobsInspector(props: { setIsRetrying(false); } }; + const download = async () => { + const job = props.job; + if (!job?.artifact || isDownloading()) return; + setIsDownloading(true); + try { + await props.onDownload(job); + } catch { + // The route reports download failures; keep the selected job visible. + } finally { + setIsDownloading(false); + } + }; const cancel = async () => { const job = props.job; if ( @@ -380,16 +395,20 @@ function JobsInspector(props: { {(artifact) => ( - void download()} + type="button" > + )} @@ -584,6 +603,7 @@ export function V2JobsScreen(props: V2JobsScreenProps) { class="mt-4 rounded-md border border-[var(--v2-border)] bg-[var(--v2-surface-subtle)] p-4 xl:hidden" job={job()} onCancel={props.onCancel} + onDownload={props.onDownload} onRetry={props.onRetry} /> )} @@ -602,6 +622,7 @@ export function V2JobsScreen(props: V2JobsScreenProps) {