diff --git a/app/upload/run.go b/app/upload/run.go index d79961876..e62f2ff8b 100644 --- a/app/upload/run.go +++ b/app/upload/run.go @@ -557,7 +557,7 @@ func (uc *UpCmd) manageAssetTags(ctx context.Context, a *assets.Asset) { tags[i] = a.Tags[i].Name } for _, t := range a.Tags { - if uc.tagsCache.AddIDToCollection(t.Name, t, a.ID) { + if uc.tagsCache.AddIDToCollection(t.Value, t, a.ID) { // Record tag event uc.app.FileProcessor().Logger().Record(ctx, fileevent.ProcessedTagged, a.File, "tag", t.Value) } diff --git a/internal/e2e/client/DATA/fromFolder/folder-as-tags-test/2/same/telescopes_03.jpg b/internal/e2e/client/DATA/fromFolder/folder-as-tags-test/2/same/telescopes_03.jpg new file mode 100644 index 000000000..8471a6969 Binary files /dev/null and b/internal/e2e/client/DATA/fromFolder/folder-as-tags-test/2/same/telescopes_03.jpg differ diff --git a/internal/e2e/client/DATA/fromFolder/folder-as-tags-test/2/unique2/telescopes_04.jpg b/internal/e2e/client/DATA/fromFolder/folder-as-tags-test/2/unique2/telescopes_04.jpg new file mode 100644 index 000000000..41a2144cb Binary files /dev/null and b/internal/e2e/client/DATA/fromFolder/folder-as-tags-test/2/unique2/telescopes_04.jpg differ diff --git a/internal/e2e/client/DATA/fromFolder/folder-as-tags-test/one/same/telescopes_01.jpg b/internal/e2e/client/DATA/fromFolder/folder-as-tags-test/one/same/telescopes_01.jpg new file mode 100644 index 000000000..2ec5253f7 Binary files /dev/null and b/internal/e2e/client/DATA/fromFolder/folder-as-tags-test/one/same/telescopes_01.jpg differ diff --git a/internal/e2e/client/DATA/fromFolder/folder-as-tags-test/one/unique1/telescopes_02.jpg b/internal/e2e/client/DATA/fromFolder/folder-as-tags-test/one/unique1/telescopes_02.jpg new file mode 100644 index 000000000..5967d61d9 Binary files /dev/null and b/internal/e2e/client/DATA/fromFolder/folder-as-tags-test/one/unique1/telescopes_02.jpg differ diff --git a/internal/e2e/client/fromFolder_test.go b/internal/e2e/client/fromFolder_test.go index 7382747db..f913bd5c7 100644 --- a/internal/e2e/client/fromFolder_test.go +++ b/internal/e2e/client/fromFolder_test.go @@ -133,4 +133,54 @@ func Test_FromFolder(t *testing.T) { fileevent.ProcessedTagged: 0, }, false, a.FileProcessor()) }) + t.Run("folder-as-tags", func(t *testing.T) { + adm, err := getUser("admin@immich.app") + if err != nil { + t.Fatalf("can't get admin user: %v", err) + } + // A fresh user for a new test + u1, err := createUser("minimal") + if err != nil { + t.Fatalf("can't create user: %v", err) + } + + ctx := t.Context() + c, a := root.RootImmichGoCommand(ctx) + c.SetArgs([]string{ + // "--concurrent-tasks=0", // for debugging + "upload", "from-folder", + "--server=" + ImmichURL, + "--api-key=" + u1.APIKey, + "--admin-api-key=" + adm.APIKey, + "--folder-as-tags=true", + "--pause-immich-jobs=false", // has to scan sidecars to get tags immediately + "--no-ui", + "--api-trace", + "--log-level=debug", + "DATA/fromFolder/folder-as-tags-test", + }) + err = c.ExecuteContext(ctx) + if err != nil && a.Log().GetSLog() != nil { + a.Log().Error(err.Error()) + } + + if err != nil { + t.Error("Unexpected error", err) + return + } + + e2eutils.CheckResults(t, map[fileevent.Code]int64{ + fileevent.ProcessedUploadSuccess: 4, + fileevent.ProcessedAlbumAdded: 0, + fileevent.ProcessedTagged: 4, + }, false, a.FileProcessor()) + + // Map filenames to expected tags (derived from folder structure) + e2eutils.VerifyTagList(t, u1.Email, u1.Password, map[string][]string{ + "telescopes_01.jpg": {"folder-as-tags-test/one/same"}, + "telescopes_02.jpg": {"folder-as-tags-test/one/unique1"}, + "telescopes_03.jpg": {"folder-as-tags-test/2/same"}, + "telescopes_04.jpg": {"folder-as-tags-test/2/unique2"}, + }) + }) } diff --git a/internal/e2e/e2eUtils/check.go b/internal/e2e/e2eUtils/check.go index 59e322f74..d5359396c 100644 --- a/internal/e2e/e2eUtils/check.go +++ b/internal/e2e/e2eUtils/check.go @@ -28,3 +28,38 @@ func CheckResults(t *testing.T, expectedResults map[fileevent.Code]int64, forced } return r } + +// VerifyTagList checks that all assets for the given user have the expected tags +func VerifyTagList(t *testing.T, email, password string, expectedTags map[string][]string) { + assets, err := GetAllAssets(email, password) + if err != nil { + t.Fatalf("failed to get assets: %v", err) + } + + for filename, expectedTagList := range expectedTags { + asset, exists := assets[filename] + if !exists { + t.Errorf("expected asset not found: %s", filename) + continue + } + asset, err := GetAssetDetails(email, password, asset.ID) + if err != nil { + t.Errorf("failed to get details for asset %s: %v", filename, err) + continue + } + + if len(asset.Tags) != len(expectedTagList) { + t.Errorf("asset %s: expected %d tags, got %d", filename, len(expectedTagList), len(asset.Tags)) + } + + TAGLIST: + for _, expectedTag := range expectedTagList { + for _, actualTag := range asset.Tags { + if actualTag.Value == expectedTag { + continue TAGLIST + } + } + t.Errorf("asset %s: expected tag not found: %s (got: %v)\n\n", filename, expectedTag, asset.Tags) + } + } +} diff --git a/internal/e2e/e2eUtils/client.go b/internal/e2e/e2eUtils/client.go index 4d050a950..b45c9c6f6 100644 --- a/internal/e2e/e2eUtils/client.go +++ b/internal/e2e/e2eUtils/client.go @@ -22,9 +22,14 @@ func getAPIURL() string { } func do(method string, url string, body any, token Token) (*http.Response, error) { - jsonBody, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("can't post %s: %w", url, err) + var jsonBody []byte + // Don't marshal nil into JSON "null" which some endpoints do not accept + if body != nil { + var err error + jsonBody, err = json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("can't post %s: %w", url, err) + } } req, err := http.NewRequest(method, url, bytes.NewReader(jsonBody)) if err != nil { @@ -51,6 +56,10 @@ func do(method string, url string, body any, token Token) (*http.Response, error return resp, nil } +func get(url string, token Token) (*http.Response, error) { + return do(http.MethodGet, url, nil, token) +} + func post(url string, body any, token Token) (*http.Response, error) { return do(http.MethodPost, url, body, token) } diff --git a/internal/e2e/e2eUtils/getAllTags.go b/internal/e2e/e2eUtils/getAllTags.go new file mode 100644 index 000000000..ef4fcc1e4 --- /dev/null +++ b/internal/e2e/e2eUtils/getAllTags.go @@ -0,0 +1,42 @@ +package e2eutils + +import ( + "encoding/json" + "fmt" +) + +// TagSimplified represents a tag returned from the server +type TagSimplified struct { + ID string `json:"id"` + Name string `json:"name"` + Value string `json:"value"` +} + +// GetAllTags retrieves all tags for a user +// Returns a slice of tag values +func GetAllTags(email, password string) ([]string, error) { + // Login to get access token + token, err := UserLogin(email, password) + if err != nil { + return nil, fmt.Errorf("failed to login: %w", err) + } + + resp, err := get(getAPIURL()+"/tags", token) + if err != nil { + return nil, fmt.Errorf("failed to get tags: %w", err) + } + defer resp.Body.Close() + + var tags []TagSimplified + err = json.NewDecoder(resp.Body).Decode(&tags) + if err != nil { + return nil, fmt.Errorf("failed to decode tags response: %w", err) + } + + tagValues := make([]string, len(tags)) + for i, tag := range tags { + tagValues[i] = tag.Value + } + + return tagValues, nil +} diff --git a/internal/e2e/e2eUtils/getAssets.go b/internal/e2e/e2eUtils/getAssets.go index b74c8b584..273b7b296 100644 --- a/internal/e2e/e2eUtils/getAssets.go +++ b/internal/e2e/e2eUtils/getAssets.go @@ -5,29 +5,37 @@ import ( "fmt" ) +type Tag struct { + ID string `json:"id"` + Name string `json:"name"` + Value string `json:"value"` + UpdatedAt string `json:"updatedAt"` +} + // Asset represents a simplified Immich asset returned from search +// update to include albums, exifInfo, owner, people if needed or use internal/assets type Asset struct { - ID string `json:"id"` - DeviceAssetID string `json:"deviceAssetId"` - DeviceID string `json:"deviceId"` - Type string `json:"type"` - OriginalPath string `json:"originalPath"` - OriginalFileName string `json:"originalFileName"` - Resized bool `json:"resized"` - Thumbhash string `json:"thumbhash"` - FileCreatedAt string `json:"fileCreatedAt"` - FileModifiedAt string `json:"fileModifiedAt"` - LocalDateTime string `json:"localDateTime"` - UpdatedAt string `json:"updatedAt"` - IsFavorite bool `json:"isFavorite"` - IsArchived bool `json:"isArchived"` - IsTrashed bool `json:"isTrashed"` - Duration string `json:"duration"` - Checksum string `json:"checksum"` - LivePhotoVideoID string `json:"livePhotoVideoId"` - Tags []string `json:"tags"` - Rating int `json:"rating"` - Visibility string `json:"visibility"` + ID string `json:"id"` + DeviceAssetID string `json:"deviceAssetId"` + DeviceID string `json:"deviceId"` + Type string `json:"type"` + OriginalPath string `json:"originalPath"` + OriginalFileName string `json:"originalFileName"` + Resized bool `json:"resized"` + Thumbhash string `json:"thumbhash"` + FileCreatedAt string `json:"fileCreatedAt"` + FileModifiedAt string `json:"fileModifiedAt"` + LocalDateTime string `json:"localDateTime"` + UpdatedAt string `json:"updatedAt"` + IsFavorite bool `json:"isFavorite"` + IsArchived bool `json:"isArchived"` + IsTrashed bool `json:"isTrashed"` + Duration string `json:"duration"` + Checksum string `json:"checksum"` + LivePhotoVideoID string `json:"livePhotoVideoId"` + Tags []Tag `json:"tags"` + Rating int `json:"rating"` + Visibility string `json:"visibility"` } // SearchMetadataRequest represents the request body for /search/metadata @@ -95,3 +103,26 @@ func GetAllAssets(email, password string) (map[string]*Asset, error) { return assetsByName, nil } + +// GetAssetDetails complete information about a specific asset, like its tags, albums +func GetAssetDetails(email, password, assetID string) (*Asset, error) { + // Login to get access token + token, err := UserLogin(email, password) + if err != nil { + return nil, fmt.Errorf("failed to login: %w", err) + } + + resp, err := get(getAPIURL()+"/assets/"+assetID, token) + if err != nil { + return nil, fmt.Errorf("failed to get asset details: %w", err) + } + defer resp.Body.Close() + + var asset Asset + err = json.NewDecoder(resp.Body).Decode(&asset) + if err != nil { + return nil, fmt.Errorf("failed to decode asset details response: %w", err) + } + + return &asset, nil +}