From 9d9659f5cbf15ce6df5257e320f2dece94b13878 Mon Sep 17 00:00:00 2001 From: Major Date: Fri, 3 Jul 2026 11:00:43 +0200 Subject: [PATCH 1/4] fix: Immich v3 API compatibility Immich v3.0 removed the nested assets array from GET /api/albums/{id} and deprecated the PUT asset routes in favor of PATCH. - getAlbumAssetIDs now lists an album's assets via POST /api/search/metadata with albumIds, instead of the album detail endpoint (which no longer returns assets on v3). Folded the identical tag/album pagination into one searchAssetIDs helper; remove the now-unused ImmichAlbumDetailResponse. - bulkUpdateLocation uses PATCH /api/assets instead of PUT. - Update album-sync and bulk-update tests to the new endpoints. Verified live against an Immich v3 server: forced album re-sync succeeds and repopulates album membership; PATCH /api/assets returns 204. --- backend/handlers_test.go | 8 +++--- backend/immichClient.go | 55 ++++++++++++++----------------------- backend/syncService_test.go | 36 ++++++++++++------------ backend/types.go | 6 ---- 4 files changed, 44 insertions(+), 61 deletions(-) diff --git a/backend/handlers_test.go b/backend/handlers_test.go index 00f58dc..4af4160 100644 --- a/backend/handlers_test.go +++ b/backend/handlers_test.go @@ -495,7 +495,7 @@ func TestHandleGetFrequentLocations(t *testing.T) { func TestHandleUpdateLocationSuccess(t *testing.T) { handlers, mux := newTestHandlersWithMockImmich(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == "PUT" && r.URL.Path == "/api/assets" { + if r.Method == "PATCH" && r.URL.Path == "/api/assets" { w.WriteHeader(http.StatusNoContent) return } @@ -523,7 +523,7 @@ func TestHandleUpdateLocationSuccess(t *testing.T) { func TestHandleUpdateLocationImmichFailure(t *testing.T) { handlers, mux := newTestHandlersWithMockImmich(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == "PUT" && r.URL.Path == "/api/assets" { + if r.Method == "PATCH" && r.URL.Path == "/api/assets" { w.WriteHeader(http.StatusInternalServerError) return } @@ -577,7 +577,7 @@ func TestHandleAlbumsWithGPSFilter(t *testing.T) { func TestHandleUpdateLocationWithStack(t *testing.T) { handlers, mux := newTestHandlersWithMockImmich(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == "PUT" && r.URL.Path == "/api/assets" { + if r.Method == "PATCH" && r.URL.Path == "/api/assets" { w.WriteHeader(http.StatusNoContent) return } @@ -667,7 +667,7 @@ func TestHandleGetThumbnailHiddenLibraryAssetReturnsNotFound(t *testing.T) { func TestHandleUpdateLocationHiddenLibraryAssetReturnsNotFound(t *testing.T) { immichWasCalled := false handlers, mux := newTestHandlersWithMockImmich(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == "PUT" && r.URL.Path == "/api/assets" { + if r.Method == "PATCH" && r.URL.Path == "/api/assets" { immichWasCalled = true w.WriteHeader(http.StatusNoContent) return diff --git a/backend/immichClient.go b/backend/immichClient.go index cdfd032..3b0ac14 100644 --- a/backend/immichClient.go +++ b/backend/immichClient.go @@ -141,7 +141,8 @@ func (c *ImmichClient) bulkUpdateLocation(ctx context.Context, ids []string, lat "longitude": lon, } - resp, err := c.doRequest(ctx, "PUT", "/api/assets", payload) + // PATCH (not PUT): Immich v3 deprecated the PUT asset routes in favor of PATCH. + resp, err := c.doRequest(ctx, "PATCH", "/api/assets", payload) if err != nil { return err } @@ -239,19 +240,29 @@ func (c *ImmichClient) getTags(ctx context.Context) ([]ImmichTagResponse, error) } func (c *ImmichClient) getTagAssetIDs(ctx context.Context, tagID string) ([]string, error) { - const tagSearchPageSize = 1000 - const tagSearchMaxPages = 1000 + return c.searchAssetIDs(ctx, "tagIds", tagID) +} + +// getAlbumAssetIDs lists an album's asset IDs via search/metadata rather than the +// album detail endpoint: Immich v3 removed the nested assets array from +// GET /api/albums/{id}, so that path now returns zero assets. +func (c *ImmichClient) getAlbumAssetIDs(ctx context.Context, albumID string) ([]string, error) { + return c.searchAssetIDs(ctx, "albumIds", albumID) +} + +func (c *ImmichClient) searchAssetIDs(ctx context.Context, filterKey, filterID string) ([]string, error) { + const searchPageSize = 1000 + const searchMaxPages = 1000 payload := map[string]interface{}{ - "tagIds": []string{tagID}, + filterKey: []string{filterID}, "type": "IMAGE", "visibility": "timeline", - "size": tagSearchPageSize, - "page": 1, + "size": searchPageSize, } var ids []string - for page := 1; page <= tagSearchMaxPages; page++ { + for page := 1; page <= searchMaxPages; page++ { payload["page"] = page resp, err := c.doRequest(ctx, "POST", "/api/search/metadata", payload) if err != nil { @@ -261,13 +272,13 @@ func (c *ImmichClient) getTagAssetIDs(ctx context.Context, tagID string) ([]stri if resp.StatusCode != http.StatusOK { io.Copy(io.Discard, resp.Body) resp.Body.Close() - return nil, fmt.Errorf("immich tag search returned HTTP %d", resp.StatusCode) + return nil, fmt.Errorf("immich %s search returned HTTP %d", filterKey, resp.StatusCode) } var result ImmichSearchResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { resp.Body.Close() - return nil, fmt.Errorf("failed to decode tag search response: %w", err) + return nil, fmt.Errorf("failed to decode %s search response: %w", filterKey, err) } resp.Body.Close() @@ -279,29 +290,5 @@ func (c *ImmichClient) getTagAssetIDs(ctx context.Context, tagID string) ([]stri return ids, nil } } - return nil, fmt.Errorf("tag %s asset list exceeded %d pages of %d", tagID, tagSearchMaxPages, tagSearchPageSize) -} - -func (c *ImmichClient) getAlbumAssetIDs(ctx context.Context, albumID string) ([]string, error) { - resp, err := c.doRequest(ctx, "GET", "/api/albums/"+albumID+"?withoutAssets=false", nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - io.Copy(io.Discard, resp.Body) - return nil, fmt.Errorf("immich getAlbum returned HTTP %d", resp.StatusCode) - } - - var detail ImmichAlbumDetailResponse - if err := json.NewDecoder(resp.Body).Decode(&detail); err != nil { - return nil, fmt.Errorf("failed to decode album detail response: %w", err) - } - - ids := make([]string, len(detail.Assets)) - for i, a := range detail.Assets { - ids[i] = a.ID - } - return ids, nil + return nil, fmt.Errorf("%s %s asset list exceeded %d pages of %d", filterKey, filterID, searchMaxPages, searchPageSize) } diff --git a/backend/syncService_test.go b/backend/syncService_test.go index 8545a72..d0e3352 100644 --- a/backend/syncService_test.go +++ b/backend/syncService_test.go @@ -175,6 +175,16 @@ func TestSyncAssetsAPIError(t *testing.T) { } } +func searchResponseJSON(ids ...string) ImmichSearchResponse { + items := make([]ImmichAssetResponse, len(ids)) + for i, id := range ids { + items[i] = ImmichAssetResponse{ID: id} + } + var sr ImmichSearchResponse + sr.Assets.Items = items + return sr +} + func TestSyncAlbumsErrorPropagation(t *testing.T) { factory, immich := newMockImmichFactoryNoRetry(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -182,7 +192,7 @@ func TestSyncAlbumsErrorPropagation(t *testing.T) { json.NewEncoder(w).Encode([]ImmichAlbumResponse{ {ID: "album1", AlbumName: "Test", AssetCount: 1, UpdatedAt: "2024-01-02T00:00:00Z"}, }) - case "/api/albums/album1": + case "/api/search/metadata": w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"error":"fail"}`)) default: @@ -213,12 +223,12 @@ func TestSyncAlbumsFailedFetchLeavesAlbumUnsynced(t *testing.T) { json.NewEncoder(w).Encode([]ImmichAlbumResponse{ {ID: "album1", AlbumName: "Test", AssetCount: 1, UpdatedAt: "2024-06-01T00:00:00Z"}, }) - case strings.HasPrefix(r.URL.Path, "/api/albums/"): + case r.URL.Path == "/api/search/metadata": if failAssets.Load() { w.WriteHeader(http.StatusInternalServerError) return } - json.NewEncoder(w).Encode(ImmichAlbumDetailResponse{}) + json.NewEncoder(w).Encode(searchResponseJSON()) default: http.NotFound(w, r) } @@ -439,7 +449,7 @@ func newFullMockImmichFactory(t *testing.T) (*ImmichClientFactory, *ImmichClient json.NewEncoder(w).Encode([]ImmichAlbumResponse{}) case r.URL.Path == "/api/libraries" && r.Method == "GET": json.NewEncoder(w).Encode([]ImmichLibraryResponse{}) - case r.Method == "PUT" && r.URL.Path == "/api/assets": + case r.Method == "PATCH" && r.URL.Path == "/api/assets": w.WriteHeader(http.StatusNoContent) default: http.NotFound(w, r) @@ -1132,12 +1142,8 @@ func TestSyncAlbumsSuccess(t *testing.T) { json.NewEncoder(w).Encode([]ImmichAlbumResponse{ {ID: "album1", AlbumName: "Vacation", AssetCount: 2, UpdatedAt: "2024-01-02T00:00:00Z"}, }) - case r.URL.Path == "/api/albums/album1": - json.NewEncoder(w).Encode(ImmichAlbumDetailResponse{ - Assets: []struct { - ID string `json:"id"` - }{{ID: "a1"}, {ID: "a2"}}, - }) + case r.URL.Path == "/api/search/metadata": + json.NewEncoder(w).Encode(searchResponseJSON("a1", "a2")) default: http.NotFound(w, r) } @@ -1198,7 +1204,7 @@ func TestDoFullSyncWithAlbumError(t *testing.T) { func TestImmichBulkUpdateLocation(t *testing.T) { _, immich := newMockImmichFactory(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == "PUT" && r.URL.Path == "/api/assets" { + if r.Method == "PATCH" && r.URL.Path == "/api/assets" { w.WriteHeader(http.StatusNoContent) return } @@ -1366,12 +1372,8 @@ func TestImmichGetAlbums(t *testing.T) { func TestImmichGetAlbumAssetIDs(t *testing.T) { _, immich := newMockImmichFactory(t, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/albums/album1" { - json.NewEncoder(w).Encode(ImmichAlbumDetailResponse{ - Assets: []struct { - ID string `json:"id"` - }{{ID: "a1"}, {ID: "a2"}, {ID: "a3"}}, - }) + if r.URL.Path == "/api/search/metadata" { + json.NewEncoder(w).Encode(searchResponseJSON("a1", "a2", "a3")) return } http.NotFound(w, r) diff --git a/backend/types.go b/backend/types.go index 3a64aea..ccf6d82 100644 --- a/backend/types.go +++ b/backend/types.go @@ -144,12 +144,6 @@ type ImmichAlbumResponse struct { StartDate *string `json:"startDate"` } -type ImmichAlbumDetailResponse struct { - Assets []struct { - ID string `json:"id"` - } `json:"assets"` -} - type TagRow struct { ImmichID string `json:"immichID"` Name string `json:"name"` From ea032a07aeccde7025048094c85bbacae2265da7 Mon Sep 17 00:00:00 2001 From: Major Date: Fri, 3 Jul 2026 11:18:19 +0200 Subject: [PATCH 2/4] review: follow nextPage token in searchAssetIDs, assert album filter in test - Paginate searchAssetIDs like syncAssets: follow the server-provided nextPage token (not a blind increment) and stop when a page is empty, guarding against non-sequential or empty pages - TestImmichGetAlbumAssetIDs now asserts the request is a POST filtering by albumIds, so a wrong method or filter key would fail the test --- backend/immichClient.go | 13 ++++++++++++- backend/syncService_test.go | 18 +++++++++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/backend/immichClient.go b/backend/immichClient.go index 3b0ac14..6ef2ee2 100644 --- a/backend/immichClient.go +++ b/backend/immichClient.go @@ -8,6 +8,7 @@ import ( "io" "log" "net/http" + "strconv" "time" "github.com/hashicorp/go-retryablehttp" @@ -262,7 +263,8 @@ func (c *ImmichClient) searchAssetIDs(ctx context.Context, filterKey, filterID s } var ids []string - for page := 1; page <= searchMaxPages; page++ { + page := 1 + for i := 0; i < searchMaxPages; i++ { payload["page"] = page resp, err := c.doRequest(ctx, "POST", "/api/search/metadata", payload) if err != nil { @@ -282,6 +284,9 @@ func (c *ImmichClient) searchAssetIDs(ctx context.Context, filterKey, filterID s } resp.Body.Close() + if len(result.Assets.Items) == 0 { + return ids, nil + } for _, item := range result.Assets.Items { ids = append(ids, item.ID) } @@ -289,6 +294,12 @@ func (c *ImmichClient) searchAssetIDs(ctx context.Context, filterKey, filterID s if result.Assets.NextPage == nil { return ids, nil } + // Follow the server-provided page token rather than assuming it is sequential. + next, err := strconv.Atoi(*result.Assets.NextPage) + if err != nil { + return nil, fmt.Errorf("%s search: unexpected non-numeric nextPage token %q", filterKey, *result.Assets.NextPage) + } + page = next } return nil, fmt.Errorf("%s %s asset list exceeded %d pages of %d", filterKey, filterID, searchMaxPages, searchPageSize) } diff --git a/backend/syncService_test.go b/backend/syncService_test.go index d0e3352..2fdb904 100644 --- a/backend/syncService_test.go +++ b/backend/syncService_test.go @@ -1372,11 +1372,23 @@ func TestImmichGetAlbums(t *testing.T) { func TestImmichGetAlbumAssetIDs(t *testing.T) { _, immich := newMockImmichFactory(t, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/search/metadata" { - json.NewEncoder(w).Encode(searchResponseJSON("a1", "a2", "a3")) + if r.URL.Path != "/api/search/metadata" { + http.NotFound(w, r) return } - http.NotFound(w, r) + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + if _, ok := body["tagIds"]; ok { + t.Errorf("album search must filter by albumIds, not tagIds: %v", body) + } + albumIDs, _ := body["albumIds"].([]interface{}) + if len(albumIDs) != 1 || albumIDs[0] != "album1" { + t.Errorf("expected albumIds [album1], got %v", body["albumIds"]) + } + json.NewEncoder(w).Encode(searchResponseJSON("a1", "a2", "a3")) }) ids, err := immich.getAlbumAssetIDs(context.Background(), "album1") From a185a431b92613fde31e981fb1ab24ad0335d91c Mon Sep 17 00:00:00 2001 From: Major Date: Fri, 3 Jul 2026 11:28:33 +0200 Subject: [PATCH 3/4] review: test searchAssetIDs pagination, simplify page loop - Add TestSearchAssetIDsFollowsNextPage (multi-page: follows the nextPage token to page 2 and terminates) and TestSearchAssetIDsRejectsNonNumericNextPage, covering the token-following loop that had no direct test - Replace the `for i := 0; i < searchMaxPages; i++` bound loop (unused i) with `for range searchMaxPages` --- backend/immichClient.go | 2 +- backend/syncService_test.go | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/backend/immichClient.go b/backend/immichClient.go index 6ef2ee2..3b63579 100644 --- a/backend/immichClient.go +++ b/backend/immichClient.go @@ -264,7 +264,7 @@ func (c *ImmichClient) searchAssetIDs(ctx context.Context, filterKey, filterID s var ids []string page := 1 - for i := 0; i < searchMaxPages; i++ { + for range searchMaxPages { payload["page"] = page resp, err := c.doRequest(ctx, "POST", "/api/search/metadata", payload) if err != nil { diff --git a/backend/syncService_test.go b/backend/syncService_test.go index 2fdb904..9ef3f32 100644 --- a/backend/syncService_test.go +++ b/backend/syncService_test.go @@ -1400,6 +1400,55 @@ func TestImmichGetAlbumAssetIDs(t *testing.T) { } } +func TestSearchAssetIDsFollowsNextPage(t *testing.T) { + var requested []int + _, immich := newMockImmichFactory(t, func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + page := int(body["page"].(float64)) + requested = append(requested, page) + + resp := ImmichSearchResponse{} + switch page { + case 1: + resp.Assets.Items = []ImmichAssetResponse{{ID: "a1"}, {ID: "a2"}} + next := "2" + resp.Assets.NextPage = &next + case 2: + resp.Assets.Items = []ImmichAssetResponse{{ID: "a3"}} + // NextPage nil -> terminate. + default: + t.Errorf("unexpected page requested: %d", page) + } + json.NewEncoder(w).Encode(resp) + }) + + ids, err := immich.getTagAssetIDs(context.Background(), "tag1") + if err != nil { + t.Fatalf("getTagAssetIDs: %v", err) + } + if len(ids) != 3 { + t.Errorf("expected 3 IDs across 2 pages, got %d (%v)", len(ids), ids) + } + if len(requested) != 2 || requested[0] != 1 || requested[1] != 2 { + t.Errorf("expected pages [1 2] to be requested via nextPage token, got %v", requested) + } +} + +func TestSearchAssetIDsRejectsNonNumericNextPage(t *testing.T) { + _, immich := newMockImmichFactory(t, func(w http.ResponseWriter, r *http.Request) { + resp := ImmichSearchResponse{} + resp.Assets.Items = []ImmichAssetResponse{{ID: "a1"}} + token := "not-a-number" + resp.Assets.NextPage = &token + json.NewEncoder(w).Encode(resp) + }) + + if _, err := immich.getTagAssetIDs(context.Background(), "tag1"); err == nil { + t.Error("expected error on non-numeric nextPage token") + } +} + func TestSyncStacksWithStacks(t *testing.T) { ctx := context.Background() From 85204b90360d83547ba1f84215fccd9a80938490 Mon Sep 17 00:00:00 2001 From: Major Date: Fri, 3 Jul 2026 11:30:40 +0200 Subject: [PATCH 4/4] review: assert request-body decode + albumIds type in tests Make mock-handler assertions actionable: check the JSON decode error and the albumIds/page type assertions instead of silently continuing with a nil map, so a malformed request fails with a clear message. --- backend/syncService_test.go | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/backend/syncService_test.go b/backend/syncService_test.go index 9ef3f32..e294819 100644 --- a/backend/syncService_test.go +++ b/backend/syncService_test.go @@ -1380,12 +1380,15 @@ func TestImmichGetAlbumAssetIDs(t *testing.T) { t.Errorf("expected POST, got %s", r.Method) } var body map[string]interface{} - json.NewDecoder(r.Body).Decode(&body) + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request body: %v", err) + return + } if _, ok := body["tagIds"]; ok { t.Errorf("album search must filter by albumIds, not tagIds: %v", body) } - albumIDs, _ := body["albumIds"].([]interface{}) - if len(albumIDs) != 1 || albumIDs[0] != "album1" { + albumIDs, ok := body["albumIds"].([]interface{}) + if !ok || len(albumIDs) != 1 || albumIDs[0] != "album1" { t.Errorf("expected albumIds [album1], got %v", body["albumIds"]) } json.NewEncoder(w).Encode(searchResponseJSON("a1", "a2", "a3")) @@ -1404,8 +1407,16 @@ func TestSearchAssetIDsFollowsNextPage(t *testing.T) { var requested []int _, immich := newMockImmichFactory(t, func(w http.ResponseWriter, r *http.Request) { var body map[string]interface{} - json.NewDecoder(r.Body).Decode(&body) - page := int(body["page"].(float64)) + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request body: %v", err) + return + } + pageVal, ok := body["page"].(float64) + if !ok { + t.Errorf("request missing numeric page: %v", body) + return + } + page := int(pageVal) requested = append(requested, page) resp := ImmichSearchResponse{}