Skip to content

Add a verify command that confirms Immich can render each file - #35

Merged
ptr727 merged 0 commit into
developfrom
feature/verify-command
Aug 3, 2026
Merged

Add a verify command that confirms Immich can render each file#35
ptr727 merged 0 commit into
developfrom
feature/verify-command

Conversation

@ptr727

@ptr727ptr727 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Adds verify, a standalone pipeline step that answers whether Immich can generate a preview for a file, and turns on exiftool -validate in the metadata read process and import already perform.

Fixes#25.

Why

Issue #25 describes HEIC files that are byte-complete, pass every existing check, upload successfully, and then fail thumbnail generation forever. An iloc box re-encoded from version 1 to version 0 loses construction_method, so the grid descriptors stored in idat are read as absolute file offsets and land on the ftyp header.

That is one of three classes. Two more are open against Immich: DNGs libraw cannot parse, and RAW from cameras it does not recognize. Neither is corruption, so no parser can predict them. Only running the decoder Immich runs can.

How

Immich's decoder is the only judge.verify calls Immich's own compiled MediaRepository, defaults, and ThumbnailConfig inside the immich-server image. Calling Immich's code rather than reimplementing its pipeline means behavior tracks Immich across releases; the coupling surface is three module paths, which the preflight checks. Paths are streamed in batches over stdin so container startup is paid once per batch rather than once per file.

Docker is required, and there is no offline mode. A preflight runs before any file is judged, so an unreachable Docker exits Error rather than condemning the collection.

PhotoCleaner carries no container parser of its own, deliberately. A hand-rolled parser condemns whatever it fails to understand, and a format it has never met is indistinguishable from a damaged one from the inside, so it would report other people's valid media as corrupt on evidence it could not actually read. The cost is the truncation signal: a truncated file decodes cleanly, so no decoder reports it. That cost is accepted rather than guessed around.

A file that cannot be read, or that vanishes mid-run, counts as failed rather than invalid, since neither is evidence of damage. process is the exception and logs at information without failing, because it renames as it walks and cannot cheaply tell its own work from an external deletion.

Exit codes

All six commands now share one contract: 0 success, 1 could not run, 2 completed with per-file failures. This is a behavior change - process previously exited 0 while logging failed files.

exiftool validation

-validate -all folds into the existing call at no measured cost. Only an error count fails a file: measured across a real collection, ~75% of healthy files carry validation warnings, so warnings are logged at debug level only.

Verification

  • Reproduces the reported defect on a real specimen, which the decode pass rejects with Input file has corrupt header: ... bad seek, the same error as the upstream report.
  • A full run over a 264,044-file collection completed with 0 failures and flagged 16,409 files, of which 16,394 carry the issue Input file has corrupt header #25 defect. Those hold 8,198 distinct SHA-1 values, so every corrupt image exists as a duplicate pair, which reconciles with the ~8,210 assets reported upstream.
  • A 28,965-file collection found 7 damaged JPEGs and 0 false positives. Every one was caught only by the decode pass: exiftool -validate called four of them OK, and ffmpeg reported damage while still exiting 0.
  • Docker removed from PATH exits 1 with a clear message and marks no file invalid.
  • 367 tests pass; markdownlint, editorconfig-checker and cspell clean.

Sample files are documented alongside the corpus, including one that is structurally intact and fails only in the decode pass.

CopilotAI review requested due to automatic review settings August 2, 2026 01:31
@codecov

codecovBot commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 31.91489% with 352 lines in your changes missing coverage. Please review.
✅ Project coverage is 43.37%. Comparing base (1ab0f11) to head (fb944b6).
⚠️ Report is 3 commits behind head on develop.

Files with missing linesPatch %Lines
PhotoCleaner/VerifyTask.cs29.22%196 Missing and 5 partials ⚠️
PhotoCleaner/VerifyCommand.cs0.00%46 Missing ⚠️
PhotoCleaner/ProcessTask.cs0.00%26 Missing ⚠️
PhotoCleaner/ImportTask.cs0.00%25 Missing ⚠️
PhotoCleaner/ProcessCommand.cs0.00%18 Missing ⚠️
PhotoCleaner/MediaUtilities.cs0.00%14 Missing ⚠️
PhotoCleaner/CommandRunner.cs47.36%9 Missing and 1 partial ⚠️
PhotoCleaner/ImportCommand.cs0.00%4 Missing ⚠️
PhotoCleaner/SkippedExtensionTracker.cs0.00%4 Missing ⚠️
PhotoCleaner/IndexCommand.cs0.00%2 Missing ⚠️
... and 2 more
Additional details and impacted files
@@ Coverage Diff @@## develop #35 +/- ##
===========================================
- Coverage 44.96% 43.37% -1.59% 
===========================================
Files 25 28 +3 Lines 3398 3896 +498 Branches 259 306 +47 ===========================================
+ Hits 1528 1690 +162 - Misses 1824 2154 +330 - Partials 46 52 +6 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

CopilotAI 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 a new verify pipeline stage to detect media that uploads to Immich but cannot be rendered for previews, by combining an in-process ISO-BMFF structural validation pass with an optional Docker-based decode pass that runs Immich’s own thumbnail pipeline. The PR also standardizes command exit codes (0/1/2) and enables exiftool -validate in existing metadata reads so process/import can mark/skip files with validation errors.

Changes:

  • Introduces verify (CLI + orchestration + decode script) plus an ISO-BMFF structural validator for HEIC/HEIF/MOV/MP4/3GP.
  • Enables exiftool -validate -all in the shared metadata read and adds validation parsing/handling in process and import.
  • Standardizes exit-code semantics across commands and updates documentation, launch configs, spelling dictionary, and tests accordingly.

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 2 comments.

Show a summary per file
FileDescription
README.mdDocuments verify, --quick, and standardized exit codes; adds verify flow and examples.
PhotoCleanerTests/VerifyTaskTests.csAdds unit tests for verify output parsing and script/image contract checks.
PhotoCleanerTests/UndoTaskTests.csUpdates options initialization for new Quick option property.
PhotoCleanerTests/TrashCommandTests.csUpdates options initialization and minor comment cleanup.
PhotoCleanerTests/TempDirectoryFixture.csComment tweaks and maintains exiftool invocation behavior in tests.
PhotoCleanerTests/ProcessTaskTests.csAdds tests covering exiftool validation behavior in process.
PhotoCleanerTests/IsoBmffValidatorTests.csAdds synthesized-container tests for structural ISO-BMFF validation.
PhotoCleanerTests/IndexTaskTests.csUpdates options initialization and minor comment adjustments.
PhotoCleanerTests/ImportTaskTests.csUpdates expected tuple shape/counts to include invalid results from import.
PhotoCleanerTests/ExifToolJsonTests.csAdds tests for parsing ExifTool:Validate verdict strings.
PhotoCleanerTests/DirectoryCleanerTests.csMinor comment clarifications only.
PhotoCleanerTests/CommandLineTests.csAdds CLI parsing tests for the new verify command and --quick.
PhotoCleaner/VerifyTask.csImplements quick structural mode, full decode mode, Docker preflight, batching, and result parsing.
PhotoCleaner/VerifyResult.csAdds verify result/JSON protocol model + source-gen context.
PhotoCleaner/VerifyCommand.csAdds command orchestration, counting, logging, and exit-code mapping.
PhotoCleaner/UndoTask.csComment clarifications (no functional change).
PhotoCleaner/UndoCommand.csReturns standardized exit codes based on per-file failures.
PhotoCleaner/TrashCommand.csReturns standardized exit codes for command completion.
PhotoCleaner/ProcessTask.csAdds Invalid result and acts on exiftool validation errors before processing pipeline.
PhotoCleaner/ProcessCommand.csTracks invalid count and exits with 2 when invalid/failed files are present.
PhotoCleaner/MediaUtilities.csAdds -validate -all to shared exiftool metadata read.
PhotoCleaner/IsoBmffValidator.csAdds total-by-construction ISO-BMFF box-walk validator and defect reporting.
PhotoCleaner/IndexCommand.csReturns standardized exit codes based on per-file failures.
PhotoCleaner/ImportTask.csAdds Invalid import result for exiftool validation errors and reports counts.
PhotoCleaner/ImportCommand.csLogs invalid count and returns standardized exit codes.
PhotoCleaner/ImmichVerifyScript.csAdds in-container Node script strings (preflight + per-path verification via Immich modules).
PhotoCleaner/ExitCode.csIntroduces shared exit-code contract constants (0/1/2).
PhotoCleaner/ExifToolJson.csAdds ExifTool:Validate field and parsing helper for error/warning counts.
PhotoCleaner/CommandRunner.csUpdates runner to return the command’s exit code and standardize cancellation/error handling.
PhotoCleaner/CommandLine.csRegisters verify command and adds --quick option + Options.Quick field.
HISTORY.mdAdds release notes for verify, validate behavior, and exit-code breaking change.
cspell.jsonAdds new domain terms used by verify/ISO-BMFF validation and docs.
.vscode/launch.jsonAdds launch profiles for verify and verify --quick.
.github/copilot-instructions.mdUpdates architecture/docs to include verify components and exit-code contract.

Comment threadPhotoCleaner/MediaUtilities.cs
Comment threadPhotoCleaner/VerifyTask.cs
CopilotAI review requested due to automatic review settings August 2, 2026 01:50

CopilotAI 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

Copilot reviewed 34 out of 34 changed files in this pull request and generated no new comments.

Suppressed comments (2)

PhotoCleaner/VerifyTask.cs:96

  • The comment says quick-mode verification is "not recorded in the database", but quick mode still calls IndexTask.IndexFileAsync when --db is provided (inserting/updating hash state). What’s true is that quick mode does not mark files as verified (is_processed) so a later full run won’t skip decode. Consider updating the comment to match the actual behavior to avoid confusion during maintenance.
 // Not recorded in the database, so a later full run cannot skip a file never decoded.
tally.Verified();

README.md:306

  • The README says a --quick run "never records anything", but verify --quick --db ... still writes/updates rows via IndexTask (it just doesn’t mark files as verified). This wording could mislead users into thinking the DB is untouched. Consider rephrasing to clarify that quick mode avoids setting the verified bit rather than avoiding all DB writes.

CopilotAI review requested due to automatic review settings August 2, 2026 02:27
@ptr727

Copy link
Copy Markdown
OwnerAuthor

Both suppressed comments from the last review were correct and are fixed in 98a20c3.

The claim that a --quick run "records nothing" was wrong in both the code comment and the README. Quick mode still goes through the shared index path when --db is given, so it writes content hashes, and those are cached for a later run. What it withholds is the verified bit, which is what keeps a later full run from skipping a file whose decode was never attempted.

Comment now reads The verified bit is left unset, so a later full run still decodes this file, and the README bullet says the same rather than implying the database is untouched.

Also in this round, from the earlier review:

  • 35ea0e6 - exiftool verdict decided from output rather than exit code. Worth noting the impact was larger than the comment suggested: exiftool exits non-zero on exactly the files whose verdict reports an error, so the fail-on-error branch was unreachable.
  • 88f0abb - paths translated across the container boundary instead of assuming the host path is valid inside the container.
  • 8adb86a - paired start/stop debug lines around each container invocation, plus measured startup: about 3.8s median over 200 synthetic launches, roughly 2.3s of which is Immich's own module graph.

CopilotAI 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

Copilot reviewed 34 out of 34 changed files in this pull request and generated no new comments.

Suppressed comments (2)

PhotoCleaner/VerifyTask.cs:213

  • PassesStructuralCheck treats I/O or other unexpected exceptions as a pass (return true). In --quick mode that means the file is counted as Verified even though it could not be read/checked, which produces an incorrect success verdict for tooling/permission problems. These cases should be counted as Failed and the file should not be marked verified.
 catch (Exception ex)
{
// A parser failure is not evidence the file is bad.
Log.Warning(ex, "Structural check could not read '{FilePath}'", file);
return true;

PhotoCleaner/VerifyTask.cs:179

  • ShouldVerifyAsync calls IndexFileAsync without any per-file exception handling. If hashing/indexing throws (e.g., file deleted mid-run, access denied, transient IO error), the exception will bubble out of the Parallel.ForEachAsync body and abort the entire verify run rather than counting just that file as failed.

Consider catching non-cancellation exceptions here, logging the file path, incrementing Failed, and skipping verification for that file so the rest of the collection can still be verified.

 if (database is not null)
{
IndexTask indexTask = new(options, database, skippedExtensions);
(IndexStatus status, _, _, bool wasVerified) = await indexTask
.IndexFileAsync(file, cancellationToken)
.ConfigureAwait(false);

CopilotAI review requested due to automatic review settings August 2, 2026 02:30

CopilotAI 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

Copilot reviewed 34 out of 34 changed files in this pull request and generated no new comments.

Suppressed comments (1)

PhotoCleaner/VerifyTask.cs:213

  • In PassesStructuralCheck, any exception (e.g., file missing, access denied, transient I/O error) currently logs a warning but returns true. In --quick mode that means the file is then counted as Verified even though it was never actually checked, which can produce false-clean results.

Treat an inability to read the file as a tooling failure for that file: increment Failed and return false so it is not counted as verified (and in full mode it will not be sent to Immich either).

 catch (Exception ex)
{
// A parser failure is not evidence the file is bad.
Log.Warning(ex, "Structural check could not read '{FilePath}'", file);
return true;
}

CopilotAI review requested due to automatic review settings August 2, 2026 02:39
@ptr727

Copy link
Copy Markdown
OwnerAuthor

The suppressed comment on PassesStructuralCheck was correct and is fixed in 1832bdf.

The catch-all logged a warning and returned true, so a file the check could not open was counted as verified. For a command whose entire purpose is to answer whether a file is good, a false clean is the worst outcome it can produce, and this one was reachable from an ordinary permission error or transient I/O fault.

Rather than invent handling, I compared against the per-file pattern already used by ProcessCommand, ImportTask, and IndexTask:

  • a file that no longer exists is logged at information and left uncounted, since that races with any concurrent run
  • any other exception is an error against the Failed count

The structural check now follows that pattern exactly. Neither path reports the file as verified, and neither reports it as invalid, because being unable to read a file is not evidence of damage. In full mode the file is also no longer handed to the decode pass, which would otherwise have risked a false Invalid for what is really an I/O problem.

Added a regression case using an unreadable file. Restoring the old catch fails it on Verified being 1 rather than 0, so the case proves the fault it names rather than merely passing.

On process: low-confidence findings are being treated as first-class here, not skimmed. This one and the two in the previous round were all real, and GOVERNANCE.md requires the collapsed findings to be investigated and answered precisely because they appear in no thread and a thread-polling loop would report a clean pass while they stand.

CopilotAI 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

Copilot reviewed 34 out of 34 changed files in this pull request and generated no new comments.

Suppressed comments (1)

README.md:332

  • The Exit Codes section says “Only a 2 means files were actually found to be bad”, but verify returns exit code 2 when counts.Failed > 0 as well (e.g., an unreadable file or a missing verdict line). Those cases are tooling/coverage gaps rather than “files were bad”, so the current wording can cause scripts (including the example below) to misinterpret the outcome.

Consider clarifying that 2 means “one or more files were invalid or could not be verified”, while 1 remains “verification could not run at all / preflight failed.”

CopilotAI review requested due to automatic review settings August 2, 2026 04:51
@ptr727

Copy link
Copy Markdown
OwnerAuthor

The suppressed comment on the exit-code section was correct and is fixed in a023731.

It caught a second-order effect of the previous round's fix: once an unreadable file counted against Failed, exit 2 stopped meaning only "files were found to be bad". A run that merely could not read a file exits 2 while nothing was judged bad, so the old wording would have led a script to the wrong conclusion.

The table row now reads invalid or could not be verified, and the prose points at the Invalid and Failed counts as the way to tell a bad file from a gap in coverage. 1 still means the check could not run at all.

Also in this round, 4ea2f09 raises the decode batch from 256 to 1024. Per-file decode cost turned out to span fiftyfold across real media - about 20ms for a thumbnail against 1103ms for HEIC and video - so at 256 a batch of small images spent 37 percent of its time starting the container. 1024 puts the cheap end at sevenfold and typical photographs near fiftyfold, without producing fewer batches than threads on a small tree.

Verification against a real 28,965-file collection: 7 damaged JPEGs found, 0 false positives, 0 failures. All seven were confirmed independently with ffmpeg, and every one was caught only by the decode pass - exiftool -validate called four of them OK, and ffmpeg reported the damage but still exited zero. One sample of each distinct failure signature has been added to the regression corpus.

CopilotAI 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

Copilot reviewed 34 out of 34 changed files in this pull request and generated no new comments.

Suppressed comments (1)

PhotoCleaner/VerifyTask.cs:213

  • PassesStructuralCheck treats a missing file as a silent skip: it returns false without incrementing Failed, and the exception filter || !File.Exists(file) can also match non-"missing" failures (e.g., paths that make File.Exists return false). This can make verify exit 0 even though some files were never verified.

Count this case as Failed and narrow the filter to actual missing-file exceptions so other I/O errors are still counted as failures.

 catch (Exception ex) when (ex is FileNotFoundException || !File.Exists(file))
{
Log.Information("File no longer exists during verification: '{FilePath}'", file);
return false;
}

CopilotAI review requested due to automatic review settings August 2, 2026 05:04
@ptr727

Copy link
Copy Markdown
OwnerAuthor

Verified the finding before acting, then split it, because the two halves are not equivalent.

The filter breadth is a real bug, fixed in 6e1aefe and applied across the codebase in 44637e9. I tested the claim rather than taking it on faith:

parent directory chmod 000:
File.Exists = False
OpenRead throws UnauthorizedAccessException

So || !File.Exists(file) did route a permission error into the branch meant for files that went away, logged at information and counted nowhere. That defeats the false-clean fix from the previous round. The filter now matches FileNotFoundException or DirectoryNotFoundException, so every other error reaches the failed count, which is what the surrounding pattern always intended. A regression test covers it and fails on the old filter with Failed being 0 rather than 1.

The same filter existed in ProcessCommand, so it is corrected there too. ImportTask and IndexTask never had the lenient branch, routing everything to failed, so those two sites were the whole set. Left as two inline filters rather than a shared predicate: the condition is one type pattern, and an exception filter reads better where it is caught than behind a call.

Counting a genuinely missing file as Failed is declined, as a change to a deliberate decision rather than a defect. A file that vanished mid-run is expected in this pipeline, since process renames files while it works, and the existing treatment of logging it and moving on is long-standing behaviour across the commands. Making verify exit 2 because another run renamed a file would report a bad collection where none exists, which is the same class of false alarm as reporting an unreachable Docker as corruption. If that call is ever revisited it should change in every command at once, not just the newest one.

For the record on process, all five suppressed comments raised on this PR have been real, and each was checked against the code and the existing patterns before being acted on or declined.

CopilotAI 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

Copilot reviewed 34 out of 34 changed files in this pull request and generated no new comments.

Suppressed comments (2)

PhotoCleaner/VerifyTask.cs:215

  • When a file disappears between enumeration and verification, it currently logs and returns without incrementing any counter. That can make the final counts (and exit code) look like a clean pass even though some paths were never verified. Treat this as a tooling/coverage failure (like unreadable files / missing verdicts) so callers can distinguish “all files verified” from “some files were not checked”.
 catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException)
{
// File.Exists also returns false when the path cannot be read at all.
// Matching the exception alone keeps a permission error out of this uncounted branch.
Log.Information("File no longer exists during verification: '{FilePath}'", file);
return false;

PhotoCleaner/IsoBmffValidator.cs:297

  • extentCount comes from untrusted file bytes, but it’s used to pre-size the List<Extent>. A malformed file can declare a very large extent count and cause a large allocation (or OOM) before any bounds checks fail. Avoid pre-sizing from the on-disk value; let the list grow as extents are successfully parsed.
 int extentCount = BinaryPrimitives.ReadUInt16BigEndian(buffer[..2]);
List<Extent> extents = new(extentCount);
for (int e = 0; e < extentCount; e++)

CopilotAI review requested due to automatic review settings August 2, 2026 05:09

CopilotAI 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

Copilot reviewed 34 out of 34 changed files in this pull request and generated no new comments.

Suppressed comments (1)

PhotoCleaner/VerifyTask.cs:180

  • VerifyTask.ShouldVerifyAsync allocates a new IndexTask for every file. Besides allocation overhead on large trees, IndexTask.IndexFileAsync logs at Information level per file, so a normal verify --db ... run at the default log level will emit every file path (very noisy and potentially sensitive) even when everything verifies cleanly.

Consider indexing directly here (using Database.ResolveHashesAsync + Insert/Update calls) or otherwise reusing/suppressing the per-file IndexTask logging so verify stays “log only defects” by default.

 if (database is not null)
{
IndexTask indexTask = new(options, database, skippedExtensions);
(IndexStatus status, _, _, bool wasVerified) = await indexTask
.IndexFileAsync(file, cancellationToken)

CopilotAI review requested due to automatic review settings August 2, 2026 15:23

CopilotAI 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

Copilot reviewed 39 out of 39 changed files in this pull request and generated no new comments.

@ptr727

Copy link
Copy Markdown
OwnerAuthor

Both suppressed comments from review 4839916844 confirmed and fixed in ae86b42. I found them while checking whether this PR was clean enough to merge, so it is worth saying plainly that it was not, and this review is why.

Unreadable file counted Invalid without --db. Correct, and the inconsistency is the real problem rather than either verdict on its own. With a database the hash read opens every file, so a permission or I/O failure throws and the per-file guard counts it Failed. Without one nothing opened the file before the container did, so the same file went to Immich, came back unrenderable, and was counted Invalid. The verdict for a tooling gap therefore depended on an unrelated flag, and one of the two paths reported a readable-media problem as damaged media.

Fixed with the cheap probe open the comment suggested, in the no-database path only, letting the existing guard count the throw so both paths reach one verdict. ExecuteAsync_UnreadableFileWithoutDatabase_CountsFailedNotInvalid covers it and reports Failed 0 without the fix.

Worth noting this restores a guarantee I had removed. When the container parser came out in 60dd072 I deleted the equivalent test, reasoning that the premise no longer held because the container runs as root and can read a file the host process cannot. That reasoning was wrong: the point was never whether Immich can read the file, it was that PhotoCleaner could not, and a tool that cannot read a file has learned nothing about the media. The test is back.

Verify Quick launch profile. Correct, and it is the last surviving reference to a mode that never shipped. My sweep for it was case-sensitive, so --quick in the args was found and removed while Quick in the profile name was not. Re-swept case-insensitively across the repository and the out-of-repo pipeline script; this was the only remaining hit. The two verify profiles differ by whether they pass a database, so the name now says that.

All gates clean, 368 tests pass.

@ptr727

Copy link
Copy Markdown
OwnerAuthor

Audited every Copilot review on this PR again before considering a merge, and found three suppressed sets I had never answered: 4839594134, 4839799677, and 4839840931. Answering all of them now. Most were overtaken by later commits, but three were live and two of those were real defects.

Live and fixed.

  • A vanished file counted Invalid rather than Failed (4839799677). A file removed after its batch was assembled makes the decoder fail, and that rejection was reported as "Immich cannot render", so a file that is simply gone was recorded as damaged media. The host is now checked before a rejection is believed. This is the same semantic the probe fix addressed one commit earlier, in the one remaining place it did not hold.
  • The verify flow documented the wrong mount (4839594134 and 4839840931, raised twice). It said the media directory is mounted "read-only at its own absolute path". It is mounted at the fixed container path /photocleaner with every path translated onto it, which is precisely what lets a host path that is not a valid container path work. Also corrected the exit criterion, which is 2 for a failed file as well as an invalid one.

Declined, with reasons.

  • Materializing files.Chunk(BatchSize) into a list (4839840931). The batch count is used in the log line that opens the run, so the sequence has to be enumerated regardless. The arrays hold the same string references already held by files, so at 264,044 files this is roughly 258 arrays over about two megabytes of existing references, and streaming would trade that for losing the count. Not worth the change.
  • HISTORY.md calling the files "corrupt" (4839594134). The claim is accurate: an iloc re-encoded to version 0 cannot express construction_method, so the container genuinely is malformed, not merely unlucky. The sentence already says they "passed the entire pipeline undetected", which carries the byte-complete nuance the comment is protecting. It is also the maintainer's own first-person release note, so it is not mine to reword on a reviewer's preference.

Obsolete, listed so the record is complete: the PassesStructuralCheck warning wording and the preflight --quick message were both raised against commits that no longer exist in this form; the parser was removed in 60dd072 and the message corrected in 0e60b69.

One test note, since it matters more than the finding. I first wrote ExecuteAsync_FileRemovedBeforeDecode_CountsFailedNotInvalid for the vanished-file fix, then checked it against the unfixed code and it passed, because the earlier existence check catches a never-existing path long before the decode. It proved nothing while looking like coverage, so I removed it rather than ship it, and recorded in the code why the branch is untested: reproducing it needs the file to vanish inside the window between assembling a batch and reading its results.

All gates clean, 368 tests pass.

@ptr727
ptr727 requested review from Copilot and removed request for CopilotAugust 3, 2026 13:09

CopilotAI 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

Copilot reviewed 39 out of 39 changed files in this pull request and generated 1 comment.

Comment threadPhotoCleaner/VerifyTask.cs

CopilotAI 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

Copilot reviewed 39 out of 39 changed files in this pull request and generated no new comments.

Suppressed comments (1)

PhotoCleaner/VerifyTask.cs:82

  • VerifyTask.RunAsync always runs PreflightAsync (Docker + image check) even when there are no supported media files to verify. This makes verify fail with exit code 1 on hosts without Docker even if the input contains only ignored non-media files (or an empty list), and adds avoidable startup cost for those no-op runs.

Consider short-circuiting before the preflight when there are zero supported extensions in files (and optionally, in a follow-up, deferring preflight until at least one post-DB-skip candidate exists).

 await PreflightAsync(cancellationToken).ConfigureAwait(false);

@ptr727

Copy link
Copy Markdown
OwnerAuthor

Suppressed comment from review 4844671099 confirmed and fixed in e1d846d.

The preflight ran before the command looked at what it had been given, so a directory holding no supported media exited 1 on a host without Docker, and paid about four seconds of container startup on a host that had it. That is an infrastructure failure reported for work the command was never going to do, which is the same shape as the other verdict problems this branch has been fixing: the tool blaming the environment, or the media, for its own accounting.

Preflight now runs only when at least one file carries a supported extension. The partition loop is untouched, so non-media files are still counted Ignored and still drive the unknown-extension warnings.

Proven end to end rather than argued, because a unit test on this host cannot tell the two apart. With docker removed from PATH and a directory containing only .txt and .md:

without the fix: Verifying Immich image ... is usable
System.InvalidOperationException: Verification requires the docker command ...
exit=1
with the fix: Total 2 files / Ignored 2 non-media files / Failed 0
exit=0

ExecuteAsync_OnlyNonMediaFiles_NeedsNoDocker is deliberately not gated on the Immich image being available, unlike the other ExecuteAsync cases. A host without that image is precisely the situation this protects, and it is also what CI is, so on CI the test fails without the fix and passes with it. That is the first test in this loop that distinguishes the fix in the environment that matters, after two that did not and were removed.

I did not take the follow-up half of the suggestion, deferring preflight until at least one post-database-skip candidate exists. It would mean a full pass over the file list, including hashing every file, before the first container starts, which trades a guaranteed cost for an occasional saving and delays the point where an unreachable Docker is reported. Worth revisiting only if repeat no-op runs over large trees become common.

All gates clean, 369 tests pass.

CopilotAI 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

Copilot reviewed 39 out of 39 changed files in this pull request and generated no new comments.

Suppressed comments (1)

PhotoCleaner/VerifyTask.cs:406

  • docker run -v "{mount}:{ContainerMount}:ro" breaks when the host path contains a ':' (valid on Linux/macOS), because Docker uses ':' as the volume-spec separator. That makes verify fail on some perfectly valid media directories.

Use --mount type=bind,... instead, which avoids ':'-delimited parsing and supports ':' in source paths.

 if (mount is not null)
{
arguments.Add("-v");
arguments.Add($"{mount}:{ContainerMount}:ro");
}

CopilotAI 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

Copilot reviewed 39 out of 39 changed files in this pull request and generated no new comments.

Suppressed comments (1)

PhotoCleaner/VerifyTask.cs:88

  • PreflightAsync is triggered based only on the presence of any media extension in the input set. When --db is used, it’s possible for every media file to be skipped as already-verified/unchanged (and --reprocess is false), in which case nothing will be decoded—but verify will still require Docker and will fail with exit code 1 on hosts without Docker.

This contradicts the intended behavior (“Nothing to decode means nothing needs Docker”) and defeats the point of database-backed incremental runs.

 if (files.Any(file => MediaUtilities.SupportedExtensions.Contains(Path.GetExtension(file))))
{
await PreflightAsync(cancellationToken).ConfigureAwait(false);
}

@ptr727

Copy link
Copy Markdown
OwnerAuthor

Suppressed comment from review 4844778651 confirmed and fixed in 3c0c9bc, but not by the suggested change, which on its own would have made things worse.

The defect is real. With a colon in the media directory, docker rejects the spec and the whole batch dies:

Immich decode batch exited 125: docker: invalid spec: /tmp/verify-test/has:colon:/photocleaner:ro: too many colons
Verified 0 files / Failed 1 files

The suggested remedy trades one delimiter for another.--mount is comma delimited, so it rejects a comma where -v rejects a colon. Measured across a real 267,816-path collection: 285 paths contain a comma, and none contain a colon. Album directories named like Family Reunion, Summer 2010 are ordinary; a directory containing a colon is not. So swapping -v for a naive --mount would trade a hazard that has never occurred there for one that already exists 285 times over.

Tested rather than reasoned, on docker 29.6.2:

form: in source, in source
-v host:target:rorejectedworks
--mount type=bind,source=...worksrejected
--mount type=bind,"source=...",target=...worksworks

Quoting the value inline as source="..." does not work, since the CSV parser rejects a bare quote mid-field. The whole field has to be quoted, "source=...", and an embedded quote doubled per CSV rules. Confirmed against directories named with a colon, with a comma, with both, and with both plus a "; all four now verify clean, and the colon case fails on the previous code.

No unit test: the failure lives in an argument docker parses, so nothing below the process boundary observes it. The four end-to-end runs above are the evidence, and the reasoning is recorded in the code so the next reader does not "simplify" it back to either naive form.

All gates clean, 369 tests pass.

@ptr727

Copy link
Copy Markdown
OwnerAuthor

Suppressed comment from review 4844995179 confirmed and fixed in 99af2c7. This is the follow-up half I declined one round earlier, and the decline was wrong.

The finding is right on both counts. A database-backed run that skips every file decoded nothing yet still demanded Docker, and my own comment claimed "nothing to decode means nothing needs Docker" while the code only honoured that for a tree holding no media at all. The comment overclaimed relative to the code, which is the thing I have been fixing elsewhere in this branch.

Why the earlier decline was wrong. I argued it would mean "a full pass over the file list, including hashing every file, before the first container starts". That is not how the loop works. Batches are processed as they are chunked, so the first batch to find a candidate triggers the preflight, after about one batch of files rather than the whole tree. The exposure I was protecting against is bounded by a batch, which is seconds, not the hours I implied. I should have checked the loop instead of reasoning from a mental model of it.

The preflight is now a Lazy<Task> with ExecutionAndPublication, awaited by the first batch that has something to decode, so it runs exactly once and a run that decodes nothing never starts a container.

Verified with docker removed from PATH, all three cases:

1. first run, docker present, --db -> Verified 1, Skipped 0, exit 0
2. re-run, all cached, no docker -> Verified 0, Skipped 1, Failed 0, exit 0
3. uncached, no docker -> exit 1, "Verification requires the docker command
and the Immich image ..." intact

Case 2 is the one that fails on the previous commit. Case 3 confirms the fail-fast behaviour and the message survive the change, which was the property I was worried about losing.

All gates clean, 369 tests pass.

CopilotAI 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

Copilot reviewed 39 out of 39 changed files in this pull request and generated no new comments.

Suppressed comments (1)

PhotoCleaner/VerifyTask.cs:172

  • Unreadable files are only probed when no database is configured (_indexTask is null). With a database, IndexTask.IndexFileAsync can hit the size/mtime cache (Database.ResolveHashesAsync) and return cached hashes without ever opening the file, which means an unreadable file could be incorrectly treated as already-verified and skipped (or even verified by Docker running under different permissions). To keep the behavior consistent with the intent (“unreadable counts as Failed regardless of DB”), probe readability unconditionally before any skip/verify decision.
 // Without one nothing opens it before the container does.
// An unreadable file would then reach Immich and come back as unrenderable.
// That reports a tooling gap as damage, so probe here and let the per-file guard count it.
if (_indexTask is null)

CopilotAI 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

Copilot reviewed 39 out of 39 changed files in this pull request and generated no new comments.

Suppressed comments (1)

PhotoCleaner/CommandRunner.cs:30

  • CommandRunner logs "{CommandName} complete" for any exit code other than ExitCode.Failed. That includes ExitCode.Error (1), which represents a command that did not complete successfully, so the log message is misleading for callers troubleshooting failures.

Consider logging success only for ExitCode.Success, and logging an error (or at least warning) for ExitCode.Error / any other non-zero code.

 if (exitCode == ExitCode.Failed)
{
Log.Warning("{CommandName} complete with failures", commandName);
}
else
{
Log.Information("{CommandName} complete", commandName);
}

@ptr727

Copy link
Copy Markdown
OwnerAuthor

Suppressed comment from review 4845112782 confirmed and fixed in 7025c3c.

The premise checks out exactly as described. Database.ResolveHashesAsync returns the cached pair when FileSize and MtimeTicks match, without opening the file, and stat needs no read permission on the file itself. So an unreadable file carrying a matching row reached the decoder having never been opened by this tool, and my _indexTask is null condition rested on an assumption about the hash read that does not hold.

That matters because Immich runs as another user and may well read the file, so a gap in this tool's access came back as a verdict about the media, which is the outcome this branch has been closing off everywhere else.

Where the probe went, and why not simply unconditional. The comment suggested probing "before any skip/verify decision". Placing it at the end of the skip decision instead covers exactly the files that get judged, and costs nothing on the ignored, missing, and already-verified paths. That distinction is not cosmetic here: an incremental run over a large tree spends nearly all of itself on the skip path, and an unconditional probe would add an open per file to precisely the case --db exists to make cheap. A file that skips as already verified is not being judged, so its readability is not being claimed.

ExecuteAsync_UnreadableFileWithCachedHashes_CountsFailedNotInvalid covers it: index the file while readable so the row caches its hashes, remove all permissions, then run. It reports Failed 0 on the previous commit and Failed 1 now.

This is the third variant of one defect found across three rounds, each in a path the previous fix did not reach: no database at all, then the vanished-file case after the batch was assembled, now the cached-hash case. Worth naming, because the pattern is that any route to the decoder which skips opening the file will produce a media verdict for a tooling problem, and the fix only holds where every such route is covered.

All gates clean, 370 tests pass.

CopilotAI 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

Copilot reviewed 39 out of 39 changed files in this pull request and generated no new comments.

Suppressed comments (2)

PhotoCleaner/VerifyTask.cs:152

  • When file has no extension, Path.GetExtension(file) returns an empty string, so SkippedExtensionTracker ends up tracking "" and later logs Unknown file extension: '', which is confusing/noisy. Consider skipping tracking when the extension is empty (or mapping it to a clear placeholder).
 if (!MediaUtilities.SupportedExtensions.Contains(extension))
{
Log.Debug("Skipping non-media file: '{FilePath}'", file);
skippedExtensions.Track(extension);
tally.Ignored();

PhotoCleaner/TrashCommand.cs:157

  • This comment overstates the behavior: the loop can stop early on an invalid NextPage value (logging a warning) without throwing, which is a partial sync. Consider rewording so it doesn't imply there is no partial-success/partial-sync scenario.
 // A page either fetches or throws, so there is no partial-failure state to report.
return ExitCode.Success;

@ptr727

Copy link
Copy Markdown
OwnerAuthor

Both suppressed comments from review 4845224642 confirmed and fixed in 04f0e66, and the earlier readability finding is now handled in the shared path in 3e41038.

Empty extension tracking. Correct. A file with no extension tracks as an empty string, so the summary printed Unknown file extension: , a quoted nothing that reads as a bug in the message rather than a fact about the tree. Fixed in SkippedExtensionTracker rather than in VerifyTask, because all four commands feed the same tracker: ProcessTask, ImportTask, IndexTask, and VerifyTask each call Track(Path.GetExtension(...)). Fixing it at the call site named in the comment would have left three commands printing the blank. No file among the 264,044 verified carried this, so it is latent noise rather than an observed defect.

The trash comment overstating. Correct, and the consequence is larger than the wording. The loop does stop early on a NextPage value it cannot use, warns, sets hasMore = false, and falls through to return ExitCode.Success. So a partial sync reports success, and the trash database is left short of the server. The comment now says exactly that.

I have not changed trash to return a failure in that case, because that is a contract change rather than a defect fix: trash is documented as having no per-item failure concept and keeping 0 and 1 only, and 2 means "completed with per-file failures". A partial sync is arguably that, and it matters downstream, since import --trashdb and process --trashdb use the database to skip files, so a short database silently re-imports assets that were trashed. That is the maintainer's call and I have raised it with him rather than deciding it inside a review thread.

Readability, from the previous round, now shared. The maintainer pointed out that reading attributes needs no permission on content, and that this affects every command rather than verify alone. Measured against an unreadable file before the change: process reported Failed 0 and exited 0, and import reported Imported 1. Both claimed success over a file they never read. index was already correct, because it hashes and therefore opens. The probe now lives in the shared exiftool call, so process and import inherit it, and verify calls the same helper for the path that hands the file to Immich instead of reading it here.

Keeping the probe ahead of exiftool is what makes it correct. exiftool reports a file it cannot open and a file it cannot parse identically, as an error alongside well formed JSON, so keying on that field instead would have turned damaged media into a tooling failure. I tried that first and it broke two existing tests that assert garbage bytes count Invalid, which was the design telling me the distinction matters. Readable garbage still counts Invalid; only an unreadable file counts Failed.

All gates clean, 371 tests pass.

CopilotAI 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

Copilot reviewed 40 out of 40 changed files in this pull request and generated no new comments.

Suppressed comments (1)

PhotoCleaner/TrashCommand.cs:157

  • The comment claims there is “no partial-failure state to report”, but this loop inserts hashes page-by-page inside the pagination loop. If a later page fetch/insert throws after earlier pages succeeded, the command will exit with ExitCode.Error but the Trash DB may already be partially updated. The comment should reflect that partial updates are possible on mid-run failures.
 // A page either fetches or throws.
// Pagination also stops early on a NextPage value it cannot use, warned above.

CopilotAI 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

Copilot reviewed 40 out of 40 changed files in this pull request and generated no new comments.

Suppressed comments (1)

.github/copilot-instructions.md:366

  • The CLI now includes the new verify subcommand, but this bullet still documents only five subcommands and omits verify, which makes the command list here inconsistent with CommandLine.cs and the rest of the PR docs.
- **Five subcommands**: `process`, `undo`, `import`, `index`, `trash`, each with their own option set

CopilotAI 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

Copilot reviewed 40 out of 40 changed files in this pull request and generated no new comments.

Suppressed comments (1)

PhotoCleanerTests/VerifyTaskTests.cs:486

  • ImmichImageAvailable() redirects stdout/stderr for docker image inspect but never reads from those streams. When a process writes enough output to fill the redirected buffer, WaitForExit can deadlock and hang the test run. Since the test only needs the exit code, avoid redirecting these streams (or read them asynchronously).

@ptr727

Copy link
Copy Markdown
OwnerAuthor

Suppressed comment from review 4845477898 confirmed and fixed in the commit above.

The pattern is wrong exactly as described: both streams were redirected and neither was read, so the child can fill a pipe buffer and block on the write while WaitForExit blocks on the child.

Measured before changing it, since "can deadlock" and "does deadlock" are different claims: docker image inspect for this image emits 5,662 bytes against a pipe buffer of about 64 KB, so nothing has hung and nothing was going to hang today. That margin belongs to the image manifest though, not to this code, and it grows with layers and environment. It is a latent hazard rather than an observed failure.

Fixed on both sides. Both streams are read before the wait, and the probe now passes --format "" because only the exit code is wanted, so there is almost nothing to buffer in the first place. I kept the redirection rather than dropping it as the comment suggested, since without it the manifest prints into the test output on every gated test.

Confirmed the probe still discriminates: exit 0 for the present image, exit 1 for a name that does not exist, and the 23 VerifyTaskTests still run rather than skip, which is what would silently regress if the probe started returning false.

All gates clean, 372 tests pass.

CopilotAI 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

Copilot reviewed 40 out of 40 changed files in this pull request and generated no new comments.

@ptr727

Copy link
Copy Markdown
OwnerAuthor

Auditing every review before merging turned up three suppressed findings that were never answered, across reviews 4845161481, 4845319556, and 4845366389. All three are now fixed in the commit above. A fourth, 4839450732, was the batch.Count double-count, already fixed in 21649ee under a later review that raised the same thing.

  • CommandRunner logged completion for any code other than Failed, so Error would have reported the command complete. Each outcome is now named. Nothing returns Error from the work itself today and the catch blocks return it directly, so this guards a future caller rather than a live defect, and the comment says so rather than implying it was broken.
  • The architecture map still said "Five subcommands" and omitted verify, having been written before this branch added it. Now six, with verify listed. Worth noting the file already listed verify correctly in the CommandLine.cs bullet, so it contradicted itself.
  • The trash comment covered pagination stopping early but not a page that throws, which leaves the database short as well while keeping the pages before it, and exits Error from the handler rather than reaching the return. The comment now covers both.

Separately, while running the final check the five Docker-gated VerifyTaskTests began skipping. That was not a code regression: the :release tag had disappeared from the local image store, leaving only :v3 on the same image id. Pulling it back restored the tag with no new layers, and all 372 tests run and pass again. Recording it because a gate that silently starts skipping is exactly the kind of thing that reads as green while covering nothing, and the skip count is the only signal.

All gates clean, 372 tests pass, zero unresolved threads.

CopilotAI 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

Copilot reviewed 40 out of 40 changed files in this pull request and generated no new comments.

Sign up for freeto 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.

2 participants

@ptr727