Skip to content

fix(upload): let a replaced server asset be represented by its replacement - #1427

Closed
gthb wants to merge 7 commits into
simulot:mainfrom
gthb:fix/replaced-asset-in-index
Closed

gthb wants to merge 7 commits into
simulot:mainfrom
gthb:fix/replaced-asset-in-index

Conversation

@gthb

@gthb gthb commented Aug 19, 2026

Copy link
Copy Markdown

What

Fix from-google-photos losing the stack of a group that contains a takeout duplicate (X(1).jpg) of a server copy that the same run replaced with a bigger X.jpg. The duplicate was matched to the replaced, force-deleted asset and got its ID; the group's POST /api/stacks then failed with 400 Not found or no asset.update access (or left a stack whose primary asset is deleted, when the server had not finished deleting it yet). 17 such groups in a 26k-asset import, after #1423.

Fixes #1426. Refs #1285.

Cause

replaceAsset marks the replaced server asset as trashed but leaves it in the index under its checksum and its name. A later local file with the same content, or the same name and date, is matched to it and gets the ID of an asset that no longer exists. A local file matched to the server asset before the replacement keeps the stale ID as well, since nothing revisits it.

Fix

  • immichIndex.replaceAsset records replaced ID → replacement.
  • ShouldUpload resolves a matched asset to its replacement before comparing: a duplicate of the deleted copy is advised as "bigger copy on the server" for the replacement (so it is not uploaded, and takes the replacement's ID); a same-named, same-dated file is compared with the replacement.
  • handleGroup resolves the group's IDs the same way before stacking, for files matched before the replacement happened; with stackIDs (fix(upload): don't take a group sibling or a replaced server asset for a server copy of a photo #1423) the duplicate then collapses onto the replacement in the stack request.

An e2e test (Test_FromGooglePhotos_ReplacedCopy, two runs: the small copy, then the takeout with the bigger copy, its edited version and the duplicate) fails before the fix and passes after; a unit test covers both lookup paths in ShouldUpload.

Not covered

An album addition queued for the duplicate before the replacement is flushed later with the stale ID, so that addition presumably fails for that asset (not observed; the replacement is added to the same albums by its own processing, so the album is not left empty).

Notes

Stacked on #1423 (branch fix/edited-sibling-not-server-duplicate): it relies on stackIDs deduplicating the group's IDs, and changes the same lines of ShouldUpload. Merge #1423 first; this PR then shows only its own two commits.

gthb added 7 commits August 18, 2026 00:03
Add an e2e test uploading a takeout with eight photos and their
Google-edited versions (one sidecar per pair, as Google exports them):
all sixteen must be uploaded and each pair stacked.

It fails on the current code: the edited version is indexed under the
sidecar's title, i.e. the original's name, with the original's capture
date. When it happens to be processed first, the original is taken for a
smaller or bigger server copy of it, and is either not uploaded at all
or uploaded as a replacement that force-deletes the edited version. The
order within a pair is random (map iteration in the adapter), hence
eight pairs: all of them coming out right by chance is 1/256.

The e2e utils gain GetAllAssetList (GetAllAssets keys by file name, and
both assets of a pair are named alike) and GetAllStacks (v3's
search/metadata does not report stack membership).

Refs simulot#1285, simulot#877
ShouldUpload looks for a server asset with the same name and capture
date as the local one, and advises to skip the upload (server has a
bigger copy) or to replace the server asset (server has a smaller copy).
Assets uploaded earlier in the run are part of that lookup.

For a Google Photos edited pair, X.jpg and X-edited.jpg, the adapter
gives both the sidecar's title X.jpg as OriginalFileName and the same
capture date, and the two are grouped to be stacked. When X-edited.jpg
is processed first, X.jpg then finds it as "an asset with the same name
and date": if X.jpg is the smaller file it is not uploaded, if it is the
bigger one it replaces X-edited.jpg, and replaceAsset force-deletes the
edited version that was uploaded a moment ago. In the latter case the
stack is then created with the deleted ID (400 or 500 from the server,
depending on how far the deletion got) and the next tag batch that
contains it fails as a whole.

Pass the group to handleAsset and have ShouldUpload skip the asset's own
siblings; they are about to be stacked together, so by construction
they are not the same photo at another size. Same-name matches against
pre-existing server assets, or against same-run uploads from other
directories, are unchanged.

Fixes simulot#1285, simulot#877
handleGroup put the cover's ID first unconditionally, even when empty,
and never checked for duplicates. An empty ID happens when the cover was
discarded or failed to upload; the same ID twice happens when two assets
of the group are backed by the same server asset. Both end in a rejected
POST /api/stacks (issue simulot#749 shows the "" case).

Collect the IDs through stackIDs, which drops empty ones and repeats,
and record the "stacked" event only when a stack is actually created,
so the final report counts what happened.

The AlreadyProcessed case (same checksum seen earlier in the run) now
sets the asset's ID from the already-uploaded asset, as the other
"already on the server" cases do; it was left empty, so albums and
stacks were requested with "".
A blank line separated the comment from the func declaration, so it was
not a doc comment; the siblings parameter added to it is only visible
in godoc once the two are joined.
An asset of the group without a server ID (discarded, or failed to
upload) is not part of the stack request; don't record a "stacked" event
for it.
…tack

Add an e2e test in two runs. The first uploads a small photo.jpg. The
second uploads a takeout with a bigger photo.jpg of the same date, its
edited version, and photo(1).jpg, Google's duplicate, byte-identical to
the small copy on the server.

It fails on the current code: the bigger photo.jpg replaces the server's
copy, which is force-deleted, but photo(1).jpg is matched to that copy
by checksum, either before the replacement (then its ID goes stale) or
after (the index still lists the deleted asset under its checksum), and
the group's stack is requested with the deleted ID: 400 from the server,
no stack.
…ement

replaceAsset marks the replaced server asset as trashed but leaves it in
the index, under its checksum and its name. A later local file with the
same content (Google's X(1).jpg duplicate of a photo whose bigger X.jpg
just replaced the server's copy) or the same name and date was matched
to it and got the ID of an asset that no longer exists, and the group's
stack was requested with that ID and rejected. A local file matched to
the server asset before the replacement kept the stale ID as well.

Record the replacement in the index. ShouldUpload resolves a matched
asset to its replacement before comparing, so a duplicate of the deleted
copy is advised as "bigger copy on the server" for the replacement, and
handleGroup resolves the group's IDs the same way before stacking.
@gthb

gthb commented Aug 19, 2026

Copy link
Copy Markdown
Author

Folded into #1423 (the same lines of ShouldUpload, and it relies on that PR's stackIDs); closing to keep one PR. The issue stays as #1426.

@gthb gthb closed this Aug 19, 2026
@gthb
gthb deleted the fix/replaced-asset-in-index branch August 19, 2026 11:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

from-google-photos: a takeout duplicate of a server copy that the run has just replaced gets the deleted asset's ID, and the group's stack fails

1 participant