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..3b63579 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" @@ -141,7 +142,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 +241,30 @@ 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++ { + page := 1 + for range searchMaxPages { payload["page"] = page resp, err := c.doRequest(ctx, "POST", "/api/search/metadata", payload) if err != nil { @@ -261,16 +274,19 @@ 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() + if len(result.Assets.Items) == 0 { + return ids, nil + } for _, item := range result.Assets.Items { ids = append(ids, item.ID) } @@ -278,30 +294,12 @@ func (c *ImmichClient) getTagAssetIDs(ctx context.Context, tagID string) ([]stri 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("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..e294819 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,15 +1372,26 @@ 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" { + 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{} + 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, 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")) }) ids, err := immich.getAlbumAssetIDs(context.Background(), "album1") @@ -1386,6 +1403,63 @@ 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{} + 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{} + 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() 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"`