From 37a168cf2f75ba5efe861c0c0bd11c9f1f8b1787 Mon Sep 17 00:00:00 2001 From: Varun Date: Fri, 3 Apr 2026 21:10:44 +0530 Subject: [PATCH] chore: gitignore docs/plans --- .gitignore | 3 + ...26-04-03-import-destination-inline-plan.md | 252 ------ .../2026-04-03-import-destination-inline.md | 40 - docs/plans/2026-04-03-import-queue.md | 784 ------------------ 4 files changed, 3 insertions(+), 1076 deletions(-) delete mode 100644 docs/plans/2026-04-03-import-destination-inline-plan.md delete mode 100644 docs/plans/2026-04-03-import-destination-inline.md delete mode 100644 docs/plans/2026-04-03-import-queue.md diff --git a/.gitignore b/.gitignore index 315d974..fcdd3c9 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,7 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts +# plans +/docs/plans + .env \ No newline at end of file diff --git a/docs/plans/2026-04-03-import-destination-inline-plan.md b/docs/plans/2026-04-03-import-destination-inline-plan.md deleted file mode 100644 index d342acb..0000000 --- a/docs/plans/2026-04-03-import-destination-inline-plan.md +++ /dev/null @@ -1,252 +0,0 @@ -# Import Destination Inline Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Move album destination selection from a dialog into an inline card on the import-shared screen, between the album details card and the gallery card. - -**Architecture:** All changes are in a single file (`src/pages/assets/import-shared.tsx`). Remove `importAllDialogOpen` state and its Dialog component. Add a Destination card rendered inline when `sharedData && albumDetails`. The import button in the header calls `handleImportAll` directly. No backend changes needed. - -**Tech Stack:** React, Next.js, shadcn/ui (Card, Button, Input, Select, Label) - ---- - -### Task 1: Remove dialog state, update fetch trigger, wire import button directly - -**Files:** -- Modify: `src/pages/assets/import-shared.tsx` - -All changes are in one file. Read it fully before starting. - -**Step 1: Remove `importAllDialogOpen` state** - -Delete this line (~line 162): -```typescript -const [importAllDialogOpen, setImportAllDialogOpen] = useState(false); -``` - -**Step 2: Update the albums fetch effect** - -The current effect fetches albums when the dialog opens. Change it to fetch when `sharedData` loads instead: - -```typescript -// BEFORE -useEffect(() => { - if (importAllDialogOpen) { - listAlbums().then(setExistingAlbums).catch(console.error); - } -}, [importAllDialogOpen]); - -// AFTER -useEffect(() => { - if (sharedData) { - listAlbums().then(setExistingAlbums).catch(console.error); - } -}, [sharedData]); -``` - -**Step 3: Update the Back button handler** - -Remove `setImportAllDialogOpen(false)` from the Back button's onClick (it's one of the reset lines in the Header `leftComponent`). - -**Step 4: Update the import button in the Header rightComponent** - -Replace the current onClick (which opens the dialog) with a direct `handleImportAll` call: - -```tsx - -``` - -Also remove the `setAlbumImportMode("album")` reset that was in the old onClick — the inline card owns the mode state now. - -**Step 5: Commit** - -```bash -git add src/pages/assets/import-shared.tsx -git commit -m "refactor: wire import button directly to handleImportAll, fetch albums on sharedData load" -``` - ---- - -### Task 2: Add inline Destination card - -**Files:** -- Modify: `src/pages/assets/import-shared.tsx` - -**Step 1: Find the insertion point** - -In the JSX return, inside the `
` element, there is: -- An album details `` rendered when `sharedData` is set -- A gallery `` rendered when `sharedData && albumDetails` - -Insert the Destination card between them — after the album details card, before the gallery card. It should only render when `sharedData && albumDetails` (same condition as the gallery). - -**Step 2: Add the Destination card** - -```tsx -{sharedData && albumDetails && ( - - - Destination - - Choose where to save the imported assets in your Immich library. - - - -
- - - -
- - {albumImportMode === "album" && ( -
- - setAlbumNameInput(e.target.value)} - placeholder="Imported shared album" - disabled={importAllLoading} - /> -
- )} - - {albumImportMode === "existing-album" && ( -
- - -
- )} - - {importAllLoading && jobProgress && ( -

- {jobProgress.uploaded + jobProgress.skipped} / {jobProgress.total} processed - {jobProgress.failed > 0 && ` · ${jobProgress.failed} failed`} -

- )} -
-
-)} -``` - -**Step 3: Commit** - -```bash -git add src/pages/assets/import-shared.tsx -git commit -m "feat: add inline destination card for album selection on import-shared screen" -``` - ---- - -### Task 3: Remove the import Dialog - -**Files:** -- Modify: `src/pages/assets/import-shared.tsx` - -The import Dialog (open/close controlled by `importAllDialogOpen`) now needs to be removed. The video and image preview Dialogs must be kept — do not touch those. - -**Step 1: Remove the import Dialog JSX** - -Delete the entire Dialog block that starts with: -```tsx - - - ... - - -``` - -It ends just before the video preview Dialog (``). - -**Step 2: Remove unused Dialog imports if any** - -Check the import statement at the top. The `Dialog`, `DialogContent`, `DialogHeader`, `DialogTitle`, `DialogDescription` components are still used by the video and image preview dialogs — keep them. No imports need removing. - -**Step 3: Verify the progress counter is gone from the dialog (it was moved to the Destination card in Task 2)** - -Confirm no duplicate progress `

` exists. - -**Step 4: Commit** - -```bash -git add src/pages/assets/import-shared.tsx -git commit -m "refactor: remove import dialog, destination selection is now inline" -``` - ---- - -## Done - -After Task 3: -- Album destination (create / existing / none) is visible on the screen without clicking anything -- The import button calls `handleImportAll` directly -- Progress shows inline in the Destination card while importing -- The import Dialog is gone -- No backend changes diff --git a/docs/plans/2026-04-03-import-destination-inline.md b/docs/plans/2026-04-03-import-destination-inline.md deleted file mode 100644 index 861a2a3..0000000 --- a/docs/plans/2026-04-03-import-destination-inline.md +++ /dev/null @@ -1,40 +0,0 @@ -# Import Destination Inline Design - -**Date:** 2026-04-03 - -## Goal - -Show album destination selection inline on the import-shared screen rather than inside a dialog, so users can set their target album before browsing and importing. - -## Design - -A "Destination" card is inserted between the album details card and the gallery card. It is only rendered when `sharedData` is loaded. The import dialog is removed entirely. - -### Layout - -``` -[Album Details Card] -[Destination Card] ← new - Destination: [Create new album] [Existing Album] [No Album] - Album name: [____________] ← shown when "Create new album" selected - Select album: [dropdown ] ← shown when "Existing Album" selected - Progress: X / Y processed · Z failed ← shown while importing -[Gallery Card] -``` - -### Behaviour changes - -- The "Import all" / "Import selected (N)" header button calls `handleImportAll` directly without opening a dialog -- `importAllDialogOpen` state and the Dialog component are removed -- Album options are read from existing state: `albumImportMode`, `albumNameInput`, `selectedAlbumId` -- `existingAlbums` is fetched when `sharedData` loads (moved from `importAllDialogOpen` effect) -- Progress counter moves into the Destination card while importing -- Upload banner stays in the gallery card - -### No backend changes - -The `albumOptions` shape passed to `createImportJob` is unchanged. - -## Files changed - -- `src/pages/assets/import-shared.tsx` — only file touched diff --git a/docs/plans/2026-04-03-import-queue.md b/docs/plans/2026-04-03-import-queue.md deleted file mode 100644 index 4e7dfef..0000000 --- a/docs/plans/2026-04-03-import-queue.md +++ /dev/null @@ -1,784 +0,0 @@ -# Import Queue Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Replace the synchronous fire-and-wait import with a SQLite-backed job queue so imports survive page closes and show live progress via polling. - -**Architecture:** A job is created in SQLite on request, a background worker processes items concurrently (4 at a time) and writes per-item status back to the db. The frontend polls a status endpoint every 2s. Platform-specific logic is encapsulated in processor classes so Nextcloud/PhotoPrism can be added by dropping in a new file. On server restart, any stuck `processing` jobs are reset to `pending` and restarted when the user's next poll hits the status endpoint. - -**Tech Stack:** Drizzle ORM + libsql (SQLite), Next.js API routes, TypeScript, React (polling via `useEffect` + `setInterval`) - ---- - -### Task 1: Add schema tables - -**Files:** -- Create: `src/db/schema/importJobs.schema.ts` -- Modify: `src/db/schema/index.ts` - -**Step 1: Create the schema file** - -```typescript -// src/db/schema/importJobs.schema.ts -import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; -import { randomUUID } from "crypto"; - -export const importJobs = sqliteTable("import_jobs", { - id: text("id").primaryKey().$defaultFn(() => randomUUID()), - userId: text("user_id").notNull(), - platform: text("platform").notNull(), // 'immich' | 'nextcloud' | 'photoprism' - status: text("status").notNull().default("pending"), // pending | processing | completed | failed - url: text("url").notNull(), // base URL of source - urlConfig: text("url_config").notNull().default("{}"), // JSON: auth/connection (key, token, etc.) - importData: text("import_data").notNull().default("{}"), // JSON: album options, resolved albumId, etc. - totalCount: integer("total_count").notNull().default(0), - uploadedCount: integer("uploaded_count").notNull().default(0), - skippedCount: integer("skipped_count").notNull().default(0), - failedCount: integer("failed_count").notNull().default(0), - createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()), - updatedAt: integer("updated_at", { mode: "timestamp" }) - .$defaultFn(() => new Date()) - .$onUpdate(() => new Date()), -}); - -export const importJobItems = sqliteTable("import_job_items", { - id: text("id").primaryKey().$defaultFn(() => randomUUID()), - jobId: text("job_id").notNull().references(() => importJobs.id), - assetId: text("asset_id").notNull(), // source platform's asset ID - status: text("status").notNull().default("pending"), // pending | processing | uploaded | skipped | failed - itemData: text("item_data").notNull().default("{}"), // JSON: filename, type, dates, isFavorite, etc. - immichId: text("immich_id"), // Immich asset ID after successful upload - error: text("error"), - createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()), - updatedAt: integer("updated_at", { mode: "timestamp" }) - .$defaultFn(() => new Date()) - .$onUpdate(() => new Date()), -}); -``` - -**Step 2: Export from schema index** - -In `src/db/schema/index.ts`, add: -```typescript -export * from "./importJobs.schema"; -``` - -**Step 3: Commit** - -```bash -git add src/db/schema/importJobs.schema.ts src/db/schema/index.ts -git commit -m "feat: add import_jobs and import_job_items schema tables" -``` - ---- - -### Task 2: Generate and apply migration - -**Files:** -- Create: `src/db/migrations/` (auto-generated by drizzle-kit) - -**Step 1: Generate migration** - -```bash -bun run db:generate -``` - -Expected: a new `.sql` file appears in `src/db/migrations/` containing `CREATE TABLE import_jobs` and `CREATE TABLE import_job_items`. - -**Step 2: Verify migration SQL looks correct** - -Open the generated file and confirm it has both tables with all columns. No manual edits needed. - -**Step 3: Test that migrations run** - -```bash -bun run dev -``` - -Expected: server starts without errors. The migration auto-runs via `instrumentation.ts → runMigrations()`. - -**Step 4: Commit** - -```bash -git add src/db/migrations/ -git commit -m "feat: add migration for import_jobs and import_job_items" -``` - ---- - -### Task 3: Processor interface and registry - -**Files:** -- Create: `src/workers/import/types.ts` -- Create: `src/workers/import/registry.ts` - -**Step 1: Define the processor interface** - -```typescript -// src/workers/import/types.ts -import type { importJobs, importJobItems } from "@/db/schema"; - -export type ImportJob = typeof importJobs.$inferSelect; -export type ImportJobItem = typeof importJobItems.$inferSelect; - -export interface ProcessorContext { - headers: Record; // user auth headers for Immich API calls - albumId?: string; // resolved after setup() -} - -export interface SetupResult { - skipAssetIds: string[]; // source asset IDs already in Immich → mark skipped - albumId?: string; // created or resolved album ID - importDataPatch?: Record; // merged into job.importData in db -} - -export interface ImportProcessor { - /** Called once per job before items are processed. Dedup + album creation. */ - setup(job: ImportJob, context: ProcessorContext): Promise; - /** Called once per item. Returns the Immich asset ID on success, throws on failure. */ - processItem(job: ImportJob, item: ImportJobItem, context: ProcessorContext): Promise<{ immichId: string }>; -} -``` - -**Step 2: Create the registry** - -```typescript -// src/workers/import/registry.ts -import type { ImportProcessor } from "./types"; - -const processors = new Map(); - -export function registerProcessor(platform: string, processor: ImportProcessor) { - processors.set(platform, processor); -} - -export function getProcessor(platform: string): ImportProcessor { - const processor = processors.get(platform); - if (!processor) { - throw new Error(`No import processor registered for platform: ${platform}`); - } - return processor; -} -``` - -**Step 3: Commit** - -```bash -git add src/workers/import/types.ts src/workers/import/registry.ts -git commit -m "feat: add import processor interface and registry" -``` - ---- - -### Task 4: Immich shared-link processor - -**Files:** -- Create: `src/workers/import/processors/immich-shared-link.ts` - -This extracts the core download/upload logic from `src/pages/api/import-shared/upload-all.ts` into the processor interface. The helpers (`downloadSharedAsset`, `uploadAssetBuffer`, `createImmichAlbum`, `addAssetToAlbum`, `ensurePowerToolsTag`) already exist in `upload-all.ts` and `helpers.ts` — copy/import them as needed. - -**Step 1: Create the processor** - -```typescript -// src/workers/import/processors/immich-shared-link.ts -import { ENV } from "@/config/environment"; -import { - ensurePowerToolsTag, - tagAssetWithPowerTools, - type HeadersRecord, -} from "@/pages/api/import-shared/helpers"; -import type { ImportJob, ImportJobItem, ImportProcessor, ProcessorContext, SetupResult } from "../types"; - -// --- helpers copied/imported from upload-all.ts --- -// (parseFileNameFromDisposition, guessContentType, inferAssetTypeFromName, -// downloadSharedAsset, uploadAssetBuffer, createImmichAlbum, addAssetToAlbum) -// Copy them here verbatim from upload-all.ts so upload-all.ts stays intact. - -const DEVICE_ID = "immich-power-tools"; -const makeDeviceAssetId = (assetId: string) => `shared-${assetId}`; - -export class ImmichSharedLinkProcessor implements ImportProcessor { - async setup(job: ImportJob, context: ProcessorContext): Promise { - const urlConfig = JSON.parse(job.urlConfig) as { key: string }; - const items = await getJobItems(job.id); // helper defined in runner.ts, passed via closure or import - const deviceAssetIds = items.map((i) => makeDeviceAssetId(i.assetId)); - const lookup = new Map(items.map((i) => [makeDeviceAssetId(i.assetId).toLowerCase(), i.assetId])); - - const skipAssetIds: string[] = []; - try { - const res = await fetch(`${ENV.IMMICH_URL}/api/assets/exist`, { - method: "POST", - headers: { ...context.headers, "Content-Type": "application/json" }, - body: JSON.stringify({ deviceAssetIds, deviceId: DEVICE_ID }), - }); - if (res.ok) { - const { existingIds = [] } = await res.json(); - for (const id of existingIds) { - const assetId = lookup.get(id.toLowerCase()); - if (assetId) skipAssetIds.push(assetId); - } - } - } catch (e) { - console.warn("Dedup check failed, continuing without it", e); - } - - const importData = JSON.parse(job.importData) as { - albumOptions?: { createAlbum?: boolean; albumName?: string; addToAlbumId?: string }; - albumId?: string; - }; - - let albumId: string | undefined; - const albumOptions = importData.albumOptions; - if (albumOptions?.createAlbum) { - const name = albumOptions.albumName?.trim() || `Shared import ${new Date().toISOString()}`; - albumId = await createImmichAlbum(name, { - ...context.headers, - "Content-Type": "application/json", - } as HeadersRecord); - } else if (albumOptions?.addToAlbumId) { - albumId = albumOptions.addToAlbumId; - } - - return { skipAssetIds, albumId, importDataPatch: albumId ? { albumId } : undefined }; - } - - async processItem(job: ImportJob, item: ImportJobItem, context: ProcessorContext): Promise<{ immichId: string }> { - const urlConfig = JSON.parse(job.urlConfig) as { key: string }; - const itemData = JSON.parse(item.itemData) as { - originalFileName?: string; - type: string; - fileCreatedAt?: string | null; - localDateTime?: string | null; - duration?: string | null; - isFavorite?: boolean; - isArchived?: boolean; - }; - - const asset = { id: item.assetId, ...itemData }; - const { ["Content-Type"]: _omit, ...uploadHeaders } = context.headers as HeadersRecord; - - const downloaded = await downloadSharedAsset(asset, job.url, urlConfig.key); - const tag = await ensurePowerToolsTag(context.headers as HeadersRecord); - const immichId = await uploadAssetBuffer( - asset, - downloaded, - uploadHeaders as HeadersRecord, - context.headers as HeadersRecord, - tag.id - ); - - if (context.albumId) { - await addAssetToAlbum(context.albumId, immichId, { - ...context.headers, - "Content-Type": "application/json", - } as HeadersRecord); - } - - return { immichId }; - } -} -``` - -**Step 2: Register the processor** - -Create `src/workers/import/processors/index.ts`: -```typescript -import { registerProcessor } from "../registry"; -import { ImmichSharedLinkProcessor } from "./immich-shared-link"; - -registerProcessor("immich", new ImmichSharedLinkProcessor()); -``` - -**Step 3: Commit** - -```bash -git add src/workers/import/processors/ -git commit -m "feat: add immich shared-link import processor" -``` - ---- - -### Task 5: Worker runner - -**Files:** -- Create: `src/workers/import/runner.ts` - -The runner owns the active-job tracking (in-memory Set), processes items concurrently, and updates the db after each item. - -**Step 1: Create the runner** - -```typescript -// src/workers/import/runner.ts -import { eq, inArray } from "drizzle-orm"; -import { appDb } from "@/db"; -import { importJobs, importJobItems } from "@/db/schema"; -import { getProcessor } from "./registry"; -import "./processors/index"; // side-effect: registers all processors - -export type { ImportJob, ImportJobItem } from "./types"; - -const activeJobs = new Set(); -const CONCURRENCY = 4; - -export function isJobActive(jobId: string) { - return activeJobs.has(jobId); -} - -export async function getJobItems(jobId: string) { - return appDb.select().from(importJobItems).where(eq(importJobItems.jobId, jobId)); -} - -export async function resumePendingJobs() { - // Reset any jobs stuck in 'processing' from a previous crashed run - await appDb - .update(importJobs) - .set({ status: "pending" }) - .where(eq(importJobs.status, "processing")); -} - -export async function runImportJob( - jobId: string, - headers: Record -): Promise { - if (activeJobs.has(jobId)) return; - activeJobs.add(jobId); - - try { - const [job] = await appDb.select().from(importJobs).where(eq(importJobs.id, jobId)); - if (!job || job.status === "completed" || job.status === "failed") return; - - await appDb.update(importJobs).set({ status: "processing" }).where(eq(importJobs.id, jobId)); - - const processor = getProcessor(job.platform); - const context = { headers }; - - // --- Setup: dedup + album creation --- - const { skipAssetIds, albumId, importDataPatch } = await processor.setup(job, context); - - if (importDataPatch) { - const merged = { ...JSON.parse(job.importData), ...importDataPatch }; - await appDb.update(importJobs).set({ importData: JSON.stringify(merged) }).where(eq(importJobs.id, jobId)); - } - - if (skipAssetIds.length > 0) { - await appDb - .update(importJobItems) - .set({ status: "skipped" }) - .where(inArray(importJobItems.assetId, skipAssetIds)); - await appDb - .update(importJobs) - .set({ skippedCount: skipAssetIds.length }) - .where(eq(importJobs.id, jobId)); - } - - const processorContext = { ...context, albumId }; - const pendingItems = await appDb - .select() - .from(importJobItems) - .where(eq(importJobItems.status, "pending")); - - // --- Concurrent item processing --- - let idx = 0; - const worker = async () => { - while (true) { - const item = pendingItems[idx++]; - if (!item) break; - - await appDb - .update(importJobItems) - .set({ status: "processing" }) - .where(eq(importJobItems.id, item.id)); - - try { - const { immichId } = await processor.processItem(job, item, processorContext); - await appDb - .update(importJobItems) - .set({ status: "uploaded", immichId }) - .where(eq(importJobItems.id, item.id)); - await appDb - .update(importJobs) - .set({ uploadedCount: job.uploadedCount + 1 }) - .where(eq(importJobs.id, jobId)); - } catch (err: any) { - await appDb - .update(importJobItems) - .set({ status: "failed", error: err?.message ?? "Unknown error" }) - .where(eq(importJobItems.id, item.id)); - await appDb - .update(importJobs) - .set({ failedCount: job.failedCount + 1 }) - .where(eq(importJobs.id, jobId)); - } - } - }; - - const concurrency = Math.min(CONCURRENCY, pendingItems.length); - await Promise.all(Array.from({ length: concurrency }, worker)); - - await appDb - .update(importJobs) - .set({ status: "completed" }) - .where(eq(importJobs.id, jobId)); - } catch (err: any) { - console.error(`Import job ${jobId} failed fatally:`, err); - await appDb - .update(importJobs) - .set({ status: "failed" }) - .where(eq(importJobs.id, jobId)); - } finally { - activeJobs.delete(jobId); - } -} -``` - -**Step 2: Commit** - -```bash -git add src/workers/import/runner.ts -git commit -m "feat: add import job worker runner with concurrency and db tracking" -``` - ---- - -### Task 6: Update instrumentation.ts for startup resume - -**Files:** -- Modify: `instrumentation.ts` - -**Step 1: Add resume call** - -```typescript -// instrumentation.ts -export async function register() { - if (process.env.NEXT_RUNTIME === "nodejs") { - const { runMigrations } = await import("./src/db"); - await runMigrations(); - - const { resumePendingJobs } = await import("./src/workers/import/runner"); - await resumePendingJobs(); - } -} -``` - -`resumePendingJobs` only resets `processing → pending`; it doesn't start workers (no user headers available at boot). Workers restart on next poll. - -**Step 2: Commit** - -```bash -git add instrumentation.ts -git commit -m "feat: reset stuck import jobs to pending on startup" -``` - ---- - -### Task 7: Job creation API endpoint - -**Files:** -- Create: `src/pages/api/import-jobs/index.ts` - -**Step 1: Create the endpoint** - -```typescript -// src/pages/api/import-jobs/index.ts -import type { NextApiRequest, NextApiResponse } from "next"; -import { appDb } from "@/db"; -import { importJobs, importJobItems } from "@/db/schema"; -import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; -import { getUserHeaders } from "@/helpers/user.helper"; -import { runImportJob } from "@/workers/import/runner"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== "POST") { - res.setHeader("Allow", "POST"); - return res.status(405).json({ error: "Method Not Allowed" }); - } - - const currentUser = await getCurrentUser(req); - if (!currentUser) return res.status(401).json({ error: "Unauthorized" }); - - const { platform, url, urlConfig, importData, assets } = req.body; - - if (!platform || !url || !Array.isArray(assets) || assets.length === 0) { - return res.status(400).json({ error: "platform, url, and assets are required" }); - } - - // Create job row - const [job] = await appDb - .insert(importJobs) - .values({ - userId: currentUser.id, - platform, - url, - urlConfig: JSON.stringify(urlConfig ?? {}), - importData: JSON.stringify(importData ?? {}), - totalCount: assets.length, - }) - .returning(); - - // Create one item row per asset - await appDb.insert(importJobItems).values( - assets.map((asset: { id: string; [key: string]: unknown }) => ({ - jobId: job.id, - assetId: asset.id, - itemData: JSON.stringify(asset), - })) - ); - - // Fire-and-forget: start the worker without awaiting - const headers = getUserHeaders(currentUser) as Record; - runImportJob(job.id, headers).catch((err) => - console.error("Unhandled error in runImportJob", err) - ); - - return res.status(201).json({ jobId: job.id }); -} -``` - -**Step 2: Commit** - -```bash -git add src/pages/api/import-jobs/index.ts -git commit -m "feat: add POST /api/import-jobs endpoint to create and enqueue import jobs" -``` - ---- - -### Task 8: Job status polling endpoint - -**Files:** -- Create: `src/pages/api/import-jobs/[jobId].ts` - -**Step 1: Create the endpoint** - -```typescript -// src/pages/api/import-jobs/[jobId].ts -import type { NextApiRequest, NextApiResponse } from "next"; -import { eq } from "drizzle-orm"; -import { appDb } from "@/db"; -import { importJobs, importJobItems } from "@/db/schema"; -import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; -import { getUserHeaders } from "@/helpers/user.helper"; -import { isJobActive, runImportJob } from "@/workers/import/runner"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== "GET") { - res.setHeader("Allow", "GET"); - return res.status(405).json({ error: "Method Not Allowed" }); - } - - const currentUser = await getCurrentUser(req); - if (!currentUser) return res.status(401).json({ error: "Unauthorized" }); - - const { jobId } = req.query as { jobId: string }; - - const [job] = await appDb.select().from(importJobs).where(eq(importJobs.id, jobId)); - if (!job || job.userId !== currentUser.id) { - return res.status(404).json({ error: "Job not found" }); - } - - // Auto-resume: if job is pending but has no active worker (e.g. after server restart) - if (job.status === "pending" && !isJobActive(jobId)) { - const headers = getUserHeaders(currentUser) as Record; - runImportJob(jobId, headers).catch((err) => - console.error("Unhandled error in runImportJob (resume)", err) - ); - } - - const items = await appDb - .select() - .from(importJobItems) - .where(eq(importJobItems.jobId, jobId)); - - return res.status(200).json({ job, items }); -} -``` - -**Step 2: Commit** - -```bash -git add src/pages/api/import-jobs/[jobId].ts -git commit -m "feat: add GET /api/import-jobs/[jobId] polling endpoint with auto-resume" -``` - ---- - -### Task 9: Route constants and client handler - -**Files:** -- Modify: `src/config/routes.ts` -- Create: `src/handlers/api/import-jobs.handler.ts` - -**Step 1: Add routes** - -In `src/config/routes.ts`, append: -```typescript -// Import Jobs (queue) -export const IMPORT_JOBS_PATH = BASE_API_ENDPOINT + "/import-jobs"; -export const IMPORT_JOB_PATH = (jobId: string) => BASE_API_ENDPOINT + "/import-jobs/" + jobId; -``` - -**Step 2: Create handler** - -```typescript -// src/handlers/api/import-jobs.handler.ts -import { IMPORT_JOBS_PATH, IMPORT_JOB_PATH } from "@/config/routes"; -import API from "@/lib/api"; - -export interface CreateImportJobParams { - platform: string; - url: string; - urlConfig: Record; - importData: Record; - assets: { id: string; [key: string]: unknown }[]; -} - -export const createImportJob = async (params: CreateImportJobParams): Promise<{ jobId: string }> => { - return API.post(IMPORT_JOBS_PATH, params); -}; - -export const getImportJob = async (jobId: string) => { - return API.get(IMPORT_JOB_PATH(jobId)); -}; -``` - -**Step 3: Commit** - -```bash -git add src/config/routes.ts src/handlers/api/import-jobs.handler.ts -git commit -m "feat: add import-jobs route constants and client handler" -``` - ---- - -### Task 10: Update frontend to use job queue + polling - -**Files:** -- Modify: `src/pages/assets/import-shared.tsx` - -**Step 1: Add state for active job** - -In the component state block, add: -```typescript -const [activeJobId, setActiveJobId] = useState(null); -const [jobProgress, setJobProgress] = useState<{ uploaded: number; skipped: number; failed: number; total: number } | null>(null); -``` - -**Step 2: Replace `handleImportAll` body** - -Replace the `importSharedUploadAll(...)` call block with: - -```typescript -const job = await createImportJob({ - platform: "immich", - url: sharedData.origin, - urlConfig: { key: sharedData.key }, - importData: { albumOptions }, - assets: uploadableAssets.map((asset) => ({ - id: asset.id, - originalFileName: asset.originalFileName, - type: asset.type, - fileCreatedAt: asset.fileCreatedAt ?? null, - localDateTime: asset.localDateTime ?? null, - duration: asset.duration ?? null, - isFavorite: asset.isFavorite ?? false, - isArchived: asset.isArchived ?? false, - })), -}); -setActiveJobId(job.jobId); -setJobProgress({ uploaded: 0, skipped: 0, failed: 0, total: uploadableAssets.length }); -// importAllLoading stays true while polling -``` - -Remove the old result-parsing and banner-setting code from `handleImportAll` — that moves to the polling effect below. - -**Step 3: Add polling effect** - -```typescript -useEffect(() => { - if (!activeJobId) return; - - const interval = setInterval(async () => { - try { - const { job, items } = await getImportJob(activeJobId); - setJobProgress({ - uploaded: job.uploadedCount, - skipped: job.skippedCount, - failed: job.failedCount, - total: job.totalCount, - }); - - if (job.status === "completed" || job.status === "failed") { - clearInterval(interval); - setActiveJobId(null); - setImportAllLoading(false); - - const importData = JSON.parse(job.importData); - const failedCount = job.failedCount; - const skippedCount = job.skippedCount; - const uploadedCount = job.uploadedCount; - let bannerType: "success" | "error" = failedCount ? "error" : "success"; - let message: string; - - if (!uploadedCount && skippedCount && !failedCount) { - message = `All ${skippedCount} assets already exist on Immich.`; - } else if (failedCount) { - const skippedSuffix = skippedCount ? `, ${skippedCount} skipped` : ""; - message = `Imported ${uploadedCount} assets, ${failedCount} failed${skippedSuffix}.`; - } else if (skippedCount) { - message = `Imported ${uploadedCount} assets, skipped ${skippedCount} already on Immich.`; - } else { - message = `Imported ${uploadedCount} assets successfully.`; - } - - if (importData.albumId && uploadedCount > 0) { - const albumName = options.albumName || "selected album"; - message = `${message} Added to album "${albumName}".`; - } - - setUploadBanner({ type: bannerType, message }); - } - } catch (err) { - console.error("Polling error", err); - } - }, 2000); - - return () => clearInterval(interval); -}, [activeJobId]); -``` - -**Step 4: Update the progress UI** - -Replace the spinner/loading button text with a live counter when `jobProgress` is set: - -```tsx -{importAllLoading && jobProgress && ( -

- {jobProgress.uploaded + jobProgress.skipped} / {jobProgress.total} processed - {jobProgress.failed > 0 && ` · ${jobProgress.failed} failed`} -

-)} -``` - -**Step 5: Add imports at the top of the file** - -```typescript -import { createImportJob, getImportJob } from "@/handlers/api/import-jobs.handler"; -``` - -**Step 6: Commit** - -```bash -git add src/pages/assets/import-shared.tsx -git commit -m "feat: migrate import-shared to job queue with live progress polling" -``` - ---- - -## Done - -At this point: -- Imports are non-blocking: the page can be navigated away and the import continues server-side -- Progress is visible: `X / Y processed` updates every 2s -- Server restarts are handled: `instrumentation.ts` resets stuck jobs; next poll auto-resumes -- New platforms (Nextcloud, PhotoPrism) only need a new processor file + `registerProcessor` call in `processors/index.ts` -- The old `/api/import-shared/upload-all` endpoint is untouched and can be removed in a follow-up