Skip to content

Releases: Navilois/immich-compressor

v1.4.1

Choose a tag to compare

@github-actions github-actions released this 28 Aug 12:13
25a836b

Fixed

  • An unusable log_level is 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_logging resolves a name it does not know to INFO, while serve hands the same
    value to uvicorn, which raises on anything outside its own set. A typo therefore ran every
    command at INFO with nothing anywhere to say so, and killed serve from 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. TRACE was the worse case, because it looked
    like it worked: uvicorn has that level and logging does not, so the service came up with
    uvicorn one step below DEBUG and everything this project logs left at INFO. The accepted
    set is now DEBUG, INFO, WARNING, ERROR and CRITICAL — the five both consumers
    understand — checked in the same place and the same way as delete_mode and metadata_verify.
    Case stays irrelevant, as it has always been: the value is upper-cased before it is matched, so
    log_level: debug keeps working. config.schema.json carries 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 duplicate upload now records the replacement's checksum, not only its id. When
    Immich answers duplicate to the compressed upload, the row left behind already carries
    source_checksum, owner_id and new_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_rewrite map, 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 from new_checksum alone and that column
    was never written on this path. upload_check is 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 — every duplicate
    skip on it, all from one re-upload wave the day before — were ledger entries with no
    new_checksum, and backfilling them by hand took upload_check from 7,952 entries to
    9,209, equal to the ledger-entry count. The value is read back from GET /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 make upload_check restate 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.md has 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 is queued — 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 with SqliteException(2067) on updateAssetsV2, 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_upload is untouched.

  • report now 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 more under the list now, the same
    line requeue and backfill run have always printed.

  • immich.connect_timeout_s now applies to the commands too, not only to the running
    service.
    check, backfill scan, backfill run and restore each 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_uploaded recognition 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_LEVEL is documented. It is the environment override for log_level like any
    other setting, but serve also 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_level in config.yaml arrives after detection has already logged. docs/operations.md
    says so under Logs with a compose snippet, and notes that .env
    cannot carry it because docker-compose.yaml passes through only the variables it names.
    Both were checked against docker compose config.

  • source_quality skips a still that is strictly below min_source_quality, not one
    at it.
    pipeline.py compares quality < min_source_quality, so a source sitting
    exactly on the threshold is encoded. docs/safety.md, docs/troubleshooting.md and
    docs/architecture.md each said "at or below", which is an off-by-one at the boundary
    the setting exists to define. The Preset.min_source_quality comment 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 /metrics sample in docs/operations.md shows the six shim_* 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 — its build_info still 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 on docs/shim.md.

  • The module table in docs/architecture.md follows the package again. It still had
    the endpoints in server.py, which they left in this cycle, and it had never listed
    ingest.py, routes.py or shim.py.

  • The first sync batch after a duplicate cleanup is now measured rather than open.
    docs/upgrading.md said 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 — no updateAssetsV2 failure and
    no retry. shim_gates_opened_total stayed put and shim_touches_total rose 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

Choose a tag to compare

@github-actions github-actions released this 27 Aug 10:59
44e73fd

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/stream and POST /api/assets/bulk-upload-check to 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 new original_freed_at column
    on jobs records that moment: set by the pipeline right after a permanent delete,
    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
    isFavorite value 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_opened and shim_touches count 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
    given delete_mode. shim_gates_opened follows the record, so it counts on a
    permanent deployment even with the shim switched off; shim_touches follows the write,
    so it counts only where the no-op update is actually made. One original cannot be counted
    twice — a deployment that switched delete_mode can 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 new re_uploaded reason,
    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 against immich-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_checksum and owner_id, are added to the jobs table 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.py builds remote_asset_entity from the app's own
    schema — STRICT, WITHOUT ROWID, and the partial UNIQUE (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's rethrow carries the failure into
    the client's sync, taking healthy lines in the same batch with it; with the row absent,
    INSERT OR REPLACE resolves the conflict by deleting the original's row instead,
    silently. Removing the gate turns four of these tests red.

  • /metrics says when the surge breaker has latched. The new
    immich_compressor_paused gauge is 1 while the service is paused and 0 otherwise.
    /healthz and /stats have 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, in resume and in the log.

  • requeue --failed brings a batch of failed jobs back. Until now requeue covered only
    the skipped state, 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 one reprocess <asset_id> call at a time. --error-contains TEXT narrows 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 --apply

    Dry until --apply, like every other command that changes something. The match is a plain
    substring of last_error, not a pattern — ffmpeg and exiftool both put % and _ in their
    messages. --failed and --reason select the two states and are refused together, and
    requeue on its own still means exactly what it did before.

  • behavior.transcode_unsupported_audio re-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) and pcm_dvd (2).

    [mp4 @ 0x...] Could not find tag for codec pcm_u8 in stream #1, codec not currently
                  supported in container
    

    The 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 default enabled_types: [VIDEO]; with IMAGE enabled 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 until immich-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 under delete_mode: permanent —
    is unchanged and still on by default.

    A deployment that relied on the 200 default now has no breaker. Set
    behavior.surge_threshold explicitly 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 null default as "no
    default".
    scripts/gen_docs.py rendered a...

Read more

v1.3.1

Choose a tag to compare

@github-actions github-actions released this 23 Aug 20:13
8084b1c

Fixed

  • restore no longer warns that it cannot do the thing it is doing. Under
    delete_mode: permanent the 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, restore names how many ids the server no
    longer has and why, from the server's own answer.

Documentation

  • The force: true delete 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
    asset table entirely rather than being flagged, with no orphan left in asset_exif,
    asset_file or album_asset; and the message is Not found or no asset.read access, the
    same permission-shaped wording /trash/restore/assets answers with, not the bare
    Not found recorded before.

v1.3.0

Choose a tag to compare

@github-actions github-actions released this 23 Aug 18:56
a927419

Added

  • backfill works from an inventory. backfill scan walks the library once per enabled
    asset type, runs the worker's own guards over every asset it sees, and writes the verdict
    into a new backfill_candidates table. backfill run queues candidates out of that table,
    biggest first. backfill status says how many are waiting, how big they are, and why the
    rest were refused. backfill --type VIDEO --limit 50 --apply still means exactly what it
    did before — run is the default mode, so nothing anybody has in muscle memory moved.
    • --limit counts 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, and status
      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.
    • run says what would otherwise be invisible an hour later: that behavior.dry_run
      is on and every job it queues will end as skipped: dry_run (with the requeue command
      that brings them back), and that a latched surge breaker means nothing will be claimed.
  • report grows one line for the inventory once a scan has run.

Changed

  • The backfill reads POST /search/metadata instead of POST /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 IMAGE therefore
    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/metadata is 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 apply type, size and page — unlike
    /search/large-assets, which was re-checked the same day and still ignores both type and
    size. size caps at 1000 and answers HTTP 400 above it, nextPage is a string that goes
    null on the last page, total counts the page rather than the library, and the order is
    fileCreatedAt descending. 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-pending survives originals that no longer exist. On any deployment
    that had ever run delete_mode: permanent it restored nothing: it sent the source id
    of every completed job in a single POST /trash/restore/assets, originals removed with
    force: true are gone from Immich's database, and one id the server cannot find refuses
    the whole request with HTTP 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 count instead 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_mode being
      permanent right now. On the measured deployment the mode was already back to trash,
      so that message never printed and the operator got a bare HTTP 400 with no reason for it.
    • Exit codes: 0 every id came back, 3 some ids are no longer in Immich's database,
      2 nothing was selected, 1 the call to Immich failed. 3 is new — a rollback that
      could not roll everything back must not look like a clean success in a script.
  • The backfill asked the server for the wrong size threshold. It sent
    behavior.min_savings_bytes as minFileSize while the guard that decides the same
    question uses preset.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

Choose a tag to compare

@Navilois Navilois released this 22 Aug 19:29
fb9cf29

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_received and webhooks_rejected, in the first line of
    report, in check, in /stats and 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, report and /healthz all read exactly like a healthy installation with
    nothing to do. 0 received, 7 rejected now says it outright, and names the cause. The
    counters live in the database, because report runs in a different process from serve
    and because restarting the container must not erase the evidence.
  • immich-compressor jobs (--status, --limit, --json). last_error had 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 carrying workflow.create and nothing
    else, used for the single POST /workflows and 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.
  • TZ is passed to the container by docker-compose.yaml, by quickstart.sh, and
    written into the .env setup generates. 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 assetFileFilter pattern 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.
  • setup writes COMPRESSOR_CPUS and COMPRESSOR_MEMORY into 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_time could 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.
  • --help has a description written for a terminal. It was the module docstring, printed
    verbatim with RST backticks, listing seven of twelve commands.
  • report prints average ratio — rather than Python's None, and reprocess on an
    unknown asset names backfill as the way in.
  • setup points out a granted asset.delete. The quickstart says to leave it out for the
    first run; granted anyway it printed an ok shaped like every other permission, and that
    guarantee disappeared unannounced.

Fixed

  • backfill --type IMAGE queued videos. POST /search/large-assets accepts type and
    ignores it — measured: IMAGE and VIDEO answer with the identical 250 items, all of
    them videos, and size: 5 answers 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.sh did not forward IMMICH_API_KEY. setup refuses 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. serve loaded the settings
    first, and loading them is what runs hardware detection — whose explanation went out
    through logging.lastResort, which drops everything below WARNING. Every start threw
    away the lines docs/quickstart.md points at.
  • .gitignore covered .env and nothing beside it. .env.bak from a setup --force,
    .env.local, .env.prod and any backup taken before an edit were committable, each
    carrying the same API key and webhook token. Now .env* with !.env.example.
  • hardware left THREADS out of its container calibration command, so calibrate.sh
    fell back to 2 and the sweep measured against half the threads the encoder really gets.
  • quickstart.sh printed a second copy of the three commands setup had just printed, so
    a successful install ended looking like an error.
  • The compose override template still advised matching the CPU preset's -threads to the
    container's cpus. 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 clone with
    credentials refused, docker pull ghcr.io/navilois/immich-compressor:1.2.0, and
    --version answering 1.2.0.

v1.1.1

Choose a tag to compare

@Navilois Navilois released this 21 Aug 10:59
6d44629

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 with immich-compressor resume --apply or a token-protected POST /resume, and visible in report, /healthz and
    /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
    AssetMetadataExtraction trigger 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 carries createdAt, 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: backfill enqueues through
    the same ON CONFLICT DO NOTHING, and a row recorded here would put the asset permanently
    out of its reach. max_asset_age_hours: null turns the gate off and is refused at startup
    together with delete_mode: permanent.
  • JPEG stills are compressed too. enabled_types: [VIDEO, IMAGE] and IMAGE in the
    workflow's type filter are what setup now 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 type IMAGE exactly 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 from no_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_verify decides whether that fails the job (strict, the
    default) or only logs (warn), and warn is 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 last FFD9.
  • 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 as source_quality.
  • Per-preset overrides of max_ratio, min_savings_bytes and require_date_time_original,
    because video and stills have opposite economics.
  • One worker lane per enabled asset type, backed by a new asset_type column on the job
    store (migrated automatically). Without it a single clip with timeout_s: 7200 holds the
    only worker for two hours while every one-second image job queues up behind it. Rows
    written before the column existed carry NULL and stay claimable from every lane.
  • immich-compressor encode additionally reports source_quality, embedded_media and
    metadata_differences, so every still-specific decision is visible before the pipeline
    makes it — without touching the server.
  • MAGICK_THREAD_LIMIT, MAGICK_MEMORY_LIMIT and MAGICK_MAP_LIMIT in 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_bytes is replaced by behavior.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}. magick because
    convert is a deprecated alias in ImageMagick 7; -interlace Plane because it is free
    (the same DCT coefficients reordered — compare -metric AE returns 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 hardware lists the extensions a preset accepts, and --json carries
    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, ExposureTime went
    2497831/250000000 -> 1/100 and the GPS latitude seconds 16316639/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
    *Length tags stay compared, since a thumbnail length that moves is a truncated thumbnail.
  • The BEHAVIOR__ flags in .env reached nothing. .env.example documents four of
    them as the way to go live, but .env is compose's substitution file, not an env_file:
    docker-compose.yaml had no env_file: and named only three ${...} values in its
    environment: block, and the service sets env_file=None in config.py and never had
    .env mounted. Measured on a running container: with BEHAVIOR__DRY_RUN=false and
    BEHAVIOR__TRASH_ORIGINAL=true in .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, where BEHAVIOR__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.yaml still decides and the defaults stay inert; set, the service resolves
    dry_run=False and trash_original=True. A setting in docker-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 what docker-compose.yaml already 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 second environment: block went the same way — a
    service takes one, and uncommenting both produced a duplicate key.
  • setup unloaded docker-compose.override.yaml. The COMPOSE_FILE line 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 with docker compose config:
    BEHAVIOR__DRY_RUN resolved 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, so setup creates the override first —
    copied verbatim from docker-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 running setup, and docs/safety.md has
    you write it afterwards, at go-live. An existing override is never touched, --force or
    not: regenerating it would put a live deployment back into dry run silently.
  • .dockerignore matched __pycache__/ and *.pyc at the context root only, so
    src/immich_compressor/__pycache__/ was copied into the image — 12 stale .pyc files 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.
  • setup left the shared webhook token where git add could reach it. When Immich
    refuses the workflow — ...
Read more