Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions backend/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
66 changes: 32 additions & 34 deletions backend/immichClient.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"log"
"net/http"
"strconv"
"time"

"github.com/hashicorp/go-retryablehttp"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -261,47 +274,32 @@ 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)
}

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)
}
110 changes: 92 additions & 18 deletions backend/syncService_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,14 +175,24 @@ 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 {
case "/api/albums":
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:
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
Expand All @@ -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()

Expand Down
6 changes: 0 additions & 6 deletions backend/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading