#31622·immich

[BUG] `--delete-duplicates` can unlink a file the server has never confirmed as a duplicate

Author: DevJakeCreated Sep 17, 2026Updated Sep 17, 2026

I have searched the existing issues, both open and closed, to make sure this is not a duplicate report.

  • Yes

The bug

checkForDuplicates in packages/cli/src/commands/asset.ts treats every rejected result from POST /assets/bulk-upload-check as a duplicate, regardless of why it was rejected (lines 239–246):

for (const { id: filepath, assetId, action } of results) {
  if (action === AssetUploadAction.Accept) {
    newFiles.push(filepath);
  } else {
    // rejects are always duplicates
    duplicates.push({ id: assetId as string, filepath });
  }
}

That comment isn't actually guaranteed by the CLI's own types. AssetBulkUploadCheckResult.reason is typed as AssetRejectReason, an enum with two members, duplicate and unsupported-format (server/src/dtos/asset-media-response.dto.ts:25–28, mirrored in the generated client at packages/sdk/src/fetch-client.ts:8134–8137 and in the published OpenAPI schema). Nothing here reads reason, and nothing checks that assetId was actually returned before treating the entry as a known, deletable duplicate.

Whatever ends up in duplicates is later unlinked outright once --delete-duplicates is set, in deleteFiles (lines 505–507):

if (options.deleteDuplicates) {
  await chunkDelete(duplicates);
}

chunkDelete calls unlink directly on the local file (and its XMP sidecar) — no trash, no confirmation.

What I could and could not reproduce. I read the current implementation of the endpoint the CLI calls, AssetMediaService.bulkUploadCheck (server/src/services/asset-media.service.ts:318–346), both on main and at the v3.2.2 tag the published @immich/cli build ships from (the CLI code is identical in both). It only ever compares checksums and returns accept, or reject with reason: duplicate — it does not inspect file format at all, so it never actually emits reason: 'unsupported-format' today, and I couldn't find any other current code path that does. So on a stock, unmodified immich-server I was not able to get this to delete a genuinely un-backed-up file end-to-end; the specific trigger the CLI defends so poorly against does not currently fire.

That doesn't make the handling correct, though. unsupported-format is part of the committed public API contract — it's in the generated SDK types and the published OpenAPI schema — so @immich/cli is already written as if it might see that value; it just handles it identically to duplicate instead of excluding it. Anything that starts populating that field (a future change to bulkUploadCheck, a server fork, or a plugin built on packages/plugin-sdk) will cause --delete-duplicates to silently and permanently delete a local file that was never actually stored on the server, with no server-side copy to fall back on. For a photo/video tool that's about the worst-shaped failure available, and right now nothing in the code stands between the API contract and it happening.

The OS that Immich Server is running on

N/A — this is a logic bug in @immich/cli's local duplicate-check handling and does not depend on server OS or deployment.

Version of Immich Server

Verified against immich-app/immich main (commit efbbd32e55067eb02a51030c5c948ae617484f8b, 2026-09-16) and tag v3.2.2; the relevant CLI and server code is identical in both.

Version of Immich Mobile App

N/A

Platform with the issue

  • Server
  • Web
  • Mobile

(This is specifically @immich/cli, which talks to the server API; there's no CLI checkbox in this template.)

Your docker-compose.yml content

N/A — not deployment-specific; the defect reproduces from CLI source and unit tests alone.

Your .env content

N/A — not deployment-specific.

Reproduction steps

This is demonstrable at the unit level today, the same way packages/cli/src/commands/asset.spec.ts already tests the duplicate branch ('returns duplicates when check duplicates is rejected', lines 164–185):

  1. Mock checkBulkUpload to resolve with a result where action: AssetUploadAction.Reject, reason: AssetRejectReason.UnsupportedFormat, and no assetId. This is a legal, typed response under the API contract, even though the current server never sends it.
  2. Call checkForDuplicates([testFilePath], { concurrency: 1 }).
  3. Observe the file lands in duplicates regardless of reason, with id: undefined.
  4. Run immich upload --delete-duplicates against that response: deleteFiles unlinks the file unconditionally, even though it was never confirmed present on the server.

I was not able to reproduce step 1 against a live, unmodified immich-server, because bulkUploadCheck doesn't currently emit that reason (see above). I'm filing this as a defect in how the CLI would handle a documented, typed API response it already imports, not as an incident that's already causing data loss against stock servers.

Relevant log output

N/A

Additional information

Suggested fix: gate the --delete-duplicates path on reason === AssetRejectReason.Duplicate and a defined assetId, and report anything else (e.g. unsupported-format) separately without touching the local file. I have a PR ready for this.