Releases: Navilois/immich-compressor
Release list
v1.4.1
Fixed
-
An unusable
log_levelis refused when the settings load, instead of half-applied. The
setting was a bare string with two consumers that disagreed about what it may say:
_configure_loggingresolves a name it does not know toINFO, whileservehands the same
value to uvicorn, which raises on anything outside its own set. A typo therefore ran every
command atINFOwith nothing anywhere to say so, and killedservefrom inside uvicorn —
after the app was built and hardware detection had already logged, a long way past the point
where the rest of the configuration is checked.TRACEwas the worse case, because it looked
like it worked: uvicorn has that level andloggingdoes not, so the service came up with
uvicorn one step belowDEBUGand everything this project logs left atINFO. The accepted
set is nowDEBUG,INFO,WARNING,ERRORandCRITICAL— the five both consumers
understand — checked in the same place and the same way asdelete_modeandmetadata_verify.
Case stays irrelevant, as it has always been: the value is upper-cased before it is matched, so
log_level: debugkeeps working.config.schema.jsoncarries the five names now, so an editor
on the modeline completes them and marks a typo as it is typed; it lists the upper-case
spellings only, so it will also mark a lower-case one the service itself accepts. -
A
duplicateupload now records the replacement's checksum, not only its id. When
Immich answersduplicateto the compressed upload, the row left behind already carries
source_checksum,owner_idandnew_asset_id, which is the whole of the ledger
predicate — so the shim counts it as a full ledger entry. Only half of one worked. The
sync_rewritemap, which hands the original's checksum to the replacement's sync line,
was built as always;upload_check, which restates a device's "do you already have these
hashes?" in terms of the replacement, is built fromnew_checksumalone and that column
was never written on this path.upload_checkis the fallback for the window after the
client's mirror has seen the original's delete and before it has received the re-offered
replacement row, and the re-arm is two-pass by construction, so that window always exists.
Inside it the question went untranslated and Immich answered accept. Measured on a live
deployment (Immich v3, compressor 1.4.0) on 2026-08-28: 1,257 rows — everyduplicate
skip on it, all from one re-upload wave the day before — were ledger entries with no
new_checksum, and backfilling them by hand tookupload_checkfrom 7,952 entries to
9,209, equal to the ledger-entry count. The value is read back fromGET /assets/{id}
rather than taken from the file the run just encoded: whether Immich's duplicate detection
makes those two necessarily equal is not something this service has verified, and a
checksum the server does not hold would makeupload_checkrestate the device's question
as a hash nothing answers for. A read-back that fails leaves the column NULL and says so
in the log — the same shape this path had before — and the skip itself is unaffected. Rows
already written are not repaired;docs/upgrading.mdhas the count and the backfill. -
The shim no longer hands a checksum to a replacement while a re-upload that arrived
minutes ago still holds it. 1.4.0 stopped translating once the pipeline had recognised
a returned original, but the recognition is a job row and the job isqueued— carrying
no checksum at all — from the moment Immich answers 201 until a worker reaches it. A
device syncing inside that window got two rows claiming one(owner, checksum)and its
batch aborted withSqliteException(2067)onupdateAssetsV2, which stalls the client's
checkpoint and makes Immich re-send the same batch. Seen on a device on 2026-08-28 with
23 jobs of a re-upload burst still queued. The shim now reads the claim off the sync
stream itself: a line carrying a checksum it is armed to hand to a different asset means
the checksum is taken, and the translation stands down from that line onwards and for
every later request. It suppresses forwards only, so a claim arriving behind the line it
should have stopped still costs that one batch — but the re-send is then served from maps
that know, so the client recovers instead of looping. Nothing is written to the job store
and_check_re_uploadis untouched. -
reportnow says how many failed jobs it did not print. The list stopped at twenty
and said nothing about it, so a deployment with fifty failures showed twenty lines that
read like all of them. The count in the header was right the whole time, which is what
made the silence convincing. There is a... and 30 moreunder the list now, the same
linerequeueandbackfill runhave always printed. -
immich.connect_timeout_snow applies to the commands too, not only to the running
service.check,backfill scan,backfill runandrestoreeach built their Immich
client by hand and left the setting off, so a deployment that raised it for a slow or
distant server still got the ten-second default from all four — with nothing to say so.
There is one factory now, and every caller goes through it.
Documentation
-
The README leads with the production run and covers the shim. It opened on
Is this safe?and carried a five-job sample report, so a visitor met the risk before the
result and never saw a number worth the install. It now opens on the measured backfill —
22,586 assets, 118.66 GiB reclaimed from a 259 GB library, disk 95 % to 73 % used — with the
per-lane table, the refusals, what the run cost and the read-only audit two days later that
found no byte-identical pair in 53,855 assets. The safety chain keeps every claim it had and
moves below the quickstart, which is where it answers a question the reader is now asking.Four things the code has done since 1.3.0 were missing from it entirely: the
shim, which had no mention on the page or in the documentation table, the
re_uploadedrecognition in front of it,transcode_unsupported_audio, and the surge
breaker. The re-upload problem is the objection this project gets asked about most, so it is
now its own section with both answers in it. No claim was carried over unverified: the
numbers come from the job store's own byte records and the live-API checks recorded with
them, and the skip counts are given as an ordering rather than a partition because the
per-reason figures and the per-lane totals in that record do not reconcile. -
LOG_LEVELis documented. It is the environment override forlog_levellike any
other setting, butservealso reads it before it loads the settings, so it is the only
way to raise or silence the startup lines that explain the encoder decision — a
log_levelinconfig.yamlarrives after detection has already logged.docs/operations.md
says so under Logs with a compose snippet, and notes that.env
cannot carry it becausedocker-compose.yamlpasses through only the variables it names.
Both were checked againstdocker compose config. -
source_qualityskips a still that is strictly belowmin_source_quality, not one
at it.pipeline.pycomparesquality < min_source_quality, so a source sitting
exactly on the threshold is encoded.docs/safety.md,docs/troubleshooting.mdand
docs/architecture.mdeach said "at or below", which is an off-by-one at the boundary
the setting exists to define. ThePreset.min_source_qualitycomment said it too. The
pages that describe the guard against the preset's quality target rather than the
threshold were right as they stood and are unchanged. -
The
/metricssample indocs/operations.mdshows the sixshim_*counters. They
are emitted unconditionally, so every scrape since 1.4.0 has carried them, and a page
that promises "every family is emitted even when empty" was listing a scrape that
predates them — itsbuild_infostill read 1.3.1. The sample now matches what the
renderer produces, and a line under it says the counters read zero with the shim off and
points at their table ondocs/shim.md. -
The module table in
docs/architecture.mdfollows the package again. It still had
the endpoints inserver.py, which they left in this cycle, and it had never listed
ingest.py,routes.pyorshim.py. -
The first sync batch after a duplicate cleanup is now measured rather than open.
docs/upgrading.mdsaid whether it applies cleanly "has not been established", because
the delete for the duplicate and the upsert for the replacement can land together and
their order within a batch had not been checked. Measured on 2026-08-28 against a live
v3.1.0 instance and the Android app: 69 duplicates removed permanently, the next
pass delivered all 69 deletes in one 10,955-byte response, the device acked it, and the
shim re-armed all 69 translations inside 1.5 seconds — noupdateAssetsV2failure and
no retry.shim_gates_opened_totalstayed put andshim_touches_totalrose by exactly
69, both as the fix intends.The same run turned up the thing that actually bites, which the page now says outright:
the cleanup opens a re-upload window. The re-arm happens when the shim sees the delete, so
the corrected replacement is only re-offered on a later pass — here 57 seconds later — and
a backup scan landing in that gap uploads the files just removed. It is bounded and
self-correcting, but the page now says to run the removal with device backup off and turn
it back on after a sync has carried the translations.
v1.4.0
Added
-
A checksum-translation shim that stops the re-upload instead of only recognising it.
Off by default (shim.enabled: false), and inert until a reverse proxy routes
POST /api/sync/streamandPOST /api/assets/bulk-upload-checkto this service.The Immich app decides what to back up entirely offline, by joining the SHA-1 of each
local file against the assets it has mirrored from the server. Once the original is
permanently deleted its checksum exists nowhere, so the device uploads the file again.
The shim substitutes the original's checksum into the replacement's line in the sync
stream, so the device finds a match and never queues the file. Nothing in Immich is
altered; the substitution happens in two responses on their way to a client.It is gated, and the gate is the whole design. The app's mirror enforces one row per
(owner, checksum), so the replacement may only be given that checksum after the
original has really stopped existing — earlier, the write would either drop the
original's mirror row or abort the client's sync batch. A neworiginal_freed_atcolumn
onjobsrecords that moment: set by the pipeline right after apermanentdelete,
which this service performs itself, and by the shim when it sees the purge of a trashed
original go past on the sync stream, which happens inside Immich up to 30 days later and
is never reported here.Opening a gate also makes one no-op update to the replacement — it writes back the
isFavoritevalue it just read — because the sync stream only re-offers assets that have
changed, and without it the translation would be armed but never sent. Six counters
(shim_requests,shim_lines_rewritten,shim_hashes_translated,shim_gates_opened,
shim_touches,shim_passthrough_errors) are exposed at/metrics, and
docs/shim.md covers the deployment, the staged rollout and the limits —
including that this is, deliberately, telling one client something untrue.shim_gates_openedandshim_touchescount the event and not the code path that saw
it, so both routes to an open gate bump both: neither counter is structurally zero on a
givendelete_mode.shim_gates_openedfollows the record, so it counts on a
permanentdeployment even with the shim switched off;shim_touchesfollows the write,
so it counts only where the no-op update is actually made. One original cannot be counted
twice — a deployment that switcheddelete_modecan have both routes observe the same
purge, and the gate behind them is first-write-wins. -
A re-uploaded original is recognised instead of compressed a second time. Every job
now records the checksum and owner id the server reported for the original, before
anything mutating happens. An asset that later arrives carrying the checksum of an
original this service has already replaced is skipped as the newre_uploadedreason,
naming the earlier asset, its replacement and the date in the log. Nothing is downloaded,
encoded, uploaded or deleted — this recognises the situation, it does not act on it.The situation it recognises: the Immich mobile app decides what to back up by joining the
checksums of the files on the device against the assets it has mirrored from the server
(BackupRepository.getCandidates, verified againstimmich-app/immich@fbd5dc2). A
deleted asset leaves no row in that mirror, so a device that still holds the file uploads
it again — as a new asset, with a new id and no compressor marker, which is why the
existing loop guard cannot see it.Two columns,
source_checksumandowner_id, are added to thejobstable and applied
automatically on open. They are empty for jobs that ran before this release and cannot be
backfilled: the original they would describe is already gone. Recognition is therefore
complete only from this version onwards. -
A test that reproduces the Immich app's local mirror and replays the shim's output
through it.tests/test_app_mirror.pybuildsremote_asset_entityfrom the app's own
schema —STRICT,WITHOUT ROWID, and the partialUNIQUE (owner_id, checksum)index —
and drives it with the same upsert the app performs, so the constraint the whole gate
exists to respect is enforced in CI rather than argued about. It needs no live Immich and
no phone.Its negative control forces the ungated rewrite and pins down both failure modes, which
turn out to depend on whether the phone has already mirrored the replacement: with the row
present the unique index aborts the batch and drift'srethrowcarries the failure into
the client's sync, taking healthy lines in the same batch with it; with the row absent,
INSERT OR REPLACEresolves the conflict by deleting the original's row instead,
silently. Removing the gate turns four of these tests red. -
/metricssays when the surge breaker has latched. The new
immich_compressor_pausedgauge is 1 while the service is paused and 0 otherwise.
/healthzand/statshave always reported the latch, and neither is what a homelab
alerts on: measured on a live deployment on 2026-08-25, the breaker latched at 06:07 UTC
and the stop was noticed six hours later, with 13,134 jobs waiting behind it. Nothing else
about a paused service looks wrong — the container is healthy, the log is quiet, and the
queue simply stops moving.- alert: ImmichCompressorPaused expr: immich_compressor_paused == 1 for: 10m
The reason is deliberately not a label: it is free text with counts in it, and as a label
that is unbounded cardinality. It stays in/healthz, inresumeand in the log. -
requeue --failedbrings a batch of failed jobs back. Until nowrequeuecovered only
theskippedstate, and the other terminal one had no bulk route at all: a failed job has
spent its attempts, so the worker's own backoff never returns to it, and every gate fix was
recovered onereprocess <asset_id>call at a time.--error-contains TEXTnarrows the set
to the jobs whose recorded error contains that text, which is how a fix to one gate is
applied to exactly the jobs that gate rejected:immich-compressor requeue --failed --error-contains ShutterSpeedValue # look first immich-compressor requeue --failed --error-contains ShutterSpeedValue --applyDry until
--apply, like every other command that changes something. The match is a plain
substring oflast_error, not a pattern — ffmpeg and exiftool both put%and_in their
messages.--failedand--reasonselect the two states and are refused together, and
requeueon its own still means exactly what it did before. -
behavior.transcode_unsupported_audiore-encodes audio the container will not carry,
instead of failing the job. The video presets copy the audio stream — the point of the
exercise is the video, and a copy is free and lossless — and MP4 has no mapping for some of
what an old camera or a DVD rip produces. Measured on a live library on 2026-08-26, ffmpeg's
muxer refused 119 of the 172 failures in one backfill run on that alone:pcm_u8(108),
amr_nb(9) andpcm_dvd(2).[mp4 @ 0x...] Could not find tag for codec pcm_u8 in stream #1, codec not currently supported in containerThe first attempt is unchanged and still copies. Only a run that failed with that
diagnosis is retried, with the copy replaced by 128 kbit/s AAC — the bitrate the CPU preset
has always used. Measured in the shipped image on 2026-08-27, the refusal comes from writing
the container header before a single frame is encoded: on a 68 MB source the whole first
attempt exited in 601 ms, which is what makes retrying it cheap. There is a per-preset
override, and setting it on a preset whose command copies no audio is refused at startup.It ships off, and stays a deliberate decision, because it is the one setting here that
turns a job which cannot finish into one that deletes an original — and what it deletes is a
lossless audio stream. Nothing downstream can see that: the sanity gate counts audio
streams, it does not listen to them. The container log names every file it happens to.Not verified: how many of those 119 then pass the sanity and metadata gates. What this
removes is the muxer's refusal.
Changed
-
The surge breaker ships off (
behavior.surge_threshold: null), and its suggested value
is now 2000 rather than 200. The breaker counts assets newly queued from webhooks and
knows nothing else about them, so a first phone backup, a camera card import and a holiday
upload all look exactly like the influx it exists to stop. 200 in ten minutes is a rate
that suits the defaultenabled_types: [VIDEO]; withIMAGEenabled it is an ordinary
day, and a backstop that fires on ordinary use teaches its operator to clear it unread.
Stills and the breaker shipped in the same release, 1.1.1, and the threshold was never
re-read against them.Nothing about the mechanism changed. Writing a number turns it back on and it behaves
exactly as before: workers stop claiming, the sweeper stops finalising deletes, further
webhooks are refused, and the latch survives a restart untilimmich-compressor resume --apply.behavior.max_asset_age_hours— the gate that can actually tell a bulk
re-trigger from an upload, and the one refused at startup underdelete_mode: permanent—
is unchanged and still on by default.A deployment that relied on the 200 default now has no breaker. Set
behavior.surge_thresholdexplicitly to keep one; see
upgrading.md. A pause already latched in the database is not
cleared by this change. -
The generated configuration reference no longer prints a
nulldefault as "no
default".scripts/gen_docs.pyrendered a...
v1.3.1
Fixed
restoreno longer warns that it cannot do the thing it is doing. Under
delete_mode: permanentthe command opened with "originals removed by this service were
not trashed and cannot be restored" — printed before a single id had been tried, gated on
the mode the deployment is in now rather than the one that removed anything, and printed
by runs that then restored every original they were given. On a live stage-4 deployment
that meant a successful rollback announced itself as a refusal. The accurate version is
still there and unchanged: after the request,restorenames how many ids the server no
longer has and why, from the server's own answer.
Documentation
- The
force: truedelete was re-verified against a live library, on a real stage-4 run
rather than a throwaway asset. All three recorded consequences hold — HTTP 400 afterwards,
absent from the trash view, file unlinked from the upload directory — and two details are
now sharper in docs/immich-api-notes.md: the row leaves the
assettable entirely rather than being flagged, with no orphan left inasset_exif,
asset_fileoralbum_asset; and the message isNot found or no asset.read access, the
same permission-shaped wording/trash/restore/assetsanswers with, not the bare
Not foundrecorded before.
v1.3.0
Added
backfillworks from an inventory.backfill scanwalks the library once per enabled
asset type, runs the worker's own guards over every asset it sees, and writes the verdict
into a newbackfill_candidatestable.backfill runqueues candidates out of that table,
biggest first.backfill statussays how many are waiting, how big they are, and why the
rest were refused.backfill --type VIDEO --limit 50 --applystill means exactly what it
did before —runis the default mode, so nothing anybody has in muscle memory moved.--limitcounts queued jobs, not search results. An asset that was deleted, trashed
or given a named face between the scan and the run is recorded as such and the run moves
on to the next candidate, so fifty means fifty.- A second run makes progress. The old one re-read the same answer from the server and
spent its limit on assets it had already queued; there is now a cursor, andstatus
reports what is left. - The scan is resumable. Every page is committed before the cursor moves, so an
interrupted walk continues instead of starting over. - One live re-check per asset that is actually about to be queued — bounded by
--limit, not by the size of the library. It catches the assets the inventory has
outlived: deleted, in the trash, or newly carrying a named face. runsays what would otherwise be invisible an hour later: thatbehavior.dry_run
is on and every job it queues will end asskipped: dry_run(with therequeuecommand
that brings them back), and that a latched surge breaker means nothing will be claimed.
reportgrows one line for the inventory once a scan has run.
Changed
- The backfill reads
POST /search/metadatainstead ofPOST /search/large-assets. The
old endpoint answers with one fixed set of results — 250 items on the measured library,
every one of them a video — which makes the stills half of a library unreachable through
it no matter what the client filters afterwards.backfill --type IMAGEtherefore
returned nothing usable on a video-heavy library, even after the 1.2.0 fix stopped it
from queueing videos. The new scanner walks page by page and trusts none of the parameters
it sends: it filters by type itself and stops when a page repeats the one before it.
Documentation
POST /search/metadatais measured, not assumed. Finding 16 in
docs/immich-api-notes.md closes the gap finding 15 left open:
on v3.1.0 this endpoint does applytype,sizeandpage— unlike
/search/large-assets, which was re-checked the same day and still ignores bothtypeand
size.sizecaps at 1000 and answers HTTP 400 above it,nextPageis a string that goes
null on the last page,totalcounts the page rather than the library, and the order is
fileCreatedAtdescending. The scanner's client-side type filter and repeated-page check
stay exactly where they are; what changes is that the cost of a walk is now known — 55
requests and 16.5 s for a 53 775-asset library.
Fixed
restore --all-pendingsurvives originals that no longer exist. On any deployment
that had ever rundelete_mode: permanentit restored nothing: it sent the source id
of every completed job in a singlePOST /trash/restore/assets, originals removed with
force: trueare gone from Immich's database, and one id the server cannot find refuses
the whole request withHTTP 400 Not found or no asset.delete access— including the
originals that really were sitting in the trash. Measured on a live v3.1.0 instance on
2026-08-23: of the 50 ids it sent, 46 had been force-deleted by earlier stage-4 runs, and
the one recoverable original stayed trashed while the command exited 1.- The selection now goes out in batches, and a refused batch is halved and re-sent until
each unknown id stands alone, so a dead id costs only itself. Once a batch turns out
to be mostly missing — what a stage-4 deployment looks like — the rest is sent one id at
a time rather than halved, which is the cheaper way to isolate them. - It reports the server's own
countinstead of the number of ids it sent, and names
how many ids Immich no longer knows. - The explanation of why ids go missing no longer depends on
delete_modebeing
permanentright now. On the measured deployment the mode was already back totrash,
so that message never printed and the operator got a bare HTTP 400 with no reason for it. - Exit codes:
0every id came back,3some ids are no longer in Immich's database,
2nothing was selected,1the call to Immich failed.3is new — a rollback that
could not roll everything back must not look like a clean success in a script.
- The selection now goes out in batches, and a refused batch is halved and re-sent until
- The backfill asked the server for the wrong size threshold. It sent
behavior.min_savings_bytesasminFileSizewhile the guard that decides the same
question usespreset.effective_min_savings_bytes(), so a preset with its own override —
which is exactly what the stills presets have, because video and stills have opposite
economics — was scanned against a threshold nobody configured. The scan now runs the guard
itself, and the threshold is per preset by construction.
v1.2.0
Everything here comes from one first-install audit against a live Immich v3.1.0 and a
259 GB library. The pipeline itself came through it unchanged — encoder detection, the
sanity gate, the metadata chain, the verification chain and all four stages ran on the
first try. What cost the tester most of an hour was everything around them, and that is
what this release is.
Added
- Webhook counters.
webhooks_receivedandwebhooks_rejected, in the first line of
report, incheck, in/statsand in/metrics. A shared secret that does not match
was the one failure in this architecture that left no trace anywhere: Immich discards
the 401 and logs the workflow as "executed successfully", no job row is written, and
check,reportand/healthzall read exactly like a healthy installation with
nothing to do.0 received, 7 rejectednow says it outright, and names the cause. The
counters live in the database, becausereportruns in a different process fromserve
and because restarting the container must not erase the evidence. immich-compressor jobs(--status,--limit,--json).last_errorhad one
documented route,curl 'localhost:8080/jobs?status=failed', and it works nowhere in a
default install: no port is published, and the image contains neither curl nor wget.setup --workflow-key. A second API key carryingworkflow.createand nothing
else, used for the singlePOST /workflowsand never written to any file. Keeping that
permission out of the long-lived service key is right; it did not follow that the only
ways left were a full-access browser session token or a 64-character secret typed into a
web form by hand.TZis passed to the container bydocker-compose.yaml, byquickstart.sh, and
written into the.envsetupgenerates. Immich sets it for its own containers, so
without this the two services timestamped their logs hours apart — while
troubleshooting.md asks you to read them side by side.- The startup block names the
assetFileFilterpattern your workflow has to carry. The
marker couples three things nobody ever sees together, one of which lives inside Immich
where nothing here can check it. setupwritesCOMPRESSOR_CPUSandCOMPRESSOR_MEMORYinto the generated.env,
commented out, with this machine's own numbers — and says which mechanism wins when the
compose override sets them too.
Changed
- The capture-date gate is measured against the source. A video without a
creation_timecould not pass the sanity gate at any quality, which ruled out every
screen recording, messenger clip, drone export and cut file in a library. The gate exists
to catch a capture date the encode lost, and an output cannot lose what the input never
had. Sources that carry one are checked exactly as before. - The rejection warning for a bad shared secret names the length and first characters of
the token that arrived alongside the one expected — what separates a paste cut short from
a token left over from an earlier install. --helphas a description written for a terminal. It was the module docstring, printed
verbatim with RST backticks, listing seven of twelve commands.reportprintsaverage ratio —rather than Python'sNone, andreprocesson an
unknown asset namesbackfillas the way in.setuppoints out a grantedasset.delete. The quickstart says to leave it out for the
first run; granted anyway it printed anokshaped like every other permission, and that
guarantee disappeared unannounced.
Fixed
backfill --type IMAGEqueued videos.POST /search/large-assetsacceptstypeand
ignores it — measured:IMAGEandVIDEOanswer with the identical 250 items, all of
them videos, andsize: 5answers with 250. The stills backfill was therefore
unreachable, and anybody who thought they were testing 50 photos re-encoded 50 videos.
Now filtered client-side, with the discarded results counted out loud.quickstart.shdid not forwardIMMICH_API_KEY.setuprefuses without a key and
tells you to set that exact variable; the script never passed it in, so following the
advice returned you to the dead end you were already in.- The encoder decision was logged before logging existed.
serveloaded the settings
first, and loading them is what runs hardware detection — whose explanation went out
throughlogging.lastResort, which drops everything below WARNING. Every start threw
away the lines docs/quickstart.md points at. .gitignorecovered.envand nothing beside it..env.bakfrom asetup --force,
.env.local,.env.prodand any backup taken before an edit were committable, each
carrying the same API key and webhook token. Now.env*with!.env.example.hardwareleftTHREADSout of its container calibration command, socalibrate.sh
fell back to 2 and the sweep measured against half the threads the encoder really gets.quickstart.shprinted a second copy of the three commandssetuphad just printed, so
a successful install ended looking like an error.- The compose override template still advised matching the CPU preset's
-threadsto the
container'scpus. The container reads its own cgroup limit; the advice contradicted two
other files and stood in the one everybody edits.
Known
-
The repository was private when this was tagged, and the documented quickstart begins with
git clone— so the five-minute path was not open to anybody outside it. The published
image was a separate matter and was pullable anonymously throughout.Resolved the same day. The repository was made public on 2026-08-22, and the
documented path was then walked from an empty directory on this machine:git clonewith
credentials refused,docker pull ghcr.io/navilois/immich-compressor:1.2.0, and
--versionanswering1.2.0.
v1.1.1
Added
- A surge breaker (
behavior.surge_threshold/surge_window_seconds, 200 per 10 min).
Backstop behind the gate below, for a bulk influx it does not model — an unfamiliar
trigger, a re-uploaded library, a misdirected workflow. More than the threshold in new
webhook-queued assets inside the window latches the service paused: workers claim nothing,
the sweeper finalises no deletes, further webhooks are refused. The latch is stored in the
database, because restarting the container is the first thing an operator reaches for and
it must not be the thing that clears a pause. Cleared withimmich-compressor resume --applyor a token-protectedPOST /resume, and visible inreport,/healthzand
/stats. A large phone backup will trip it; the breaker only pauses, and that is the right
way round for a service that deletes originals. - A bulk-trigger gate (
behavior.max_asset_age_hours, 24 h by default). Immich's
AssetMetadataExtractiontrigger is a maintenance operation: one click on
Administration → Jobs → Extract Metadata re-fires the workflow for every asset in the
library. Assets already recorded were immune; assets never seen were not, which was the
whole library until it had been worked through. Every webhook carriescreatedAt, which
dates the upload rather than the exposure, so a re-trigger is now refused at ingest
while a legitimate import of a thousand old photos still passes — something a rate limit
could not distinguish. A refusal writes no job, deliberately:backfillenqueues through
the sameON CONFLICT DO NOTHING, and a row recorded here would put the asset permanently
out of its reach.max_asset_age_hours: nullturns the gate off and is refused at startup
together withdelete_mode: permanent. - JPEG stills are compressed too.
enabled_types: [VIDEO, IMAGE]andIMAGEin the
workflow's type filter are whatsetupnow writes. The encoder path already existed;
what was missing was the decision logic around it. - Format allowlist (
Preset.match.extensions). Immich files RAW, PNG, GIF, TIFF, WebP
and HEIC under typeIMAGEexactly like JPEG, and ImageMagick reads DNG/CR2/CR3/NEF/ARW
through libraw — without the list a raw file would be developed into an 8-bit JPEG, pass
every sanity check, and have its original deleted. Anything not on the list is skipped as
unsupported_format, which is deliberately a different reason fromno_preset. - A metadata gate. After the encode, source and output are compared with
exiftool -G -EXIF:all -GPS:all -XMP:all -IPTC:all; any tag that is missing or changed is
a finding.behavior.metadata_verifydecides whether that fails the job (strict, the
default) or only logs (warn), andwarnis refused at startup together with
delete_mode: permanent— a warning cannot undo a force-deleted original. - Motion photos are detected and skipped as
embedded_media. A Samsung or Google motion
photo is a JPEG with an MP4 behind the end-of-image marker; a re-encode drops the video
while every other check reports success. Two independent signals: the XMP markers, and
payload after the EOI marker found by walking the JPEG's segment structure rather than
searching for the lastFFD9. Preset.min_source_quality— skip a still that is already at or below the preset's own
quality target, since quantisation error is cumulative and a re-encode usually produces a
larger file (measured 158 368 -> 190 488 bytes for a q60 source through the q82 preset).
Skipped assource_quality.- Per-preset overrides of
max_ratio,min_savings_bytesandrequire_date_time_original,
because video and stills have opposite economics. - One worker lane per enabled asset type, backed by a new
asset_typecolumn on the job
store (migrated automatically). Without it a single clip withtimeout_s: 7200holds the
only worker for two hours while every one-second image job queues up behind it. Rows
written before the column existed carryNULLand stay claimable from every lane. immich-compressor encodeadditionally reportssource_quality,embedded_mediaand
metadata_differences, so every still-specific decision is visible before the pipeline
makes it — without touching the server.MAGICK_THREAD_LIMIT,MAGICK_MEMORY_LIMITandMAGICK_MAP_LIMITin the image.
ImageMagick is built with OpenMP and sizes its thread pool from the host core count,
ignoring the container's cgroup limit — the same trap the video preset defuses with
pools=2 -threads 2.
Changed
- Breaking:
behavior.min_size_bytesis replaced bybehavior.min_savings_bytes
(default 1 MiB, was 20 MiB). The old threshold guessed from the input size whether the
work was worth doing; the new one measures whether it was. It also serves as the
pre-download filter, and that half needs no calibration: a file cannot save more bytes
than it has. A config that still carries the old key is refused at startup with the
replacement named in the error. See docs/upgrading.md. - The generated stills preset is now
magick {input} -auto-orient -quality 82 -interlace Plane {output}.magickbecause
convertis a deprecated alias in ImageMagick 7;-interlace Planebecause it is free
(the same DCT coefficients reordered —compare -metric AEreturns 0 — for 3-8 % less
size); and no-sampling-factor, because ImageMagick then inherits the source's chroma
subsampling instead of halving it on every 4:4:4 source, which no sanity check would
notice. immich-compressor hardwarelists the extensions a preset accepts, and--jsoncarries
them.
Fixed
- The metadata gate rejected every geotagged camera JPEG. EXIF stores rationals, and
copying a tag re-approximates the fraction, so a carry-over that loses nothing still moves
the float: measured on a phone JPEG through the shipped preset,ExposureTimewent
2497831/250000000->1/100and the GPS latitude seconds16316639/1000000->
39421/2416. Both print identically. Values are now compared as exiftool presents them,
and the offset tags (ThumbnailOffset,PreviewImageStart,OtherImageStart,
StripOffsets) are ignored because they are file positions, not content — the matching
*Lengthtags stay compared, since a thumbnail length that moves is a truncated thumbnail. - The
BEHAVIOR__flags in.envreached nothing..env.exampledocuments four of
them as the way to go live, but.envis compose's substitution file, not anenv_file:
docker-compose.yamlhad noenv_file:and named only three${...}values in its
environment:block, and the service setsenv_file=Noneinconfig.pyand never had
.envmounted. Measured on a running container: withBEHAVIOR__DRY_RUN=falseand
BEHAVIOR__TRASH_ORIGINAL=truein.env, the container environment held neither, so a
deployment that went live this way stayed in dry run and said nothing. The compose file
now lists the four by name, in the list form — a bare name is passed on only when it is
set, whereBEHAVIOR__DRY_RUN: ${BEHAVIOR__DRY_RUN:-}would have handed every other
deployment an empty string to parse. Verified against the 1.1.0 image end to end: unset,
config.yamlstill decides and the defaults stay inert; set, the service resolves
dry_run=Falseandtrash_original=True. A setting indocker-compose.override.yaml
still wins over.env, and a test now holds the two files to the same list of flags. - The compose override template broke on its first edit. It ended in a
{}that kept
the file valid while every block in it was a comment — but a flow mapping cannot hold
block keys, so uncommenting anything made compose stop with a YAML parse error until that
line was deleted too. It now carries one real setting instead,restart: unless-stopped,
which only restates whatdocker-compose.yamlalready sets: the file stays valid and
inert, and every block can be uncommented on its own. Verified by turning all of them on
at once against real compose. The secondenvironment:block went the same way — a
service takes one, and uncommenting both produced a duplicate key. setupunloadeddocker-compose.override.yaml. TheCOMPOSE_FILEline it writes for
a detected GPU replaces compose's default file list, and the override is only ever in
that default list — so naming an overlay there dropped the override entirely, taking the
go-live flags (BEHAVIOR__DRY_RUN,BEHAVIOR__TRASH_ORIGINAL,BEHAVIOR__DELETE_MODE),
the resource limits and any local image pin with it, in exact contradiction of the docs
telling people to keep all of that there. Measured withdocker compose config:
BEHAVIOR__DRY_RUNresolved to nothing and the image fell back to
ghcr.io/navilois/immich-compressor:1. The override is now named last on that line, where
it wins. Compose exits 1 on a file it cannot stat, sosetupcreates the override first —
copied verbatim fromdocker-compose.override.example.yaml, every block in it still a
comment — rather than naming a file that is not there yet. Without that it would only have
helped people who wrote their override before runningsetup, anddocs/safety.mdhas
you write it afterwards, at go-live. An existing override is never touched,--forceor
not: regenerating it would put a live deployment back into dry run silently..dockerignorematched__pycache__/and*.pycat the context root only, so
src/immich_compressor/__pycache__/was copied into the image — 12 stale.pycfiles on
a measured rebuild, three of them orphans from a branch that was not even checked out.
Harmless at runtime, but it made the image depend on which branch was last built.setupleft the shared webhook token wheregit addcould reach it. When Immich
refuses the workflow — ...