feat: add read-only Immich photo picker - #6
Conversation
Add smart and recent photo browsing over a rolling date window, explicit reference-photo selection, bounded outfit imports, compatible image gateway configuration, and integration contract tests.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 21b8151bc9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const response = await request(endpoint); | ||
| const bytes = await limitedBytes(response, variant === "thumbnail" ? 10 * 1024 * 1024 : 64 * 1024 * 1024); |
There was a problem hiding this comment.
Align the original-image cap with the import body limit
Limit proxied originals to the maximum size the import path can actually accept. An Immich image between roughly 18.7 MiB and 64 MiB passes this check, but importImmichAssets converts it to a base64 data URL and posts it to /api/import/jobs; base64 expansion pushes that JSON over the 25 MiB limit in scripts/import-job-api.mjs:21, so an explicitly selectable image consistently fails with HTTP 413.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 3aba082: proxied originals are now capped at 18 MiB, below the 25 MiB JSON body limit after base64 expansion. The contract test now rejects a declared 19 MiB original.
| .toColorspace("srgb") | ||
| .png({ compressionLevel: 9 }) | ||
| .toBuffer(); | ||
| await writeFile(referencePath(), normalized, { mode: 0o600 }); |
There was a problem hiding this comment.
Create the configured reference directory before saving
Create path.dirname(referencePath()) recursively before writing the selected reference. When WARDROBE_MODEL_REFERENCE points to a new custom directory, such as references/model.png, no code creates that parent, so writeFile throws ENOENT, the endpoint returns 500, and setup remains blocked even though the Immich selection succeeded.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 3aba082: the reference endpoint now creates the configured parent directory recursively before writing. The contract test uses a previously missing nested directory.
Cap proxied originals below the JSON import body limit and create custom reference directories before writing normalized images.
|
@codex review |
There was a problem hiding this comment.
Pull request overview
Adds an optional, read-only Immich integration to the Wardrobe import flow, enabling users to pick a model reference and import explicitly selected outfit photos from an Immich library while keeping Wardrobe local-first and limiting data transfer.
Changes:
- Adds an Immich Vite middleware (
/api/immich/*) plus a new UI picker to browse/search and select up to 5 assets per import batch. - Updates the import tray/setup UX so missing model reference can be satisfied via Immich selection.
- Adds an OpenAI Images compatibility switch to omit
output_format, plus docs/env updates and lightweight contract checks.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| vite.config.mjs | Registers the Immich middleware plugin in the dev/preview server. |
| src/import-flow.jsx | Integrates Immich config fetch + picker entrypoints into the import UI. |
| src/import-flow.css | Makes the tray label interactive and styles hover/focus states. |
| src/ImmichPicker.jsx | New Immich picker UI: search/recent browsing, selection limits, reference save/import actions. |
| src/immich-picker.css | Styling for the Immich picker grid, search controls, and footer actions. |
| scripts/import-job-api.mjs | Adds OPENAI_API_BASE_URL and OPENAI_OMIT_IMAGE_OUTPUT_FORMAT support for image edits. |
| scripts/immich-api.mjs | New server-side Immich proxy/search/reference saver with size caps and normalization. |
| scripts/check-immich-entry-ui.mjs | Smoke check ensuring the setup tray entrypoint remains interactive and wired for Immich. |
| scripts/check-immich-api.mjs | Contract-style check for Immich API behavior, size limits, and reference save normalization. |
| README.md | Documents Immich picker behavior/security guidance and new env vars. |
| package.json | Adds test:immich and runs it as part of npm run check. |
| .gitignore | Ignores the .secrets/ directory for local Immich key storage. |
| .env.example | Adds Immich and OpenAI compatibility env vars with guidance. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <header className="import-popover__header"><div><p className="import-popover__eyebrow">Wardrobe import</p><h2 className="import-popover__title" id="import-title">{readyCount ? `${readyCount} ready for review` : activeStatus?.tone === "error" ? "Import needs attention" : jobs.length ? "Preparing new pieces" : notice?.text || "Add to your wardrobe"}</h2></div><button className="import-icon-button" type="button" onClick={() => setOpen(false)} aria-label="Close import progress"><X size={20} /></button></header> | ||
| {!jobs.length ? setupRequired ? <div className="import-drop-target import-setup-warning"><WarningCircle size={30} /><h2>Setup required</h2><p>Add your OpenAI API key to <code>.env</code> and a PNG reference photo of yourself at <code>{setup.modelReference || "data/model-reference.png"}</code>, then restart the app.</p></div> : <div className="import-drop-target"><UploadSimple size={28} /><h2>{notice ? "Try another image" : "Choose or paste an image"}</h2><p>{notice?.detail || "We’ll isolate each clothing item, suggest its details, and hold everything for your approval."}</p><button className="import-button import-button--primary" disabled={!setup?.ready} onClick={() => { setNotice(null); inputRef.current?.click(); }}>Choose images</button></div> : ( | ||
| <header className="import-popover__header"><div><p className="import-popover__eyebrow">Wardrobe import</p><h2 className="import-popover__title" id="import-title">{immichMode ? "Photos from Immich" : readyCount ? `${readyCount} ready for review` : activeStatus?.tone === "error" ? "Import needs attention" : jobs.length ? "Preparing new pieces" : notice?.text || "Add to your wardrobe"}</h2></div><button className="import-icon-button" type="button" onClick={() => { setImmichMode(null); setOpen(false); }} aria-label="Close import progress"><X size={20} /></button></header> | ||
| {immichMode ? <ImmichPicker mode={immichMode} onClose={() => setImmichMode(null)} onImport={importImmichAssets} onReferenceSaved={referenceSaved} /> : !jobs.length ? setupRequired ? <div className="import-drop-target import-setup-warning"><WarningCircle size={30} /><h2>Setup required</h2><p>{!setup.hasApiKey && <>Connect an OpenAI API or Codex OAuth provider. </>}{!setup.hasModelReference && <>Choose a PNG reference photo of yourself. </>}</p>{!setup.hasModelReference && immich?.ready && <div className="import-source-actions"><button className="import-button import-button--primary" onClick={() => openImmich("reference")}><Images size={15} /> Choose from Immich</button></div>}</div> : <div className="import-drop-target"><UploadSimple size={28} /><h2>{notice ? "Choose your next source" : "Choose, paste, or browse Immich"}</h2><p>{notice?.detail || "We’ll isolate each clothing item, suggest its details, and hold everything for your approval."}</p><div className="import-source-actions"><button className="import-button import-button--primary" disabled={!setup?.ready} onClick={() => { setNotice(null); inputRef.current?.click(); }}>Choose images</button>{immich?.ready && <button className="import-button" onClick={() => openImmich("import")}><Images size={15} /> Browse Immich · 4 years</button>}</div></div> : ( |
There was a problem hiding this comment.
Fixed in 8080e07: setup now accurately requires OPENAI_API_KEY, and the Immich label renders the configured year window.
| if (!selectedAssets.length) return; | ||
| setBusy(true); setError(""); | ||
| try { await onImport(selectedAssets); } | ||
| catch (requestError) { setError(requestError.message); setBusy(false); } | ||
| }; |
There was a problem hiding this comment.
Fixed in 8080e07: submitImport now clears busy state in a finally block.
| export function immichApi(options = {}) { | ||
| const setting = (name, fallback = "") => options.env?.[name] || process.env[name] || fallback; | ||
| const baseUrl = () => setting("IMMICH_BASE_URL", "").replace(/\/$/, ""); | ||
| const keyFile = () => setting("IMMICH_API_KEY_FILE", "/run/secrets/immich-api-key"); |
There was a problem hiding this comment.
Fixed in 8080e07: the code default now matches the documented .secrets/immich-api-key path. Container deployments can still override it.
| form.set("size", size); | ||
| form.set("quality", quality || "high"); | ||
| form.set("output_format", "png"); | ||
| if (!omitOutputFormat) form.set("output_format", "png"); | ||
| if (background) form.set("background", background); | ||
| for (const [index, image] of images.entries()) { |
There was a problem hiding this comment.
Fixed in 8080e07: every gateway image response is now decoded and normalized to PNG before the pipeline persists it.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3aba082190
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const response = await request(`/api/assets/${input.assetId}/original`); | ||
| const bytes = await limitedBytes(response, 64 * 1024 * 1024); | ||
| const normalized = await sharp(bytes) |
There was a problem hiding this comment.
Use Immich previews for unsupported originals
When a selected Immich IMAGE is stored as HEIC, RAW, or another format unsupported by the bundled Sharp binaries, fetching /original here makes sharp(bytes) fail and the reference cannot be saved. Outfit imports have the same problem because importImmichAssets fetches asset.originalUrl before the import API normalizes it. Immich's transcoded preview should be used for processing, or originals should be filtered/transcoded explicitly.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 8080e07: imports and reference selection now use Immich transcoded previews, covering HEIC/RAW inputs while preserving originals. Contract tests exercise the preview route, and the live Immich preview endpoint returned JPEG successfully.
| <header className="import-popover__header"><div><p className="import-popover__eyebrow">Wardrobe import</p><h2 className="import-popover__title" id="import-title">{readyCount ? `${readyCount} ready for review` : activeStatus?.tone === "error" ? "Import needs attention" : jobs.length ? "Preparing new pieces" : notice?.text || "Add to your wardrobe"}</h2></div><button className="import-icon-button" type="button" onClick={() => setOpen(false)} aria-label="Close import progress"><X size={20} /></button></header> | ||
| {!jobs.length ? setupRequired ? <div className="import-drop-target import-setup-warning"><WarningCircle size={30} /><h2>Setup required</h2><p>Add your OpenAI API key to <code>.env</code> and a PNG reference photo of yourself at <code>{setup.modelReference || "data/model-reference.png"}</code>, then restart the app.</p></div> : <div className="import-drop-target"><UploadSimple size={28} /><h2>{notice ? "Try another image" : "Choose or paste an image"}</h2><p>{notice?.detail || "We’ll isolate each clothing item, suggest its details, and hold everything for your approval."}</p><button className="import-button import-button--primary" disabled={!setup?.ready} onClick={() => { setNotice(null); inputRef.current?.click(); }}>Choose images</button></div> : ( | ||
| <header className="import-popover__header"><div><p className="import-popover__eyebrow">Wardrobe import</p><h2 className="import-popover__title" id="import-title">{immichMode ? "Photos from Immich" : readyCount ? `${readyCount} ready for review` : activeStatus?.tone === "error" ? "Import needs attention" : jobs.length ? "Preparing new pieces" : notice?.text || "Add to your wardrobe"}</h2></div><button className="import-icon-button" type="button" onClick={() => { setImmichMode(null); setOpen(false); }} aria-label="Close import progress"><X size={20} /></button></header> | ||
| {immichMode ? <ImmichPicker mode={immichMode} onClose={() => setImmichMode(null)} onImport={importImmichAssets} onReferenceSaved={referenceSaved} /> : !jobs.length ? setupRequired ? <div className="import-drop-target import-setup-warning"><WarningCircle size={30} /><h2>Setup required</h2><p>{!setup.hasApiKey && <>Connect an OpenAI API or Codex OAuth provider. </>}{!setup.hasModelReference && <>Choose a PNG reference photo of yourself. </>}</p>{!setup.hasModelReference && immich?.ready && <div className="import-source-actions"><button className="import-button import-button--primary" onClick={() => openImmich("reference")}><Images size={15} /> Choose from Immich</button></div>}</div> : <div className="import-drop-target"><UploadSimple size={28} /><h2>{notice ? "Choose your next source" : "Choose, paste, or browse Immich"}</h2><p>{notice?.detail || "We’ll isolate each clothing item, suggest its details, and hold everything for your approval."}</p><div className="import-source-actions"><button className="import-button import-button--primary" disabled={!setup?.ready} onClick={() => { setNotice(null); inputRef.current?.click(); }}>Choose images</button>{immich?.ready && <button className="import-button" onClick={() => openImmich("import")}><Images size={15} /> Browse Immich · 4 years</button>}</div></div> : ( |
There was a problem hiding this comment.
Remove the unsupported Codex OAuth setup option
When OPENAI_API_KEY is missing, this message tells the user that a Codex OAuth provider can satisfy setup, but the repository contains no OAuth flow and setupStatus() only considers OPENAI_API_KEY when deciding whether imports are ready. A user following this option remains permanently blocked, so the instruction should continue to require the environment key unless OAuth support is actually wired into the import API.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 8080e07: removed the unsupported OAuth wording and restored the OPENAI_API_KEY requirement.
Use Immich processing previews for HEIC and RAW compatibility, align configuration defaults and dynamic labels, normalize gateway image responses, and make picker state cleanup robust.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8080e07730
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| </div> | ||
| ) : <p className="immich-picker__empty">No matching photos were found. Try another search or open Recent.</p>} | ||
| <div className="immich-picker__footer"> | ||
| <div className="immich-pagination"><button className="import-button" disabled={loading || page <= 1} onClick={() => load({ requestedPage: page - 1 })}>Previous</button><span>Page {page}</span><button className="import-button" disabled={loading || !nextPage} onClick={() => load({ requestedPage: Number(nextPage) || page + 1 })}>Next</button></div> |
There was a problem hiding this comment.
Keep pagination tied to the submitted search
After displaying smart-search results, editing the search field and then clicking Previous or Next calls load without requestedQuery, so it defaults to the newly edited query value. This requests (for example) page 2 of an entirely new query, skipping that query's first page and replacing the displayed result set unexpectedly. Store the last submitted query separately and use it for pagination.
Useful? React with 👍 / 👎.
Summary
output_formatfieldSecurity and privacy
asset.read,asset.view, andasset.downloadTest plan
npm run checkimmich_setup_entrypoint=okimmich_api_contract=oknpm audit --omit=dev --audit-level=highreports 0 vulnerabilitiesgit diff --check