diff --git a/.gitignore b/.gitignore index f069115..6e96e43 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ CLAUDE.md changelog.md immich-stack /docs/plan +immich-openapi-specs.json REF diff --git a/cmd/config.go b/cmd/config.go index 003aa2b..892e2bf 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -35,6 +35,9 @@ var withDeleted bool var logLevel string var logFormat string var removeSingleAssetStacks bool +var filterAlbumIDs []string +var filterTakenAfter string +var filterTakenBefore string /************************************************************************************************** ** Configures the logger based on command-line flags and environment variables. Sets up the @@ -138,7 +141,7 @@ type LoadEnvConfig struct { func logStartupSummary(logger *logrus.Logger) { // Build summary based on format if format := os.Getenv("LOG_FORMAT"); format == "json" { - logger.WithFields(logrus.Fields{ + fields := logrus.Fields{ "runMode": runMode, "cronInterval": cronInterval, "logLevel": logger.GetLevel().String(), @@ -153,7 +156,17 @@ func logStartupSummary(logger *logrus.Logger) { "criteria": criteria, "parentFilenamePromote": parentFilenamePromote, "parentExtPromote": parentExtPromote, - }).Info("Configuration loaded") + } + if len(filterAlbumIDs) > 0 { + fields["filterAlbumIDs"] = filterAlbumIDs + } + if filterTakenAfter != "" { + fields["filterTakenAfter"] = filterTakenAfter + } + if filterTakenBefore != "" { + fields["filterTakenBefore"] = filterTakenBefore + } + logger.WithFields(fields).Info("Configuration loaded") } else { // Build human-readable summary var summary []string @@ -187,6 +200,15 @@ func logStartupSummary(logger *logrus.Logger) { if criteria != "" { summary = append(summary, fmt.Sprintf("criteria=%s", criteria)) } + if len(filterAlbumIDs) > 0 { + summary = append(summary, fmt.Sprintf("filter-albums=%d", len(filterAlbumIDs))) + } + if filterTakenAfter != "" { + summary = append(summary, fmt.Sprintf("filter-after=%s", filterTakenAfter)) + } + if filterTakenBefore != "" { + summary = append(summary, fmt.Sprintf("filter-before=%s", filterTakenBefore)) + } logger.Infof("Starting with config: %s", strings.Join(summary, ", ")) } @@ -277,6 +299,21 @@ func LoadEnvForTesting() LoadEnvConfig { parentExtPromote = envVal } } + if len(filterAlbumIDs) == 0 { + if envVal := os.Getenv("FILTER_ALBUM_IDS"); envVal != "" { + parts := strings.Split(envVal, ",") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + filterAlbumIDs = utils.RemoveEmptyStrings(parts) + } + } + if filterTakenAfter == "" { + filterTakenAfter = strings.TrimSpace(os.Getenv("FILTER_TAKEN_AFTER")) + } + if filterTakenBefore == "" { + filterTakenBefore = strings.TrimSpace(os.Getenv("FILTER_TAKEN_BEFORE")) + } // Log startup configuration summary logStartupSummary(logger) diff --git a/cmd/config_test.go b/cmd/config_test.go index 58c508d..2ba7949 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -59,6 +59,37 @@ func TestStartupConfigurationSummary(t *testing.T) { `"removeSingleAssetStacks":true`, }, }, + { + name: "text format with filter fields", + envVars: map[string]string{ + "API_KEY": "test-key", + "FILTER_ALBUM_IDS": "album1,album2", + "FILTER_TAKEN_AFTER": "2024-01-01T00:00:00Z", + "FILTER_TAKEN_BEFORE": "2024-12-31T23:59:59Z", + }, + wantInLog: []string{ + "Starting with config:", + "filter-albums=2", + "filter-after=2024-01-01T00:00:00Z", + "filter-before=2024-12-31T23:59:59Z", + }, + }, + { + name: "json format with filter fields", + envVars: map[string]string{ + "API_KEY": "test-key", + "LOG_FORMAT": "json", + "FILTER_ALBUM_IDS": "album1,album2,album3", + "FILTER_TAKEN_AFTER": "2024-06-01T00:00:00Z", + "FILTER_TAKEN_BEFORE": "2024-06-30T23:59:59Z", + }, + wantInLog: []string{ + "Configuration loaded", + `"filterAlbumIDs":["album1","album2","album3"]`, + `"filterTakenAfter":"2024-06-01T00:00:00Z"`, + `"filterTakenBefore":"2024-06-30T23:59:59Z"`, + }, + }, } for _, tt := range tests { @@ -342,6 +373,7 @@ func resetTestEnv() { "REPLACE_STACKS", "WITH_ARCHIVED", "WITH_DELETED", "REMOVE_SINGLE_ASSET_STACKS", "CRITERIA", "PARENT_FILENAME_PROMOTE", "PARENT_EXT_PROMOTE", + "FILTER_ALBUM_IDS", "FILTER_TAKEN_AFTER", "FILTER_TAKEN_BEFORE", } for _, env := range envVars { @@ -363,4 +395,191 @@ func resetTestEnv() { withDeleted = false logLevel = "" removeSingleAssetStacks = false + filterAlbumIDs = nil + filterTakenAfter = "" + filterTakenBefore = "" +} + +/************************************************************************************************ +** Tests for FILTER_ALBUM_IDS environment variable parsing with whitespace handling +************************************************************************************************/ + +func TestFilterAlbumIDsParsing(t *testing.T) { + tests := []struct { + name string + envValue string + expected []string + }{ + { + name: "simple list", + envValue: "album1,album2", + expected: []string{"album1", "album2"}, + }, + { + name: "with spaces after comma", + envValue: "album1, album2, album3", + expected: []string{"album1", "album2", "album3"}, + }, + { + name: "with leading and trailing spaces", + envValue: " album1 , album2 ", + expected: []string{"album1", "album2"}, + }, + { + name: "empty entries filtered", + envValue: "album1,,album2", + expected: []string{"album1", "album2"}, + }, + { + name: "only spaces filtered", + envValue: "album1, ,album2", + expected: []string{"album1", "album2"}, + }, + { + name: "single album", + envValue: "album1", + expected: []string{"album1"}, + }, + { + name: "single album with spaces", + envValue: " album1 ", + expected: []string{"album1"}, + }, + { + name: "UUIDs with spaces", + envValue: "550e8400-e29b-41d4-a716-446655440000 , 660e8400-e29b-41d4-a716-446655440001", + expected: []string{"550e8400-e29b-41d4-a716-446655440000", "660e8400-e29b-41d4-a716-446655440001"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear environment + resetTestEnv() + + // Set required env vars + os.Setenv("API_KEY", "test-key") + os.Setenv("FILTER_ALBUM_IDS", tt.envValue) + defer resetTestEnv() + + // Load config + config := LoadEnvForTesting() + + // Assert + assert.NoError(t, config.Error) + assert.Equal(t, tt.expected, filterAlbumIDs, "filterAlbumIDs should be correctly parsed and trimmed") + }) + } +} + +func TestFilterAlbumIDsEmptyEnv(t *testing.T) { + // Clear environment + resetTestEnv() + + // Set required env vars but NOT FILTER_ALBUM_IDS + os.Setenv("API_KEY", "test-key") + defer resetTestEnv() + + // Load config + config := LoadEnvForTesting() + + // Assert + assert.NoError(t, config.Error) + assert.Nil(t, filterAlbumIDs, "filterAlbumIDs should be nil when env var is not set") +} + +/************************************************************************************************ +** Tests for date filter environment variable parsing with TrimSpace +************************************************************************************************/ + +func TestDateFilterEnvVarParsing(t *testing.T) { + tests := []struct { + name string + envAfter string + envBefore string + expectedAfter string + expectedBefore string + }{ + { + name: "valid dates without spaces", + envAfter: "2024-01-01T00:00:00Z", + envBefore: "2024-12-31T23:59:59Z", + expectedAfter: "2024-01-01T00:00:00Z", + expectedBefore: "2024-12-31T23:59:59Z", + }, + { + name: "with leading space on after", + envAfter: " 2024-01-01T00:00:00Z", + envBefore: "", + expectedAfter: "2024-01-01T00:00:00Z", + expectedBefore: "", + }, + { + name: "with trailing space on after", + envAfter: "2024-01-01T00:00:00Z ", + envBefore: "", + expectedAfter: "2024-01-01T00:00:00Z", + expectedBefore: "", + }, + { + name: "with leading space on before", + envAfter: "", + envBefore: " 2024-12-31T23:59:59Z", + expectedAfter: "", + expectedBefore: "2024-12-31T23:59:59Z", + }, + { + name: "with trailing space on before", + envAfter: "", + envBefore: "2024-12-31T23:59:59Z ", + expectedAfter: "", + expectedBefore: "2024-12-31T23:59:59Z", + }, + { + name: "both with leading and trailing spaces", + envAfter: " 2024-01-01T00:00:00Z ", + envBefore: " 2024-12-31T23:59:59Z ", + expectedAfter: "2024-01-01T00:00:00Z", + expectedBefore: "2024-12-31T23:59:59Z", + }, + { + name: "empty values", + envAfter: "", + envBefore: "", + expectedAfter: "", + expectedBefore: "", + }, + { + name: "only whitespace becomes empty", + envAfter: " ", + envBefore: " ", + expectedAfter: "", + expectedBefore: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear environment + resetTestEnv() + + // Set required env vars + os.Setenv("API_KEY", "test-key") + if tt.envAfter != "" { + os.Setenv("FILTER_TAKEN_AFTER", tt.envAfter) + } + if tt.envBefore != "" { + os.Setenv("FILTER_TAKEN_BEFORE", tt.envBefore) + } + defer resetTestEnv() + + // Load config + config := LoadEnvForTesting() + + // Assert + assert.NoError(t, config.Error) + assert.Equal(t, tt.expectedAfter, filterTakenAfter, "filterTakenAfter should be trimmed") + assert.Equal(t, tt.expectedBefore, filterTakenBefore, "filterTakenBefore should be trimmed") + }) + } } diff --git a/cmd/duplicates.go b/cmd/duplicates.go index b82994b..fe3cd75 100644 --- a/cmd/duplicates.go +++ b/cmd/duplicates.go @@ -23,6 +23,13 @@ import ( func runDuplicates(cmd *cobra.Command, args []string) { logger := loadEnv() + /********************************************************************************************** + ** Warn if filter flags are set (they have no effect on this command). + **********************************************************************************************/ + if len(filterAlbumIDs) > 0 || filterTakenAfter != "" || filterTakenBefore != "" { + logger.Warnf("Filter flags (--filter-album-ids, --filter-taken-after, --filter-taken-before) have no effect on the duplicates command") + } + /********************************************************************************************** ** Support multiple API keys (comma-separated). **********************************************************************************************/ @@ -40,7 +47,7 @@ func runDuplicates(cmd *cobra.Command, args []string) { if i > 0 { logger.Infof("\n") } - client := immich.NewClient(apiURL, key, false, false, true, withArchived, withDeleted, false, logger) + client := immich.NewClient(apiURL, key, false, false, true, withArchived, withDeleted, false, nil, "", "", logger) if client == nil { logger.Errorf("Invalid client for API key: %s", key) continue diff --git a/cmd/fixtrash.go b/cmd/fixtrash.go index aa5af15..40be568 100644 --- a/cmd/fixtrash.go +++ b/cmd/fixtrash.go @@ -27,6 +27,13 @@ import ( func runFixTrash(cmd *cobra.Command, args []string) { logger := loadEnv() + /********************************************************************************************** + ** Warn if filter flags are set (they have no effect on this command). + **********************************************************************************************/ + if len(filterAlbumIDs) > 0 || filterTakenAfter != "" || filterTakenBefore != "" { + logger.Warnf("Filter flags (--filter-album-ids, --filter-taken-after, --filter-taken-before) have no effect on the fix-trash command") + } + /********************************************************************************************** ** Support multiple API keys (comma-separated). **********************************************************************************************/ @@ -44,7 +51,7 @@ func runFixTrash(cmd *cobra.Command, args []string) { if i > 0 { logger.Infof("\n") } - client := immich.NewClient(apiURL, key, false, false, dryRun, withArchived, withDeleted, false, logger) + client := immich.NewClient(apiURL, key, false, false, dryRun, withArchived, withDeleted, false, nil, "", "", logger) if client == nil { logger.Errorf("Invalid client for API key: %s", key) continue diff --git a/cmd/main.go b/cmd/main.go index 297e830..ab747b2 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -32,6 +32,9 @@ func bindFlags(rootCmd *cobra.Command) { rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "", "Log level: debug, info, warn, error (or set LOG_LEVEL env var)") rootCmd.PersistentFlags().StringVar(&logFormat, "log-format", "", "Log format: text, json (or set LOG_FORMAT env var)") rootCmd.PersistentFlags().BoolVar(&removeSingleAssetStacks, "remove-single-asset-stacks", false, "Remove stacks with only one asset (or set REMOVE_SINGLE_ASSET_STACKS=true)") + rootCmd.PersistentFlags().StringSliceVar(&filterAlbumIDs, "filter-album-ids", nil, "Filter by album IDs or names, comma-separated (or set FILTER_ALBUM_IDS env var)") + rootCmd.PersistentFlags().StringVar(&filterTakenAfter, "filter-taken-after", "", "Filter assets taken after date, ISO 8601 (or set FILTER_TAKEN_AFTER env var)") + rootCmd.PersistentFlags().StringVar(&filterTakenBefore, "filter-taken-before", "", "Filter assets taken before date, ISO 8601 (or set FILTER_TAKEN_BEFORE env var)") } /************************************************************************************************** diff --git a/cmd/main_test.go b/cmd/main_test.go index 27b31a9..17263d0 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -6,9 +6,12 @@ package main import ( "io" + "os" + "testing" "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" ) /************************************************************************************************** @@ -35,3 +38,144 @@ func CreateTestableRootCommand() *cobra.Command { func configureLoggerForTesting(output io.Writer) *logrus.Logger { return configureLoggerWithOutput(output) } + +/************************************************************************************************ +** Tests for filter flags binding and parsing +************************************************************************************************/ + +func TestFilterFlagsBinding(t *testing.T) { + tests := []struct { + name string + args []string + expectedAlbum []string + expectedAfter string + expectedBefore string + }{ + { + name: "album flag single value", + args: []string{"--filter-album-ids=album1"}, + expectedAlbum: []string{"album1"}, + expectedAfter: "", + expectedBefore: "", + }, + { + name: "album flag multiple values", + args: []string{"--filter-album-ids=album1,album2"}, + expectedAlbum: []string{"album1", "album2"}, + expectedAfter: "", + expectedBefore: "", + }, + { + name: "album flag with UUID", + args: []string{"--filter-album-ids=550e8400-e29b-41d4-a716-446655440000"}, + expectedAlbum: []string{"550e8400-e29b-41d4-a716-446655440000"}, + expectedAfter: "", + expectedBefore: "", + }, + { + name: "date flags both set", + args: []string{"--filter-taken-after=2024-01-01T00:00:00Z", "--filter-taken-before=2024-12-31T23:59:59Z"}, + expectedAlbum: nil, + expectedAfter: "2024-01-01T00:00:00Z", + expectedBefore: "2024-12-31T23:59:59Z", + }, + { + name: "only taken-after flag", + args: []string{"--filter-taken-after=2024-06-15T12:00:00Z"}, + expectedAlbum: nil, + expectedAfter: "2024-06-15T12:00:00Z", + expectedBefore: "", + }, + { + name: "only taken-before flag", + args: []string{"--filter-taken-before=2024-06-15T12:00:00Z"}, + expectedAlbum: nil, + expectedAfter: "", + expectedBefore: "2024-06-15T12:00:00Z", + }, + { + name: "all filter flags combined", + args: []string{"--filter-album-ids=album1,album2", "--filter-taken-after=2024-01-01T00:00:00Z", "--filter-taken-before=2024-12-31T23:59:59Z"}, + expectedAlbum: []string{"album1", "album2"}, + expectedAfter: "2024-01-01T00:00:00Z", + expectedBefore: "2024-12-31T23:59:59Z", + }, + { + name: "no filter flags", + args: []string{}, + expectedAlbum: nil, + expectedAfter: "", + expectedBefore: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset global variables + filterAlbumIDs = nil + filterTakenAfter = "" + filterTakenBefore = "" + + // Create a fresh command + cmd := CreateTestableRootCommand() + + // Set command to not run (we just want to test flag parsing) + cmd.Run = nil + cmd.RunE = func(c *cobra.Command, args []string) error { + return nil + } + + // Set args + cmd.SetArgs(tt.args) + + // Suppress output + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + // Execute to parse flags + err := cmd.Execute() + assert.NoError(t, err) + + // Assert + assert.Equal(t, tt.expectedAlbum, filterAlbumIDs, "filterAlbumIDs should match") + assert.Equal(t, tt.expectedAfter, filterTakenAfter, "filterTakenAfter should match") + assert.Equal(t, tt.expectedBefore, filterTakenBefore, "filterTakenBefore should match") + }) + } +} + +func TestFilterFlagsOverrideEnvVars(t *testing.T) { + // This test verifies that CLI flags take precedence over environment variables + + // Set environment variables + os.Setenv("FILTER_TAKEN_AFTER", "2023-01-01T00:00:00Z") + os.Setenv("FILTER_TAKEN_BEFORE", "2023-12-31T23:59:59Z") + defer func() { + os.Unsetenv("FILTER_TAKEN_AFTER") + os.Unsetenv("FILTER_TAKEN_BEFORE") + }() + + // Reset global variables + filterAlbumIDs = nil + filterTakenAfter = "" + filterTakenBefore = "" + + // Create command with CLI flags that should override env vars + cmd := CreateTestableRootCommand() + cmd.Run = nil + cmd.RunE = func(c *cobra.Command, args []string) error { + return nil + } + + // Set args with different values than env vars + cmd.SetArgs([]string{"--filter-taken-after=2024-06-01T00:00:00Z"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + // Execute + err := cmd.Execute() + assert.NoError(t, err) + + // CLI flag should take precedence + assert.Equal(t, "2024-06-01T00:00:00Z", filterTakenAfter, "CLI flag should override env var") +} diff --git a/cmd/stacker.go b/cmd/stacker.go index e41ce7e..09372f5 100644 --- a/cmd/stacker.go +++ b/cmd/stacker.go @@ -174,7 +174,7 @@ func runStacker(cmd *cobra.Command, args []string) { if i > 0 { logger.Infof("\n") } - client := immich.NewClient(apiURL, key, resetStacks, replaceStacks, dryRun, withArchived, withDeleted, removeSingleAssetStacks, logger) + client := immich.NewClient(apiURL, key, resetStacks, replaceStacks, dryRun, withArchived, withDeleted, removeSingleAssetStacks, filterAlbumIDs, filterTakenAfter, filterTakenBefore, logger) if client == nil { logger.Errorf("Invalid client for API key: %s", key) continue @@ -342,7 +342,7 @@ func runCronLoopForAllUsers(apiKeys []string, apiURL string, logger *logrus.Logg if i > 0 { logger.Infof("\n") } - client := immich.NewClient(apiURL, key, resetStacks, replaceStacks, dryRun, withArchived, withDeleted, removeSingleAssetStacks, logger) + client := immich.NewClient(apiURL, key, resetStacks, replaceStacks, dryRun, withArchived, withDeleted, removeSingleAssetStacks, filterAlbumIDs, filterTakenAfter, filterTakenBefore, logger) if client == nil { logger.Errorf("Invalid client for API key: %s", key) continue diff --git a/docs/api-reference/cli-usage.md b/docs/api-reference/cli-usage.md index 5075349..54a9687 100644 --- a/docs/api-reference/cli-usage.md +++ b/docs/api-reference/cli-usage.md @@ -62,6 +62,9 @@ immich-stack [command] [flags] | `--cron-interval` | `CRON_INTERVAL` | Interval in seconds for cron mode | | `--log-level` | `LOG_LEVEL` | Log level: debug, info, warn, error | | `--remove-single-asset-stacks` | `REMOVE_SINGLE_ASSET_STACKS` | Remove stacks containing only one asset | +| `--filter-album-ids` | `FILTER_ALBUM_IDS` | Filter by album IDs or names (comma-separated, OR logic) | +| `--filter-taken-after` | `FILTER_TAKEN_AFTER` | Only process assets taken after this date (ISO 8601) | +| `--filter-taken-before` | `FILTER_TAKEN_BEFORE` | Only process assets taken before this date (ISO 8601) | ### Command-Specific Notes @@ -145,6 +148,37 @@ immich-stack \ --api-key your_key ``` +### Asset Filtering + +```sh +# Filter by album ID +immich-stack \ + --filter-album-ids 550e8400-e29b-41d4-a716-446655440000 \ + --api-key your_key + +# Filter by album name +immich-stack \ + --filter-album-ids "Vacation Photos" \ + --api-key your_key + +# Filter by multiple albums (OR logic) +immich-stack \ + --filter-album-ids "album-1,Vacation Photos,Family" \ + --api-key your_key + +# Filter by date range +immich-stack \ + --filter-taken-after 2024-01-01T00:00:00Z \ + --filter-taken-before 2024-12-31T23:59:59Z \ + --api-key your_key + +# Combined: album and date filtering +immich-stack \ + --filter-album-ids "My Photos" \ + --filter-taken-after 2024-06-01T00:00:00Z \ + --api-key your_key +``` + ## Flag Precedence - Command line flags take precedence over environment variables diff --git a/docs/api-reference/environment-variables.md b/docs/api-reference/environment-variables.md index 11d0ca7..3a716d2 100644 --- a/docs/api-reference/environment-variables.md +++ b/docs/api-reference/environment-variables.md @@ -98,6 +98,50 @@ When `PARENT_FILENAME_PROMOTE` contains a numeric sequence pattern (e.g., `0000, | `WITH_ARCHIVED` | Include archived assets in processing | false | `true` | | `WITH_DELETED` | Include deleted assets in processing | false | `true` | +## Asset Filtering + +| Variable | Description | Default | Example | +| --------------------- | ----------------------------------------------------- | ------- | ------------------------ | +| `FILTER_ALBUM_IDS` | Filter by album IDs or names (comma-separated) | - | `album-uuid-1,My Photos` | +| `FILTER_TAKEN_AFTER` | Only process assets taken after this date (ISO 8601) | - | `2024-01-01T00:00:00Z` | +| `FILTER_TAKEN_BEFORE` | Only process assets taken before this date (ISO 8601) | - | `2024-12-31T23:59:59Z` | + +### Album Filtering + +Album filters accept both UUIDs and album names: + +```sh +# By UUID +FILTER_ALBUM_IDS=550e8400-e29b-41d4-a716-446655440000 + +# By name +FILTER_ALBUM_IDS=Vacation Photos + +# Multiple albums (OR logic - assets from ANY album) +FILTER_ALBUM_IDS=album-uuid-1,Vacation Photos,Family +``` + +When multiple albums are specified, assets from **any** of the albums are processed (OR logic). + +### Date Range Filtering + +Date filters use ISO 8601 (RFC3339) format: + +```sh +# Assets from 2024 only +FILTER_TAKEN_AFTER=2024-01-01T00:00:00Z +FILTER_TAKEN_BEFORE=2024-12-31T23:59:59Z + +# Assets from last month +FILTER_TAKEN_AFTER=2024-11-01T00:00:00Z +``` + +Valid date formats: + +- `2024-01-15T10:30:00Z` (UTC) +- `2024-01-15T10:30:00+00:00` (with timezone offset) +- `2024-01-15T10:30:00-05:00` (EST timezone) + ## Custom Criteria | Variable | Description | Default | Example | @@ -401,6 +445,24 @@ CRITERIA='{ }' ``` +### Asset Filtering + +```sh +# Filter by specific album +FILTER_ALBUM_IDS=550e8400-e29b-41d4-a716-446655440000 + +# Filter by multiple albums (OR logic) +FILTER_ALBUM_IDS=album-1,Vacation Photos,Family Events + +# Filter by date range +FILTER_TAKEN_AFTER=2024-01-01T00:00:00Z +FILTER_TAKEN_BEFORE=2024-12-31T23:59:59Z + +# Combined: specific album and date range +FILTER_ALBUM_IDS=My Photos +FILTER_TAKEN_AFTER=2024-06-01T00:00:00Z +``` + ## Best Practices 1. **Security** diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 7adc6fe..a7b3d31 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -88,6 +88,33 @@ WITH_ARCHIVED=true # Include archived assets WITH_DELETED=true # Include deleted assets ``` +## Asset Filtering + +Limit which assets are processed using album and date filters: + +### Filter by Album + +```sh +# Single album by UUID +FILTER_ALBUM_IDS=550e8400-e29b-41d4-a716-446655440000 + +# Single album by name +FILTER_ALBUM_IDS=Vacation Photos + +# Multiple albums (OR logic - processes assets from any of these) +FILTER_ALBUM_IDS=album-uuid-1,Vacation Photos,Family Events +``` + +### Filter by Date Range + +```sh +# Process only assets from 2024 +FILTER_TAKEN_AFTER=2024-01-01T00:00:00Z +FILTER_TAKEN_BEFORE=2024-12-31T23:59:59Z +``` + +Dates must use ISO 8601 format (e.g., `2024-01-15T10:30:00Z`). + ## Logging Configure logging output and verbosity: @@ -142,6 +169,11 @@ REPLACE_STACKS=true WITH_ARCHIVED=false WITH_DELETED=false +# Asset filtering (optional) +FILTER_ALBUM_IDS= +FILTER_TAKEN_AFTER= +FILTER_TAKEN_BEFORE= + # Logging LOG_LEVEL=info LOG_FORMAT=text diff --git a/pkg/immich/client.go b/pkg/immich/client.go index 21fbb8f..3d9f44a 100644 --- a/pkg/immich/client.go +++ b/pkg/immich/client.go @@ -39,6 +39,9 @@ type Client struct { withArchived bool withDeleted bool removeSingleAssetStacks bool + filterAlbumIDs []string + filterTakenAfter string + filterTakenBefore string logger *logrus.Logger } @@ -54,10 +57,13 @@ type Client struct { ** @param withArchived - Whether to include archived assets ** @param withDeleted - Whether to include deleted assets ** @param removeSingleAssetStacks - Whether to remove stacks with only one asset +** @param filterAlbumIDs - Filter by album IDs (empty slice means no filter) +** @param filterTakenAfter - Filter assets taken after this date (empty means no filter) +** @param filterTakenBefore - Filter assets taken before this date (empty means no filter) ** @param logger - Logger instance for output ** @return *Client - Configured Immich client instance **************************************************************************************************/ -func NewClient(apiURL, apiKey string, resetStacks bool, replaceStacks bool, dryRun bool, withArchived bool, withDeleted bool, removeSingleAssetStacks bool, logger *logrus.Logger) *Client { +func NewClient(apiURL, apiKey string, resetStacks bool, replaceStacks bool, dryRun bool, withArchived bool, withDeleted bool, removeSingleAssetStacks bool, filterAlbumIDs []string, filterTakenAfter string, filterTakenBefore string, logger *logrus.Logger) *Client { if apiKey == "" { return nil } @@ -96,6 +102,9 @@ func NewClient(apiURL, apiKey string, resetStacks bool, replaceStacks bool, dryR withArchived: withArchived, withDeleted: withDeleted, removeSingleAssetStacks: removeSingleAssetStacks, + filterAlbumIDs: filterAlbumIDs, + filterTakenAfter: filterTakenAfter, + filterTakenBefore: filterTakenBefore, logger: logger, } } @@ -238,49 +247,109 @@ func (c *Client) FetchAllStacks() (map[string]utils.TStack, error) { ** @return error - Any error that occurred during the fetch **************************************************************************************************/ func (c *Client) FetchAssets(size int, stacksMap map[string]utils.TStack) ([]utils.TAsset, error) { - var allAssets []utils.TAsset - page := 1 + // Resolve album filters (names to UUIDs) once + resolvedAlbumIDs, err := c.resolveAlbumFilters(c.filterAlbumIDs) + if err != nil { + return nil, err + } + + // Validate date filters once before processing (not inside loops) + var takenAfterTime, takenBeforeTime time.Time + if c.filterTakenAfter != "" { + takenAfterTime, err = time.Parse(time.RFC3339, c.filterTakenAfter) + if err != nil { + return nil, fmt.Errorf("invalid takenAfter date format (expected ISO 8601/RFC3339): %s", c.filterTakenAfter) + } + } + if c.filterTakenBefore != "" { + takenBeforeTime, err = time.Parse(time.RFC3339, c.filterTakenBefore) + if err != nil { + return nil, fmt.Errorf("invalid takenBefore date format (expected ISO 8601/RFC3339): %s", c.filterTakenBefore) + } + } + if c.filterTakenAfter != "" && c.filterTakenBefore != "" && !takenAfterTime.Before(takenBeforeTime) { + return nil, fmt.Errorf("takenAfter (%s) must be before takenBefore (%s)", c.filterTakenAfter, c.filterTakenBefore) + } c.logger.Infof("⬇️ Fetching assets:") - for { - c.logger.Debugf("Fetching page %d", page) - var response utils.TSearchResponse - if err := c.doRequest(http.MethodPost, "/search/metadata", map[string]interface{}{ - "size": size, - "page": page, - "order": "asc", - "type": "IMAGE", - "isVisible": true, - "withStacked": true, - "withArchived": c.withArchived, - "withDeleted": c.withDeleted, - }, &response); err != nil { - c.logger.Errorf("Error fetching assets: %v", err) - return nil, fmt.Errorf("error fetching assets: %w", err) + + // If multiple albums specified, fetch each separately and deduplicate. + // This implements OR logic: assets in album1 OR album2 OR album3. + var albumFilters [][]string + if len(resolvedAlbumIDs) == 0 { + albumFilters = [][]string{nil} // No album filter + } else if len(resolvedAlbumIDs) == 1 { + albumFilters = [][]string{resolvedAlbumIDs} + } else { + for _, albumID := range resolvedAlbumIDs { + albumFilters = append(albumFilters, []string{albumID}) } + } + + seen := make(map[string]bool) + var allAssets []utils.TAsset - // Enrich assets with stack information - for i := range response.Assets.Items { - asset := &response.Assets.Items[i] - if stack, ok := stacksMap[asset.ID]; ok { - asset.Stack = &stack + for _, albumFilter := range albumFilters { + page := 1 + for { + if len(albumFilter) > 0 { + c.logger.Debugf("Fetching page %d for album(s) %v", page, albumFilter) + } else { + c.logger.Debugf("Fetching page %d", page) + } + var response utils.TSearchResponse + + payload := map[string]interface{}{ + "size": size, + "page": page, + "order": "asc", + "type": "IMAGE", + "isVisible": true, + "withStacked": true, + "withArchived": c.withArchived, + "withDeleted": c.withDeleted, + } + if len(albumFilter) > 0 { + payload["albumIds"] = albumFilter + } + if c.filterTakenAfter != "" { + payload["takenAfter"] = c.filterTakenAfter + } + if c.filterTakenBefore != "" { + payload["takenBefore"] = c.filterTakenBefore } - } - allAssets = append(allAssets, response.Assets.Items...) + if err := c.doRequest(http.MethodPost, "/search/metadata", payload, &response); err != nil { + c.logger.Errorf("Error fetching assets: %v", err) + return nil, fmt.Errorf("error fetching assets: %w", err) + } - // Handle string nextPage: empty string means no more pages - if response.Assets.NextPage == "" || response.Assets.NextPage == "0" { - break - } - nextPageInt, err := strconv.Atoi(response.Assets.NextPage) - if err != nil || nextPageInt == 0 { - break + // Enrich assets with stack information and deduplicate + for i := range response.Assets.Items { + asset := &response.Assets.Items[i] + if seen[asset.ID] { + continue + } + seen[asset.ID] = true + if stack, ok := stacksMap[asset.ID]; ok { + asset.Stack = &stack + } + allAssets = append(allAssets, *asset) + } + + // Handle string nextPage: empty string means no more pages + if response.Assets.NextPage == "" || response.Assets.NextPage == "0" { + break + } + nextPageInt, err := strconv.Atoi(response.Assets.NextPage) + if err != nil || nextPageInt == 0 { + break + } + page = nextPageInt } - page = nextPageInt } - c.logger.Infof("🌄 %d assets fetched", len(allAssets)) + c.logger.Infof("🌄 %d assets fetched", len(allAssets)) return allAssets, nil } @@ -485,6 +554,79 @@ func (c *Client) FetchAlbums() ([]utils.TAlbum, error) { return albums, nil } +/************************************************************************************************** +** isUUID checks if a string is a valid UUID format. +**************************************************************************************************/ +func isUUID(s string) bool { + if len(s) != 36 { + return false + } + for i, c := range s { + if i == 8 || i == 13 || i == 18 || i == 23 { + if c != '-' { + return false + } + } else { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + } + return true +} + +/************************************************************************************************** +** resolveAlbumFilters resolves album filters that may be names or UUIDs to actual UUIDs. +** If a filter value is already a UUID, it's used directly. Otherwise, it's treated as an +** album name and resolved by fetching albums from the API. +** +** @param filters - List of album IDs or names +** @return []string - List of resolved album UUIDs +** @return error - Error if album name resolution fails +**************************************************************************************************/ +func (c *Client) resolveAlbumFilters(filters []string) ([]string, error) { + if len(filters) == 0 { + return nil, nil + } + + var resolved []string + var namesToResolve []string + + for _, filter := range filters { + if isUUID(filter) { + resolved = append(resolved, filter) + } else { + namesToResolve = append(namesToResolve, filter) + } + } + + if len(namesToResolve) == 0 { + return resolved, nil + } + + albums, err := c.FetchAlbums() + if err != nil { + return nil, fmt.Errorf("failed to resolve album names: %w", err) + } + + for _, name := range namesToResolve { + found := false + for _, album := range albums { + if album.AlbumName == name { + resolved = append(resolved, album.ID) + found = true + c.logger.Debugf("Resolved album name %q to ID %s", name, album.ID) + break + } + } + if !found { + return nil, fmt.Errorf("album not found: %q", name) + } + } + + return resolved, nil +} + /************************************************************************************************** ** FetchAlbumAssets fetches all assets in a specific album. ** diff --git a/pkg/immich/client_test.go b/pkg/immich/client_test.go index 7c844dd..5b52707 100644 --- a/pkg/immich/client_test.go +++ b/pkg/immich/client_test.go @@ -58,7 +58,7 @@ func TestNewClient(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Act - client := NewClient(tt.apiURL, tt.apiKey, tt.resetStacks, tt.replaceStacks, tt.dryRun, true, false, false, logrus.New()) + client := NewClient(tt.apiURL, tt.apiKey, tt.resetStacks, tt.replaceStacks, tt.dryRun, true, false, false, nil, "", "", logrus.New()) // Assert if tt.wantErr { @@ -123,6 +123,37 @@ func (m *mockTransport) RoundTrip(*http.Request) (*http.Response, error) { return m.response, nil } +// mockTransportSeq allows returning different responses for sequential requests +type mockTransportSeq struct { + responses []*http.Response + errors []error + index int +} + +func (m *mockTransportSeq) RoundTrip(*http.Request) (*http.Response, error) { + if m.index >= len(m.responses) { + // Return last response if we've exhausted the list + idx := len(m.responses) - 1 + if idx >= 0 && m.errors != nil && idx < len(m.errors) && m.errors[idx] != nil { + return nil, m.errors[idx] + } + if idx >= 0 { + return m.responses[idx], nil + } + return nil, nil + } + resp := m.responses[m.index] + var err error + if m.errors != nil && m.index < len(m.errors) { + err = m.errors[m.index] + } + m.index++ + if err != nil { + return nil, err + } + return resp, nil +} + func TestFetchAllStacks(t *testing.T) { tests := []struct { name string @@ -254,3 +285,624 @@ func TestModifyStack(t *testing.T) { }) } } + +/************************************************************************************************ +** Tests for UUID validation function +************************************************************************************************/ + +func TestIsUUID(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + // Valid UUIDs + {"valid lowercase", "550e8400-e29b-41d4-a716-446655440000", true}, + {"valid uppercase", "550E8400-E29B-41D4-A716-446655440000", true}, + {"valid mixed case", "550e8400-E29B-41d4-A716-446655440000", true}, + + // Invalid UUIDs + {"empty string", "", false}, + {"too short", "550e8400-e29b", false}, + {"too long", "550e8400-e29b-41d4-a716-446655440000-extra", false}, + {"invalid hex char G", "550e8400-e29b-41d4-a716-44665544000G", false}, + {"invalid hex char Z", "550e8400-e29b-41d4-a716-44665544000Z", false}, + {"missing dashes", "550e8400e29b41d4a716446655440000", false}, + {"wrong dash position", "550e840-0e29b-41d4-a716-446655440000", false}, + {"dash in wrong place", "550e84000e29b-41d4-a716-44665544000", false}, + {"album name", "My Vacation Photos", false}, + {"spaces instead of dashes", "550e8400 e29b 41d4 a716 446655440000", false}, + {"partial uuid", "550e8400-e29b-41d4", false}, + {"just dashes", "------------------------------------", false}, + {"numbers only wrong length", "12345678901234567890123456789012345", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isUUID(tt.input) + assert.Equal(t, tt.expected, result, "isUUID(%q) should return %v", tt.input, tt.expected) + }) + } +} + +/************************************************************************************************ +** Tests for album filter resolution +************************************************************************************************/ + +func TestResolveAlbumFilters(t *testing.T) { + tests := []struct { + name string + filters []string + albumsResponse string + expected []string + wantErr bool + errContains string + }{ + { + name: "empty filters", + filters: []string{}, + expected: nil, + wantErr: false, + }, + { + name: "nil filters", + filters: nil, + expected: nil, + wantErr: false, + }, + { + name: "single UUID passthrough", + filters: []string{"550e8400-e29b-41d4-a716-446655440000"}, + expected: []string{"550e8400-e29b-41d4-a716-446655440000"}, + wantErr: false, + }, + { + name: "multiple UUIDs passthrough", + filters: []string{"550e8400-e29b-41d4-a716-446655440000", "660e8400-e29b-41d4-a716-446655440001"}, + expected: []string{"550e8400-e29b-41d4-a716-446655440000", "660e8400-e29b-41d4-a716-446655440001"}, + wantErr: false, + }, + { + name: "single name resolved", + filters: []string{"Vacation"}, + albumsResponse: `[ + {"id": "album-uuid-vacation", "albumName": "Vacation"}, + {"id": "album-uuid-work", "albumName": "Work"} + ]`, + expected: []string{"album-uuid-vacation"}, + wantErr: false, + }, + { + name: "mixed UUID and name", + filters: []string{"550e8400-e29b-41d4-a716-446655440000", "Vacation"}, + albumsResponse: `[ + {"id": "album-uuid-vacation", "albumName": "Vacation"} + ]`, + expected: []string{"550e8400-e29b-41d4-a716-446655440000", "album-uuid-vacation"}, + wantErr: false, + }, + { + name: "album not found", + filters: []string{"NonExistent"}, + albumsResponse: `[ + {"id": "album-uuid-1", "albumName": "Vacation"} + ]`, + wantErr: true, + errContains: "album not found", + }, + { + name: "case sensitive - lowercase not found", + filters: []string{"vacation"}, + albumsResponse: `[ + {"id": "album-uuid-1", "albumName": "Vacation"} + ]`, + wantErr: true, + errContains: "album not found", + }, + { + name: "multiple names resolved", + filters: []string{"Vacation", "Work"}, + albumsResponse: `[ + {"id": "album-uuid-vacation", "albumName": "Vacation"}, + {"id": "album-uuid-work", "albumName": "Work"} + ]`, + expected: []string{"album-uuid-vacation", "album-uuid-work"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create client with mock transport + client := &Client{ + apiKey: "test", + apiURL: "http://test/api", + logger: logrus.New(), + client: &http.Client{ + Transport: &mockTransport{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(tt.albumsResponse)), + }, + }, + }, + } + + // Act + result, err := client.resolveAlbumFilters(tt.filters) + + // Assert + if tt.wantErr { + assert.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +/************************************************************************************************ +** Tests for date validation in FetchAssets +************************************************************************************************/ + +func TestFetchAssetsDateValidation(t *testing.T) { + tests := []struct { + name string + takenAfter string + takenBefore string + wantErr bool + errContains string + }{ + { + name: "empty dates - no validation", + takenAfter: "", + takenBefore: "", + wantErr: false, + }, + { + name: "valid takenAfter only", + takenAfter: "2024-01-01T00:00:00Z", + takenBefore: "", + wantErr: false, + }, + { + name: "valid takenBefore only", + takenAfter: "", + takenBefore: "2024-12-31T23:59:59Z", + wantErr: false, + }, + { + name: "both valid dates", + takenAfter: "2024-01-01T00:00:00Z", + takenBefore: "2024-12-31T23:59:59Z", + wantErr: false, + }, + { + name: "valid date with timezone offset", + takenAfter: "2024-01-01T00:00:00+05:30", + takenBefore: "", + wantErr: false, + }, + { + name: "invalid takenAfter - date only", + takenAfter: "2024-01-01", + takenBefore: "", + wantErr: true, + errContains: "invalid takenAfter date format", + }, + { + name: "invalid takenBefore - human readable", + takenAfter: "", + takenBefore: "Jan 1, 2024", + wantErr: true, + errContains: "invalid takenBefore date format", + }, + { + name: "invalid takenAfter - random string", + takenAfter: "not-a-date", + takenBefore: "", + wantErr: true, + errContains: "invalid takenAfter date format", + }, + { + name: "invalid takenBefore - unix timestamp", + takenAfter: "", + takenBefore: "1704067200", + wantErr: true, + errContains: "invalid takenBefore date format", + }, + { + name: "invalid takenAfter - missing timezone", + takenAfter: "2024-01-01T00:00:00", + takenBefore: "", + wantErr: true, + errContains: "invalid takenAfter date format", + }, + { + name: "inverted dates - takenAfter after takenBefore", + takenAfter: "2024-12-31T23:59:59Z", + takenBefore: "2024-01-01T00:00:00Z", + wantErr: true, + errContains: "takenAfter", + }, + { + name: "same date - takenAfter equals takenBefore", + takenAfter: "2024-06-15T12:00:00Z", + takenBefore: "2024-06-15T12:00:00Z", + wantErr: true, + errContains: "takenAfter", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create client with mock transport that returns empty assets + client := &Client{ + apiKey: "test", + apiURL: "http://test/api", + logger: logrus.New(), + filterTakenAfter: tt.takenAfter, + filterTakenBefore: tt.takenBefore, + client: &http.Client{ + Transport: &mockTransport{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"assets": {"items": [], "nextPage": ""}}`)), + }, + }, + }, + } + + // Act + _, err := client.FetchAssets(10, make(map[string]utils.TStack)) + + // Assert + if tt.wantErr { + assert.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +/************************************************************************************************ +** Tests for FetchAssets album filter building and deduplication +************************************************************************************************/ + +func TestFetchAssetsWithAlbumFilters(t *testing.T) { + // Standard assets response + assetsResponse := `{"assets": {"items": [ + {"id": "asset-1", "originalFileName": "photo1.jpg"}, + {"id": "asset-2", "originalFileName": "photo2.jpg"} + ], "nextPage": ""}}` + + tests := []struct { + name string + filterAlbumIDs []string + responses []string + expectedCount int + }{ + { + name: "no album filter", + filterAlbumIDs: nil, + responses: []string{assetsResponse}, + expectedCount: 2, + }, + { + name: "single album filter", + filterAlbumIDs: []string{"550e8400-e29b-41d4-a716-446655440000"}, + responses: []string{assetsResponse}, + expectedCount: 2, + }, + { + name: "multiple album filters - same assets deduped", + filterAlbumIDs: []string{"550e8400-e29b-41d4-a716-446655440000", "660e8400-e29b-41d4-a716-446655440001"}, + responses: []string{assetsResponse, assetsResponse}, // Both albums return same assets + expectedCount: 2, // Should be deduped to 2 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Build responses slice + var httpResponses []*http.Response + for _, resp := range tt.responses { + httpResponses = append(httpResponses, &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(resp)), + }) + } + + client := &Client{ + apiKey: "test", + apiURL: "http://test/api", + logger: logrus.New(), + filterAlbumIDs: tt.filterAlbumIDs, + client: &http.Client{ + Transport: &mockTransportSeq{responses: httpResponses}, + }, + } + + // Act + assets, err := client.FetchAssets(10, make(map[string]utils.TStack)) + + // Assert + require.NoError(t, err) + assert.Len(t, assets, tt.expectedCount) + }) + } +} + +func TestFetchAssetsDeduplication(t *testing.T) { + // First album returns asset-1 and asset-2 + album1Response := `{"assets": {"items": [ + {"id": "asset-1", "originalFileName": "photo1.jpg"}, + {"id": "asset-2", "originalFileName": "photo2.jpg"} + ], "nextPage": ""}}` + + // Second album returns asset-2 and asset-3 (asset-2 is duplicate) + album2Response := `{"assets": {"items": [ + {"id": "asset-2", "originalFileName": "photo2.jpg"}, + {"id": "asset-3", "originalFileName": "photo3.jpg"} + ], "nextPage": ""}}` + + client := &Client{ + apiKey: "test", + apiURL: "http://test/api", + logger: logrus.New(), + filterAlbumIDs: []string{"550e8400-e29b-41d4-a716-446655440000", "660e8400-e29b-41d4-a716-446655440001"}, + client: &http.Client{ + Transport: &mockTransportSeq{ + responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(album1Response))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(album2Response))}, + }, + }, + }, + } + + // Act + assets, err := client.FetchAssets(10, make(map[string]utils.TStack)) + + // Assert + require.NoError(t, err) + assert.Len(t, assets, 3, "Should have 3 unique assets (asset-1, asset-2, asset-3)") + + // Verify specific assets + assetIDs := make(map[string]bool) + for _, asset := range assets { + assetIDs[asset.ID] = true + } + assert.True(t, assetIDs["asset-1"]) + assert.True(t, assetIDs["asset-2"]) + assert.True(t, assetIDs["asset-3"]) +} + +func TestFetchAssetsPagination(t *testing.T) { + page1 := `{"assets": {"items": [{"id": "asset-1"}], "nextPage": "2"}}` + page2 := `{"assets": {"items": [{"id": "asset-2"}], "nextPage": "3"}}` + page3 := `{"assets": {"items": [{"id": "asset-3"}], "nextPage": ""}}` + + tests := []struct { + name string + responses []string + expectedCount int + }{ + { + name: "single page - empty nextPage", + responses: []string{`{"assets": {"items": [{"id": "asset-1"}], "nextPage": ""}}`}, + expectedCount: 1, + }, + { + name: "single page - zero nextPage", + responses: []string{`{"assets": {"items": [{"id": "asset-1"}], "nextPage": "0"}}`}, + expectedCount: 1, + }, + { + name: "multiple pages", + responses: []string{page1, page2, page3}, + expectedCount: 3, + }, + { + name: "invalid nextPage stops pagination", + responses: []string{`{"assets": {"items": [{"id": "asset-1"}], "nextPage": "invalid"}}`}, + expectedCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var httpResponses []*http.Response + for _, resp := range tt.responses { + httpResponses = append(httpResponses, &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(resp)), + }) + } + + client := &Client{ + apiKey: "test", + apiURL: "http://test/api", + logger: logrus.New(), + client: &http.Client{ + Transport: &mockTransportSeq{responses: httpResponses}, + }, + } + + // Act + assets, err := client.FetchAssets(10, make(map[string]utils.TStack)) + + // Assert + require.NoError(t, err) + assert.Len(t, assets, tt.expectedCount) + }) + } +} + +func TestFetchAssetsStackEnrichment(t *testing.T) { + assetsResponse := `{"assets": {"items": [ + {"id": "asset-1", "originalFileName": "photo1.jpg"}, + {"id": "asset-2", "originalFileName": "photo2.jpg"} + ], "nextPage": ""}}` + + // Create stacksMap with stack info for asset-1 + stacksMap := map[string]utils.TStack{ + "asset-1": { + ID: "stack-123", + PrimaryAssetID: "asset-1", + }, + } + + client := &Client{ + apiKey: "test", + apiURL: "http://test/api", + logger: logrus.New(), + client: &http.Client{ + Transport: &mockTransport{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(assetsResponse)), + }, + }, + }, + } + + // Act + assets, err := client.FetchAssets(10, stacksMap) + + // Assert + require.NoError(t, err) + assert.Len(t, assets, 2) + + // asset-1 should have stack info + var asset1, asset2 *utils.TAsset + for i := range assets { + if assets[i].ID == "asset-1" { + asset1 = &assets[i] + } else if assets[i].ID == "asset-2" { + asset2 = &assets[i] + } + } + + require.NotNil(t, asset1) + require.NotNil(t, asset2) + assert.NotNil(t, asset1.Stack, "asset-1 should have stack info") + assert.Equal(t, "stack-123", asset1.Stack.ID) + assert.Nil(t, asset2.Stack, "asset-2 should not have stack info") +} + +func TestFetchAssetsAlbumResolutionError(t *testing.T) { + // When album name can't be resolved, FetchAssets should return error + client := &Client{ + apiKey: "test", + apiURL: "http://test/api", + logger: logrus.New(), + filterAlbumIDs: []string{"NonExistentAlbum"}, // Name, not UUID + client: &http.Client{ + Transport: &mockTransport{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`[]`)), // Empty albums list + }, + }, + }, + } + + // Act + assets, err := client.FetchAssets(10, make(map[string]utils.TStack)) + + // Assert + assert.Error(t, err) + assert.Nil(t, assets) + assert.Contains(t, err.Error(), "album not found") +} + +/************************************************************************************************ +** Tests for resolveAlbumFilters API error handling +************************************************************************************************/ + +func TestResolveAlbumFiltersAPIError(t *testing.T) { + client := &Client{ + apiKey: "test", + apiURL: "http://test/api", + logger: logrus.New(), + client: &http.Client{ + Transport: &mockTransport{ + err: io.ErrUnexpectedEOF, // Simulate network error + }, + }, + } + + // Act - try to resolve a name (not UUID) which requires API call + result, err := client.resolveAlbumFilters([]string{"SomeAlbumName"}) + + // Assert + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "failed to resolve album names") +} + +/************************************************************************************************ +** Tests for NewClient with filter parameters +************************************************************************************************/ + +func TestNewClientWithFilterParams(t *testing.T) { + tests := []struct { + name string + filterAlbumIDs []string + filterTakenAfter string + filterTakenBefore string + }{ + { + name: "with all filter params", + filterAlbumIDs: []string{"album-1", "album-2"}, + filterTakenAfter: "2024-01-01T00:00:00Z", + filterTakenBefore: "2024-12-31T23:59:59Z", + }, + { + name: "with only album filter", + filterAlbumIDs: []string{"album-1"}, + filterTakenAfter: "", + filterTakenBefore: "", + }, + { + name: "with only date filters", + filterAlbumIDs: nil, + filterTakenAfter: "2024-01-01T00:00:00Z", + filterTakenBefore: "2024-12-31T23:59:59Z", + }, + { + name: "with no filters", + filterAlbumIDs: nil, + filterTakenAfter: "", + filterTakenBefore: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewClient( + "http://test.com", + "test-key", + false, false, false, false, false, false, + tt.filterAlbumIDs, + tt.filterTakenAfter, + tt.filterTakenBefore, + logrus.New(), + ) + + require.NotNil(t, client) + assert.Equal(t, tt.filterAlbumIDs, client.filterAlbumIDs) + assert.Equal(t, tt.filterTakenAfter, client.filterTakenAfter) + assert.Equal(t, tt.filterTakenBefore, client.filterTakenBefore) + }) + } +}