Utility to prepare photos and videos for import into photo managers.
- Source Code: GitHub for source, issues, and the CI/CD pipelines.
- Versioned Releases: GitHub Releases for pre-compiled executables for Windows, Linux, and macOS.
- Docker Images: Docker Hub for container images with all tools pre-installed.
Version 1.1:
Summary:
- Added
verifycommand to detect possibly corrupt images, e.g. Immich fails to generate a preview image. - Added
-validateto exiftool to report metadata warnings and errors.
Breaking: commands now exit
2when they complete with per-file failures.
See Release History for complete release notes and older versions.
- Overview
- Usage
- Processing Flow
- Undo Flow
- Import Flow
- Trash Flow
- Verify Flow
- Supported File Types
- Docker
- Workflow Example
- Questions or Issues
- Development Environment Setup
PhotoCleaner analyzes and transforms media files through a validation pipeline that:
- Renames mismatched extensions: Corrects file extensions that do not match the actual file
content (MIME type), normalizes to the preferred extension (e.g.
.jpeg->.jpg), and strips compound extensions (e.g.photo.heic.jpg->photo.jpg). - Renames mixed-case extensions: Converts uppercase or mixed-case extensions to lowercase
(e.g.
.JPG->.jpg). - Handles Live Photos: Removes Apple Live Photo video components. Videos <= 1s are always
removed. Videos <= 4s with a candidate companion image (same basename, or basename with
_hevcsuffix stripped) are removed when both files share the sameContentIdentifierEXIF tag. Longer videos with a matching image trigger a warning but are kept. - Converts video formats: Remuxes MTS, M2TS, and MKV to MP4, re-encodes WMV, AVI, 3GP, and
GIF to MP4 (H.264/AAC), and re-encodes PCM audio to AAC in MOV and MP4 files while preserving
the video stream. All source metadata (including
ContentIdentifierand other QuickTime tags) is copied to the converted file usingexiftool -TagsFromFile. - Imports into date folders (via
importcommand): Copies (default) or moves supported media files from source directories intooutpath/date/filenameusing EXIF date metadata. Falls back to a deterministic0001/01bucket when no date is found. Supports a custom--formatstring (defaultyyyy/MM/dd) validated as a date-only pattern. SQLite deduplication via--db(Import.db) tracks source file hashes keyed by source path so re-runs skip already-imported files.--tagpathwrites the source sub-directory path components asXMP:Subjecttags on the destination file.--tagsapplies explicit comma-separatedXMP:Subjecttags to every imported file.--datepathinfers and writes EXIF/QuickTime creation dates from filenames or directory path structures when metadata is absent (opt-in, applied before the file is moved to a date-based directory so the source path is still available). - Syncs Immich trash hashes (via
trashcommand): Connects to an Immich server via its REST API, fetches all trashed asset checksums (SHA-1), and stores them in a local SQLite database. This trash DB can then be used withimport --trashdbto skip files that were already imported and trashed in Immich, and withprocess --trashdbto delete files trashed in Immich after upload, preventing re-import of known duplicates. - Verifies files render in Immich (via
verifycommand): Answers whether a file will survive Immich's preview generation, which metadata checks cannot predict. Immich's own thumbnail pipeline is run inside theimmich-serverimage, so the verdict is the one Immich will reach rather than an approximation of it. That decoder is the sole authority: PhotoCleaner does not parse container formats itself, because a format it did not recognize would be indistinguishable from a damaged one. Docker is required. Nothing is modified, andverifyonly ever reads. - Warns on DNG version: Flags DNG files with a format version newer than v1.4 that may not render correctly in older applications.
Files that are renamed or converted are re-queued through the pipeline so every transformation
is validated. Original files are preserved with a .bak extension before any modification
(unless --skipbackup is used). The application processes files in parallel and provides
detailed logging of all operations.
$> PhotoCleaner --help
Description:
PhotoCleaner - Utility to prepare photos and videos for import into photo managers.
Usage:
PhotoCleaner [command] [options]
Commands:
process Process media files
undo Undo media file processing
import Import media files into date-based subdirectories
index Index files into the database for deduplication tracking
trash Sync trashed asset hashes from Immich
verify Verify that media files can be rendered by Immich
Options:
--loglevel <Debug|Error|Fatal|Information|Verbose|Warning> Set the log level [default: Information]
--logfile <logfile> Write logs to the specified file
--logclear Clear the log file before writing
-?, -h, --help Show help and usage information
--version Show version information
$> PhotoCleaner process --help
Description:
Process media files
Options:
--path <path> (REQUIRED) The media directory path
--dryrun Perform a dry run without making changes
--threads <threads> Number of parallel threads [default: 4]
--skipbackup Skip creating backup files (disables undo)
--deleteempty Delete empty subdirectories under the target directory after the command completes
--db <db> SQLite database file for file state tracking
--rehash Force rehashing of all files, ignoring size/mtime cache
--duration <duration> Maximum duration in seconds below which a video is considered a short clip and deleted [default: 1]
--reprocess Re-run every file even if the database marks it done
--trashdb <trashdb> SQLite database with Immich trash hashes (read-only)
$> PhotoCleaner undo --help
Description:
Undo media file processing
Options:
--path <path> (REQUIRED) The media directory path
--dryrun Perform a dry run without making changes
$> PhotoCleaner import --help
Description:
Import media files into date-based subdirectories
Options:
--path <path> (REQUIRED) The media directory path
--dryrun Perform a dry run without making changes
--threads <threads> Number of parallel threads [default: 4]
--outpath <outpath> (REQUIRED) Output directory for organized files
--format <format> Date format for output subdirectory names; use '/' to create nested subdirectories (e.g. yyyy/MM/dd) [default: yyyy/MM/dd]
--deleteempty Delete empty subdirectories under the target directory after the command completes
--move Move files instead of copying (default: copy)
--tagpath Apply path sub-directory components as XMP Subject tags to the organized file
--tags <tags> Comma-separated XMP Subject tags to apply to every organized file (e.g. "vacation,family")
--datepath Set missing EXIF creation date from file path
--db <db> SQLite database file for file state tracking
--rehash Force rehashing of all files, ignoring size/mtime cache
--trashdb <trashdb> SQLite database with Immich trash hashes (read-only)
--skipdb <skipdb> SQLite database with indexed files to be skipped (read-only)
$> PhotoCleaner index --help
Description:
Index files into the database for deduplication tracking
Options:
--path <path> (REQUIRED) The media directory path
--threads <threads> Number of parallel threads [default: 4]
--db <db> (REQUIRED) SQLite database file for file state tracking
--rehash Force rehashing of all files, ignoring size/mtime cache
--processed Mark newly inserted rows as already processed (use when seeding a Process.db from existing files)
$> PhotoCleaner trash --help
Description:
Sync trashed asset hashes from Immich
Options:
--url <url> (REQUIRED) Immich server URL (e.g. http://immich:2283)
--apikey <apikey> Immich API key (mutually exclusive with --apikey-file)
--apikey-file <apikey-file> File containing the Immich API key (mutually exclusive with --apikey)
--trashdb <trashdb> (REQUIRED) SQLite database to store Immich trash hashes
$> PhotoCleaner verify --help
Description:
Verify that media files can be rendered by Immich
Options:
--path <path> (REQUIRED) The media directory path
--threads <threads> Number of parallel threads [default: 4]
--db <db> SQLite database file for file state tracking
--rehash Force rehashing of all files, ignoring size/mtime cache
--reprocess Re-run every file even if the database marks it done
Option notes:
-
--path: must point to an existing directory. Accepts exactly one directory per command invocation. -
--threads: defaults tomin(CPU count, 4). Must be> 0and<= CPU count. -
--skipbackup: opt-in (processonly). Skips all.bakfile creation. Theundocommand cannot reverse a run made with this flag. -
--outpath: required forimport. Target directory (created on demand). -
--format: optional (importonly). A C# date format string used to name date subdirectories (default"yyyy/MM/dd"). Must be date-only, so time components are rejected. Files with no EXIF date land in a"0001/01/01"fallback bucket. -
--deleteempty: optional (import,process). After the command completes, deletes empty child subdirectories from the target directory (deepest first). Forimportthe target is--outpath, and forprocessit is--path(which is operated on in-place). The target root itself is never deleted. Useful for cleaning up directory trees left behind afterprocessdeletes files (live photos, originals when--skipbackup) or after pruning organized output. -
--move: optional (importonly). Moves files instead of copying. Default behavior is to copy, which preserves the source files. Use--movewhen the source directory is temporary. -
--tagpath: optional (importonly). Splits the source sub-directory path relative to--pathinto tokens and writes each token as anXMP:Subjecttag on the destination file using exiftool. Files at the root of--pathreceive no tags. Tags are applied with a remove-then-add pattern (-XMP:Subject-= / -XMP:Subject+=) so existing tags are preserved and duplicates are not created. Only file types that support XMP writes are tagged. -
--tags: optional (importonly). A comma-separated list ofXMP:Subjecttags applied to every organized file (e.g.--tags "vacation,family trip,2018"). Tags are applied using the same remove-then-add pattern as--tagpath. Can be combined with--tagpath, and both sets of tags are merged. Only file types that support XMP writes are tagged. -
--datepath: optional (importonly). When a file has no embedded creation date, infers one from the filename or directory path structure (viaDateFromPath) and writes it to the destination file before restoring mtime. Opt-in because writing to files is destructive and the source path context is only available duringimport(before files move to date-based directories). -
--db <path>: optional forimportandprocess, required forindex. Path to a SQLite database file. Uses a singlefilestable (pathPRIMARY KEY,sha256,sha1,file_size,mtime_ticks,is_processed). The schema is the same for every command, but the meaning of thepathcolumn depends on which command writes the row, so each pipeline stage gets its own DB file:- Import.db (
import --db Import.db): rows are keyed by SOURCE path.sha256is the source content hash. Dedup query: skip a source whosesha256is already in this DB. - Process.db (
process --db Process.db): rows are keyed by DEST path.sha256is the current dest content hash.is_processed = 1means the dest file's pipeline ran to completion.--reprocessignores the flag.
The DB file is created automatically on first use. The two stages MUST use separate DB files: if
importandprocessshared one DB,processwould overwrite the source-content hashes thatimportwrote (becauseimportmutates the dest file via XMP tag injection, so the dest hash diverges from the source hash, andprocessthen re-hashes the dest and clobbers the row). This was a real bug, and the per-stage layout is the fix. - Import.db (
-
--trashdb <path>: required fortrash, optional forimportandprocess. Path to a SQLite database with Immich trash hashes.- For
trash: hashes are fetched from the Immich API and written to the database. - For
import: files matching a trash hash are skipped (read-only). This is the durable "do not re-import" record beyond Immich's own ~30-day trash retention. Without it, a file the user trashed > 30 days ago can come back the next time the downloader re-fetches it, because Immich no longer has the hash to deduplicate against on re-upload. Limitation:importcompares the source-file SHA-1 against the trash DB. Whenimportrewrites the destination via--tags,--tagpath, or--datepath, the dest file's SHA-1 differs from the source SHA-1. The hash Immich stored on a prior upload (and therefore the hash that ends up in the trash DB) is the rewritten dest SHA-1, so the source-side trash check will not match. Those files are caught byprocess --trashdbinstead, which hashes the dest file directly. - For
process: files matching a trash hash are deleted from disk and from Process.db. This cleans up files trashed in Immich after upload, before the nextimmich-cliupload would re-upload them. Also acts as the safety net for files thatimport --trashdbcould not match because of the source-vs-dest SHA-1 drift described above.
- For
-
--skipdb <path>: optional forimport. Path to a SQLite database with indexed files to be skipped (read-only). Files matching a record in this DB are skipped without being recorded. Use to skip files already present in another collection. -
--url <url>: required fortrash. The Immich server URL (e.g.http://immich:2283). -
--apikey <key>/--apikey-file <path>: the Immich API key fortrash. Supply it via exactly one of these two mutually exclusive options (one is required). Create the key in Immich under Account Settings > API Keys.--apikeypasses the key inline, while--apikey-filepoints to a file whose trimmed contents are the key, keeping the secret out of shell history and process arguments. The file must exist and be non-empty. -
--rehash: optional (process,import,index). Forces SHA-256 recomputation for every file, bypassing the size/mtime cache. SHA-1 is also recomputed when--trashdbis in use. Use when file content may have changed without the modification timestamp being updated. -
--duration: optional (processonly). Overrides the short-video deletion threshold (default1.0seconds). Videos in a live-photo-compatible format whose duration is <= this value are always deleted. Must be> 0. -
--reprocess: optional (process,verify). When set, ignores theis_processedflag in the database and processes every file regardless of prior run history. Useful after changing pipeline settings (e.g.--duration) without wiping the database. -
--processed: optional (indexonly). Marks newly inserted rows withis_processed = 1. Use this when seeding a Process.db from existing files so a subsequentprocessrun treats them as already processed and only touches new arrivals. The flag does not flip the bit on rows that already exist in the DB.
Every command uses the same three codes, so a pipeline can branch on the result without parsing logs:
| Code | Meaning |
|---|---|
0 |
Success. The command completed and every file succeeded. |
1 |
Error. The command could not complete: unhandled exception, fatal configuration error, cancellation, or a failed verify preflight. |
2 |
Completed with failures. The command ran to completion, but one or more files failed or failed verification, or trash synced only part of the server. |
The distinction between 1 and 2 matters most for verify. A 1 means the check itself could
not run at all, because Docker was unreachable or the Immich image could not be prepared, and it
says nothing about any file. A 2 means the run completed and one or more files were invalid or could
not be verified, the latter covering a file that could not be read or that no verdict came back
for. A script must never treat an infrastructure failure as a verdict on the collection, and should
read the Invalid and Failed counts to tell a bad file from a gap in coverage.
trash exits 2 when pagination stops early, which leaves the trash database holding fewer
hashes than the server. That database is used by import --trashdb and process --trashdb to skip
files, so a short one silently re-imports assets that were trashed, and a pipeline gating on the
exit code should not go on to upload against it.
# Preview what changes would be made without modifying files
PhotoCleaner process --path "${HOME}/Photos" --dryrun
# Process with 8 parallel threads and log to file
PhotoCleaner process --path "${HOME}/Photos" --threads 8 --logfile /tmp/photocleaner.log
# Process without creating backup files (faster, but undo is not possible)
PhotoCleaner process --path "${HOME}/Photos" --skipbackup
# Undo all processing changes in a directory (restores .bak files)
PhotoCleaner undo --path "${HOME}/Photos"
# Preview what undo would do without modifying files
PhotoCleaner undo --path "${HOME}/Photos" --dryrun
# Import (copy) media into date-based subdirectories - source files are kept
PhotoCleaner import --path "${HOME}/Photos" --outpath "${HOME}/Organized"
# Import with a custom date format (creates e.g. 2024/06/2024-06-15/ subdirectories)
PhotoCleaner import --path "${HOME}/Photos" --outpath "${HOME}/Organized" --format "yyyy/MM/yyyy-MM-dd"
# Preview what import would do without changing anything
PhotoCleaner import --path "${HOME}/Photos" --outpath "${HOME}/Organized" --dryrun
# Move instead of copy (source files are removed)
PhotoCleaner import --path "${HOME}/Photos" --outpath "${HOME}/Organized" --move
# Import and remove empty subdirectories from the target afterward
PhotoCleaner import --path "${HOME}/Photos" --outpath "${HOME}/Organized" --deleteempty
# Import with path-based XMP:Subject tagging (sub-directory names become tags)
PhotoCleaner import --path "${HOME}/Photos" --outpath "${HOME}/Organized" --tagpath
# Import inferring missing EXIF dates from the source path
PhotoCleaner import --path "${HOME}/Photos" --outpath "${HOME}/Organized" --datepath
# Import with explicit XMP:Subject tags applied to every file
PhotoCleaner import --path "${HOME}/Photos" --outpath "${HOME}/Organized" --tags "vacation,family trip"
# Import with both path tagging, explicit tags, and date inference
PhotoCleaner import --path "${HOME}/Photos" --outpath "${HOME}/Organized" --tagpath --tags "2018" --datepath
# Import with deduplication: only copy files not already in the database
PhotoCleaner import --path "${HOME}/Photos" --outpath "${HOME}/Intermediate" --db /data/photos.db
# Index a directory into the database (stand-alone, no other processing)
PhotoCleaner index --path "${HOME}/Source" --db /data/dedup.db
# Re-index forcing hash recomputation (useful after file content changes without mtime update)
PhotoCleaner index --path "${HOME}/Source" --db /data/dedup.db --rehash
# Sync Immich trash hashes into a local database
PhotoCleaner trash --url http://immich:2283 --apikey YOUR_API_KEY --trashdb /data/trash.db
# Sync using an API key read from a file (keeps the secret out of shell history/process args)
PhotoCleaner trash --url http://immich:2283 --apikey-file /secrets/immich_api_key.txt --trashdb /data/trash.db
# Import and skip files that were trashed in Immich (prevents re-import)
PhotoCleaner import --path "${HOME}/Photos" --outpath "${HOME}/Organized" --db /data/photos.db --trashdb /data/trash.db
# Import and skip files already in another collection (read-only reference)
PhotoCleaner import --path "${HOME}/Photos" --outpath "${HOME}/Organized" --skipdb /data/existing-collection.db
# Verify that Immich can render every file, before uploading
PhotoCleaner verify --path "${HOME}/Intermediate" --db /data/verify.db
# Full workflow with Immich trash integration
PhotoCleaner trash --url http://immich:2283 --apikey $IMMICH_KEY --trashdb /data/trash.db
PhotoCleaner import --path "${HOME}/iCloud" --outpath "${HOME}/Intermediate" --db /data/photos.db --trashdb /data/trash.db
PhotoCleaner process --path "${HOME}/Intermediate" --db /data/process.db
PhotoCleaner verify --path "${HOME}/Intermediate" --db /data/verify.db- File enumeration: Recursively scans all specified directories.
- Case conflict detection: Identifies files with the same name but different casing that would collide on case-insensitive file systems, then renames them before processing.
- Per-file validation pipeline (runs in parallel, stops on first action per file):
0. Act on the exiftool
-validateverdict, which rides along with the metadata read thatprocessalready performs and so costs nothing extra. A file exiftool reports errors on is marked invalid and no further step touches it. Warnings are logged at debug level and nothing more: measured across a real collection, roughly three quarters of perfectly healthy files carry at least one (odd IFD offsets, non-standard maker note tags, short IPTC fields), so failing on warnings would condemn most of a library. This is a cheap net for a rare case, not a substitute for theverifycommand.- Rename to canonical MIME extension, correcting mismatches and stripping compound extensions.
- Rename mixed-case extension to lowercase.
- Delete short or Live Photo video clips. Videos <= 1s are always deleted, and videos <= 4s
with a candidate companion image (direct name match or
_hevc-suffix match) are deleted when both files share a matchingContentIdentifiertag. - Convert legacy or incompatible video formats to MP4:
- Remux: MTS, M2TS, MKV (stream copy, no quality loss)
- Re-encode: WMV, AVI, 3GP, GIF (H.264 CRF 21 / AAC 128k)
- Re-encode PCM audio: MOV, MP4 with PCM audio (AAC 128k, video stream copied)
- After every conversion: all source metadata copied to output via
exiftool -TagsFromFile
- Warn on DNG version > v1.4.
- Reprocess loop: Any file that was renamed or converted is re-queued until stable.
- Results summary: Reports counts of failed, invalid, modified, and successfully processed
files, and lists any unrecognized file extensions. Exits
2if anything failed or was invalid.
The import command applies the same exiftool validation, skipping any file that reports errors
rather than pulling it into the collection.
Every file modification or deletion made by process creates a .bak backup alongside the
original: the first backup is X.bak, and if that already exists (from a prior run) the next is
X.bak1, then X.bak2, etc. The undo command reverses all processing by scanning the given
directories for backup files and applying a two-pass algorithm:
- Identify derived files - an output file is "derived" (not original) when either:
- a numbered backup (
.bak1,.bak2, ...) exists for it (processed more than once), or - it is a
.mp4file whose stem has a non-.mp4primary backup in the same directory (it was the conversion output).
- a numbered backup (
- Restore or delete:
- Derived base: delete the current file and all its backup files.
- Non-derived base: delete the current file if it exists (overwritten in-place), rename
X.bak->Xto restore the original. The converted output is located via theX.bak.outcompanion file written at conversion time (handles uniquified names likestem_1.mp4). If no companion exists, falls back to deletingstem.mp4when present and untracked (legacy single-run heuristic).
Known limitation: extension renames that target a filename that did not previously exist
(e.g. photo.JPEG -> photo.jpg when photo.jpg was absent) create no backup and cannot be
undone by this command.
The import command copies (default) or moves every supported media file in the source
directories to outpath/date/filename:
- Date inference (opt-in via
--datepath): when no embedded creation date is found, infers one from the source file path usingDateFromPath(filename patterns like20210502_200152.jpgor directory structures like2021/05/02/). The inferred date is written to the destination file (for supported types) and used for subdirectory placement. Applied before the file is moved so the original path is still available. - Skip checks (opt-in): before any file operation, the source file is hashed (SHA-256;
SHA-1 is also computed when
--trashdbis provided) and checked against up to three databases in order:--trashdb: if the file matches a hash in the Immich trash DB, the file is skipped (counted as "trashed in Immich").--skipdb: if the file matches a record in the reference DB, the file is skipped (counted as "skipped by reference"). This is a read-only check, so no records are written.--db(Import.db): if the source SHA-256 is already present (from a previous import run), the file is skipped (counted as "skipped"). Otherwise, the file is copied/moved and a record is inserted keyed by the SOURCE path (not the dest path) with the source hash, size, and mtime. This is the load-bearing detail: rows in Import.db identify sources, not destinations, so subsequent runs ofprocess/indexagainst a separate Process.db cannot clobber the dedup key. The DB file is created automatically on first use.
- Date resolution: reads EXIF metadata via
exiftool. UsesEXIF:DateTimeOriginalorQuickTime:CreateDate(whichever is set). Falls back toDateTime.MinValuewhen no date is found. Those files land in a"0001/01/01"bucket (with the defaultyyyy/MM/ddformat), making undated files easy to locate and handle manually. - Subdirectory naming: the date is formatted using
--format(default"yyyy/MM/dd"). The format is validated at startup, and time components are rejected. - Copy or move: by default files are copied and the source is preserved. Pass
--moveto remove the source file after a successful copy. - Tagging:
--tagpathsplits the source sub-directory path relative to--pathinto tokens and writes each as anXMP:Subjecttag.--tagsapplies explicit comma-separated tags to every file. Both can be combined, and tags are merged and deduplicated. Applied after copy/move, before mtime restore. Files at the root of--pathreceive no path tags. - Collision handling: if a file with the same name already exists in the destination,
_1,_2, ... suffixes are appended (e.g.photo_1.jpg). A warning is logged. - Unsupported files: non-media files are counted as ignored and left in place.
- Empty directory cleanup (opt-in via
--deleteempty): after all files are imported, iterates--outpathand deletes empty child subdirectories deepest-first. The target root itself is never deleted.
Run with --dryrun to preview the planned operations without touching the file system.
The trash command syncs trashed asset checksums from an Immich server into a local SQLite
database. This enables the import and process commands to skip or delete files that
were already imported and trashed in Immich.
- Connect: authenticates to the Immich server using the
--urland the API key from either--apikeyor--apikey-file. - Fetch: paginates through
POST /api/search/metadatawith atrashedAfterfilter to retrieve all trashed assets. Each page returns up to 1000 assets. - Store: converts each asset's Base64-encoded SHA-1 checksum to lowercase hex and inserts
it into the
trash_hashestable usingINSERT OR IGNORE. The operation is idempotent - runningtrashagain safely adds any newly trashed assets. - Report: logs the total number of fetched assets, newly inserted hashes, and total hash count in the database.
The trash database is append-only. If an asset is restored (un-trashed) in Immich, its hash
remains in the database. Delete the database file and re-run trash to rebuild from scratch.
The verify command answers one question: will Immich be able to generate a preview for this
file? It exists because a file can be byte-complete, pass every other check, upload successfully,
and then fail thumbnail generation forever. Metadata inspection cannot see this, because the file
reports as a perfectly clean HEIC or DNG, so the only reliable answer comes from running the decoder
Immich runs.
verify is a standalone pipeline step rather than an option on process, so the calling script
chooses where to run it and whether a failure should stop the pipeline or merely be recorded.
-
Partition: non-media files are ignored. With
--db, files already verified and unchanged since are skipped, so a repeat run over a large collection is cheap.Give
verifyits own database file. It records the verified state in the sameis_processedcolumn thatprocesswrites, so pointing--dbat aProcess.dbmakesverifyskip every file as "already verified" when they were only processed. Nothing detects this, so use a separateVerify.db, as the examples below do. -
Decode pass (requires Docker). Every file is handed to Immich's own
MediaRepositoryrunning inside theimmich-serverimage, using the samegenerateThumbnail, the same libvips build, the same libheif and libraw versions, and for RAW the same embedded-preview extraction. Paths are streamed in batches over stdin so container startup is paid once per batch rather than once per file. The media directory is mounted read-only at the fixed container path/photocleaner, and every path is translated onto it before being sent in, so a host path that is not a valid container path still works.The decoder is the only judge of a file's health. PhotoCleaner deliberately carries no container parser of its own, because such a parser condemns whatever it fails to understand, and an unfamiliar but valid format is indistinguishable from a damaged one from the inside. A file that cannot be read, or that vanishes mid-run, counts as failed rather than invalid, since neither is evidence of damage.
-
Report: logs each rejection with the decoder's own message, then a summary. Exits
2if any file is invalid or any file failed.
Nothing is modified, moved, or deleted. verify only ever reads.
Because the decode pass calls Immich's own compiled code rather than reimplementing its pipeline,
it tracks Immich's behavior across releases automatically. It runs docker directly, so it must
be run somewhere Docker is available, and is not supported from inside PhotoCleaner's own
container. There is no offline mode, because the decoder is the whole check.
Before any file is judged, the command runs a preflight against the image. If Docker is
unreachable or the image cannot be prepared, it exits 1 without marking a single file invalid.
- Images: ARW, CR2, DNG, HEIC, HEIF, JPEG, JPG, NEF, ORF, PNG, PSD, RW2, TIF, TIFF
- Videos: 3GP, AVI, GIF, M2TS, MKV, MOV, MP4, MTS, WMV
Build the image from the project root:
docker build -f Docker/Dockerfile -t photocleaner:latest .Mount host directories as volumes so the container can access media files.
All --path, --outpath, --db, --trashdb, and --skipdb arguments refer to paths inside the container:
# Show help (default when no arguments are passed)
docker run --rm photocleaner:latest
# Process media files
docker run --rm -v /host/photos:/data \
photocleaner:latest process --path /data
# Dry run - preview without modifying files
docker run --rm -v /host/photos:/data \
photocleaner:latest process --path /data --dryrun
# Undo processing
docker run --rm -v /host/photos:/data \
photocleaner:latest undo --path /data
# Import into date-based subdirectories (copy, source preserved)
docker run --rm \
-v /host/photos:/source \
-v /host/organized:/organized \
photocleaner:latest import --path /source --outpath /organized
# Import with deduplication DB (mount a persistent directory for the DB file)
docker run --rm \
-v /host/photos:/source \
-v /host/organized:/organized \
-v /host/db:/db \
photocleaner:latest import --path /source --outpath /organized --db /db/photos.db
# Index a directory into the database
docker run --rm \
-v /host/source:/source \
-v /host/db:/db \
photocleaner:latest index --path /source --db /db/dedup.db
# Sync Immich trash hashes
docker run --rm \
-v /host/db:/db \
photocleaner:latest trash --url http://immich:2283 --apikey YOUR_API_KEY --trashdb /db/trash.db
# Import with trash skip (prevents re-importing files trashed in Immich)
docker run --rm \
-v /host/photos:/source \
-v /host/organized:/organized \
-v /host/db:/db \
photocleaner:latest import --path /source --outpath /organized --db /db/photos.db --trashdb /db/trash.dbRun kei to download photos from iCloud:
kei keeps its settings in a TOML file rather than on the command line, so the same configuration
serves the one-shot and the long-running forms. A minimal config.toml:
[auth]
username = "your@icloud.email"
[download]
directory = "/photos"
folder_structure = "%Y/%m/%Y-%m-%d"
[filters]
media = ["photos", "videos", "live-photos"]
[photos]
raw_policy = "prefer-raw"
[watch]
interval = 86400Authenticate once, interactively, so the session is stored in the data directory:
#!/bin/bash
set -Eeuo pipefail
docker run -it --rm --name kei \
-v /data/appdata/kei/config:/config \
-v /data/media/icloud:/photos \
-e KEI_DATA_DIR=/config \
ghcr.io/rhoopr/kei:latest \
kei loginThen sync on demand:
#!/bin/bash
set -Eeuo pipefail
docker run -it --rm --name kei \
-v /data/appdata/kei/config:/config \
-v /data/media/icloud:/photos \
-e KEI_DATA_DIR=/config \
ghcr.io/rhoopr/kei:latest \
kei sync \
--config /config/config.toml \
--recent 30d
# --dry-run to preview without writingOr run it as a service that keeps mirroring on the [watch] interval:
services:
kei:
image: ghcr.io/rhoopr/kei:latest
container_name: kei
restart: unless-stopped
environment:
- TZ=America/Los_Angeles
- KEI_DATA_DIR=/config
volumes:
- /data/media/icloud:/photos
- /data/appdata/kei/config:/config
secrets:
- icloud_password
command:
- kei
- service
- run
- --config
- /config/config.toml
- --password-file
- /run/secrets/icloud_passwordRun PhotoCleaner to sync Immich trash hashes (optional, prevents re-importing trashed files):
#!/bin/bash
set -Eeuo pipefail
docker run --rm \
-v /data/media:/db \
photocleaner:latest \
trash \
--url http://immich:2283 \
--apikey $IMMICH_API_KEY \
--trashdb /db/trash.dbRun PhotoCleaner to import new photos:
Copy only new files (not already in the DB and not trashed in Immich) from the download directory to an intermediate directory, without touching the downloaded originals:
#!/bin/bash
set -Eeuo pipefail
docker run --rm \
-v /data/media/icloud:/icloud \
-v /data/media/intermediate:/intermediate \
-v /data/media:/db \
photocleaner:latest \
import \
--path /icloud \
--outpath /intermediate \
--db /db/photos.db \
--trashdb /db/trash.db \
--threads 4Run PhotoCleaner to process the intermediate photos:
#!/bin/bash
set -Eeuo pipefail
docker run --rm \
-v /data/media/intermediate:/intermediate \
photocleaner:latest \
process \
--path /intermediate \
--threads 4Run PhotoCleaner to verify Immich can render the photos:
Run this on the host rather than inside the PhotoCleaner container: the decode pass invokes
docker to run Immich's own decoder, and Docker-in-Docker is not supported. Capture the exit
code instead of letting set -e abort, so the upload can be skipped while the run still reports
cleanly:
#!/bin/bash
set -Eeuo pipefail
rc=0
PhotoCleaner verify \
--path /data/media/intermediate \
--db /data/media/verify.db \
--threads 4 || rc=$?
case $rc in
0) echo "All files verified" ;;
2) echo "Invalid files found - skipping upload, review the log" >&2; exit 2 ;;
*) echo "Verification could not run (exit $rc) - this says nothing about the files" >&2; exit "$rc" ;;
esacRun Immich CLI to import photos into Immich:
#!/bin/bash
set -Eeuo pipefail
docker run -it --rm --name immichcli \
-v /data/media/Test:/upload:ro \
-e IMMICH_INSTANCE_URL=https://your.immich.server/api \
-e IMMICH_API_KEY=yourapikey \
-e TZ=America/Los_Angeles \
ghcr.io/immich-app/immich-cli:latest \
upload \
--recursive \
--concurrency 4 \
--ignore "**/*.bak*" \
--ignore "**/*.xmp" \
--ignore "**/*.tmp" \
--ignore "**/.DS_Store" \
--ignore "**/Thumbs.db" \
--ignore "**/@eaDir/**" \
--ignore "**/._*" \
/uploadRun immich-go to import photos into Immich:
#!/bin/bash
set -Eeuo pipefail
immich-go upload from-folder --server=https://your.immich.server \
--api-key=yourapikey \
--manage-raw-jpeg=StackCoverJPG \
--manage-heic-jpeg=StackCoverJPG \
--manage-burst=NoStack \
--recursive \
--concurrent-tasks=4 \
--client-timeout=60m \
--on-errors=continue \
--include-extensions=.mp4,.mov,.tif,.jpg,.png,.dng,.heif,.heic \
/data/media/TestAsk questions in the Discussions forum and report bugs in GitHub Issues.
winget install Microsoft.DotNet.SDK.10
winget install Microsoft.VisualStudioCode
winget install Gyan.FFmpeg
winget install OliverBetz.ExifToolapt install dotnet-sdk-10.0
apt install ffmpeg
apt install exiftooldotnet new tool-manifest
dotnet tool install csharpier
dotnet tool install husky
dotnet tool install dotnet-outdated-tool
dotnet husky install
dotnet husky add pre-commit -c "dotnet husky run"winget upgrade --all --accept-package-agreements --include-unknownapt update
apt upgradedotnet tool restore
dotnet tool update --all
dotnet outdated --upgrade:promptThe third-party tools, libraries, and actions this project depends on.
| Tool | Role |
|---|---|
| AwesomeAssertions | Assertion library for .NET tests. |
| BenchmarkDotNet | Benchmarking library for .NET. |
| CliWrap | Process execution library for .NET. |
| Codecov | Code coverage reporting service. |
| CSharpier | C# code formatter. |
| cspell | Spell checker. |
| Docker Hub Description | GitHub action that publishes a Docker Hub repository overview. |
| dotnet-outdated | Outdated NuGet dependency reporter. |
| editorconfig-checker | Line-ending and whitespace linter. |
| ExifTool | Media metadata reader and writer. |
| FFmpeg | Media transcoder. |
| GH Release | GitHub action that creates a release. |
| GitHub Actions | CI and automation runner. |
| GitHub Dependabot | Dependency update bot. |
| Husky.Net | Git hook manager for .NET. |
| markdownlint-cli2 | Markdown linter. |
| Nerdbank.GitVersioning | Version computation from git height. |
| Serilog | Structured logging library for .NET. |
| SQLite | Embedded relational database engine. |
| xUnit.Net | Test framework for .NET. |
Licensed under the MIT License