Skip to content

feat: add read-only Immich photo picker - #6

Open
JosepOli wants to merge 3 commits into
tandpfun:mainfrom
JosepOli:feat/immich-photo-import
Open

feat: add read-only Immich photo picker#6
JosepOli wants to merge 3 commits into
tandpfun:mainfrom
JosepOli:feat/immich-photo-import

Conversation

@JosepOli

Copy link
Copy Markdown

Summary

  • add an optional read-only Immich photo picker with smart search and recent browsing over a configurable rolling year window
  • let users choose their model reference from Immich and import up to five explicitly selected outfit photos per batch
  • proxy thumbnails/originals without modifying Immich, cap downloaded image sizes, normalize reference images, and keep credentials in a gitignored file
  • add a configurable compatibility switch for image gateways that reject the Images output_format field
  • make the setup tray entry point directly actionable when the model reference is missing

Security and privacy

  • recommends a dedicated Immich key limited to asset.read, asset.view, and asset.download
  • no API keys, tokens, private hosts, personal paths, or deployment-specific values are included
  • the integration never bulk-processes the library; only user-selected photos are imported
  • documents that Wardrobe should stay on localhost/trusted networks or behind authenticated reverse proxying

Test plan

  • npm run check
    • immich_setup_entrypoint=ok
    • immich_api_contract=ok
    • production Vite build succeeds
  • npm audit --omit=dev --audit-level=high reports 0 vulnerabilities
  • git diff --check
  • staged and committed diff scanned for credentials, tokens, private keys, private IPs, and deployment-specific paths

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.
Copilot AI review requested due to automatic review settings July 21, 2026 15:16

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread scripts/immich-api.mjs Outdated
Comment on lines +125 to +126
const response = await request(endpoint);
const bytes = await limitedBytes(response, variant === "thumbnail" ? 10 * 1024 * 1024 : 64 * 1024 * 1024);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread scripts/immich-api.mjs
.toColorspace("srgb")
.png({ compressionLevel: 9 })
.toBuffer();
await writeFile(referencePath(), normalized, { mode: 0o600 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@JosepOli

Copy link
Copy Markdown
Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/import-flow.jsx Outdated
<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> : (

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8080e07: setup now accurately requires OPENAI_API_KEY, and the Immich label renders the configured year window.

Comment thread src/ImmichPicker.jsx
Comment on lines +75 to +79
if (!selectedAssets.length) return;
setBusy(true); setError("");
try { await onImport(selectedAssets); }
catch (requestError) { setError(requestError.message); setBusy(false); }
};

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8080e07: submitImport now clears busy state in a finally block.

Comment thread scripts/immich-api.mjs Outdated
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");

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8080e07: the code default now matches the documented .secrets/immich-api-key path. Container deployments can still override it.

Comment on lines 303 to 307
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()) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8080e07: every gateway image response is now decoded and normalized to PNG before the pipeline persists it.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread scripts/immich-api.mjs Outdated
Comment on lines +158 to +160
const response = await request(`/api/assets/${input.assetId}/original`);
const bytes = await limitedBytes(response, 64 * 1024 * 1024);
const normalized = await sharp(bytes)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/import-flow.jsx Outdated
<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> : (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@JosepOli

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/ImmichPicker.jsx
</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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants