[BUG] `--delete-duplicates` can unlink a file the server has never confirmed as a duplicate
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):
- Mock
checkBulkUploadto resolve with a result whereaction: AssetUploadAction.Reject,reason: AssetRejectReason.UnsupportedFormat, and noassetId. This is a legal, typed response under the API contract, even though the current server never sends it. - Call
checkForDuplicates([testFilePath], { concurrency: 1 }). - Observe the file lands in
duplicatesregardless ofreason, withid: undefined. - Run
immich upload --delete-duplicatesagainst that response:deleteFilesunlinks 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.
Source: immich-app/immich