From fe8534ab0ce2d73d3b9e2dc1f47b631c32bb92c3 Mon Sep 17 00:00:00 2001 From: Major Date: Thu, 15 May 2025 15:08:30 +0200 Subject: [PATCH 1/3] feat(stacker): add time delta feature for flexible photo grouping - Add TDelta struct for configurable time difference handling - Implement time delta support in criteria matching - Add consistent time formatting with nanosecond precision - Add comprehensive tests for time delta functionality - Fix timezone handling to always use UTC This change allows photos taken within a configurable time window to be grouped together, useful for burst shots or HDR sequences. --- README.md | 65 +++++++++++ pkg/immich/client.go | 2 +- pkg/stacker/criteria.go | 64 ++++++++--- pkg/stacker/criteria_test.go | 214 +++++++++++++++++++++++++++++++++++ pkg/utils/constants.go | 11 +- pkg/utils/types.go | 9 ++ 6 files changed, 349 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e5ed000..c086b75 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ docker run -d --name immich-stack --env-file .env -v ./logs:/app/logs ghcr.io/m | `PARENT_EXT_PROMOTE` | Parent extension promote | `.jpg,.dng` | | `WITH_ARCHIVED` | Include archived assets | `false` | | `WITH_DELETED` | Include deleted assets | `false` | +| `CRITERIA` | JSON array of custom criteria for grouping photos (see Default Configuration section) | See Default Configuration | ## Docker Compose @@ -352,6 +353,70 @@ L1010229.JPG L1010229.DNG ``` +## Default Configuration + +### Default Criteria + +By default, Immich Stack groups photos based on two criteria: + +1. Original filename (before extension) + - Splits the filename on "~" and "." delimiters + - Uses the first part (index 0) for grouping +2. Local capture time (localDateTime) + - By default, no delta is applied (exact time matching) + - Can be configured with a delta for flexible time matching + +### Time Delta Feature + +The delta feature allows for flexible time matching when grouping photos. It's particularly useful when dealing with burst photos or photos taken in quick succession that might have slight time differences. + +For example, these two timestamps would normally be considered different: + +``` +2023-08-24T17:00:15.915Z +2023-08-24T17:00:15.810Z +``` + +By setting a delta of 1000ms (1 second), both timestamps would be rounded to the nearest second and considered the same for grouping purposes: + +``` +2023-08-24T17:00:15.000Z +``` + +Delta can be configured for any time-based field: + +- `localDateTime` +- `fileCreatedAt` +- `fileModifiedAt` +- `updatedAt` + +### Custom Criteria Configuration + +You can override the default criteria by setting the `CRITERIA` environment variable with a JSON array. Example: + +```json +[ + { + "key": "originalFileName", + "split": { + "delimiters": ["~", "."], + "index": 0 + } + }, + { + "key": "localDateTime", + "delta": { + "milliseconds": 1000 + } + } +] +``` + +This configuration would: + +1. Group by the base filename (before any "~" or "." in the name) +2. Allow a 1-second (1000ms) difference in capture times when grouping + --- ## Library Structure diff --git a/pkg/immich/client.go b/pkg/immich/client.go index b3a9332..bc8d0b6 100644 --- a/pkg/immich/client.go +++ b/pkg/immich/client.go @@ -66,7 +66,7 @@ func NewClient(apiURL, apiKey string, resetStacks bool, replaceStacks bool, dryR baseURL := fmt.Sprintf("%s://%s/api", parsedURL.Scheme, parsedURL.Host) client := &http.Client{ - Timeout: 30 * time.Second, + Timeout: 600 * time.Second, Transport: &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 100, diff --git a/pkg/stacker/criteria.go b/pkg/stacker/criteria.go index e4e9080..20ed51b 100644 --- a/pkg/stacker/criteria.go +++ b/pkg/stacker/criteria.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/majorfi/immich-stack/pkg/utils" ) @@ -30,6 +31,33 @@ func getCriteriaConfig() ([]utils.TCriteria, error) { return criteria, nil } +/************************************************************************************************** +** extractTimeWithDelta extracts a time value and applies delta if configured. +** Returns a string representation of the time, adjusted by the delta if specified. +**************************************************************************************************/ +func extractTimeWithDelta(timeStr string, delta *utils.TDelta) (string, error) { + if timeStr == "" { + return "", nil + } + + t, err := time.Parse(time.RFC3339Nano, timeStr) + if err != nil { + return "", fmt.Errorf("failed to parse time %s: %w", timeStr, err) + } + + if delta == nil || delta.Milliseconds == 0 { + return t.UTC().Format(utils.TimeFormat), nil + } + + // Round to the nearest delta interval + ms := t.UnixNano() / int64(time.Millisecond) + interval := int64(delta.Milliseconds) + roundedMs := (ms / interval) * interval + + roundedTime := time.Unix(0, roundedMs*int64(time.Millisecond)).UTC() + return roundedTime.Format(utils.TimeFormat), nil +} + /************************************************************************************************** ** applyCriteria applies the configured criteria to an asset. ** Returns a list of strings that uniquely identify the asset based on the criteria. @@ -41,24 +69,32 @@ func getCriteriaConfig() ([]utils.TCriteria, error) { **************************************************************************************************/ func applyCriteria(asset utils.TAsset, criteria []utils.TCriteria) ([]string, error) { extractors := map[string]func(asset utils.TAsset, c utils.TCriteria) (string, error){ - "id": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.ID, nil }, - "deviceAssetId": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.DeviceAssetID, nil }, - "deviceId": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.DeviceID, nil }, - "duration": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.Duration, nil }, - "fileCreatedAt": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.FileCreatedAt, nil }, - "fileModifiedAt": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.FileModifiedAt, nil }, - "hasMetadata": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return boolToString(a.HasMetadata), nil }, - "isArchived": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return boolToString(a.IsArchived), nil }, - "isFavorite": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return boolToString(a.IsFavorite), nil }, - "isOffline": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return boolToString(a.IsOffline), nil }, - "isTrashed": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return boolToString(a.IsTrashed), nil }, - "localDateTime": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.LocalDateTime, nil }, + "id": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.ID, nil }, + "deviceAssetId": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.DeviceAssetID, nil }, + "deviceId": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.DeviceID, nil }, + "duration": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.Duration, nil }, + "fileCreatedAt": func(a utils.TAsset, c utils.TCriteria) (string, error) { + return extractTimeWithDelta(a.FileCreatedAt, c.Delta) + }, + "fileModifiedAt": func(a utils.TAsset, c utils.TCriteria) (string, error) { + return extractTimeWithDelta(a.FileModifiedAt, c.Delta) + }, + "hasMetadata": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return boolToString(a.HasMetadata), nil }, + "isArchived": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return boolToString(a.IsArchived), nil }, + "isFavorite": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return boolToString(a.IsFavorite), nil }, + "isOffline": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return boolToString(a.IsOffline), nil }, + "isTrashed": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return boolToString(a.IsTrashed), nil }, + "localDateTime": func(a utils.TAsset, c utils.TCriteria) (string, error) { + return extractTimeWithDelta(a.LocalDateTime, c.Delta) + }, "originalFileName": extractOriginalFileName, "originalPath": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.OriginalPath, nil }, "ownerId": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.OwnerID, nil }, "type": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.Type, nil }, - "updatedAt": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.UpdatedAt, nil }, - "checksum": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.Checksum, nil }, + "updatedAt": func(a utils.TAsset, c utils.TCriteria) (string, error) { + return extractTimeWithDelta(a.UpdatedAt, c.Delta) + }, + "checksum": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.Checksum, nil }, } result := make([]string, 0, len(criteria)) diff --git a/pkg/stacker/criteria_test.go b/pkg/stacker/criteria_test.go index 5f578ae..4e06fc3 100644 --- a/pkg/stacker/criteria_test.go +++ b/pkg/stacker/criteria_test.go @@ -1,6 +1,7 @@ package stacker import ( + "encoding/json" "testing" "time" @@ -145,3 +146,216 @@ func TestSortStackBiggestNumber(t *testing.T) { assert.Equal(t, "PXL_20250503_152823814~2.jpg", result[3].OriginalFileName) assert.Equal(t, "PXL_20250503_152823814.jpg", result[4].OriginalFileName) } + +/************************************************************************************************ +** Test cases for time delta functionality +************************************************************************************************/ +func TestExtractTimeWithDelta(t *testing.T) { + tests := []struct { + name string + timeStr string + delta *utils.TDelta + expected string + wantErr bool + }{ + { + name: "no delta returns original time in UTC", + timeStr: "2023-08-24T17:00:15.915Z", + delta: nil, + expected: "2023-08-24T17:00:15.915000000Z", + wantErr: false, + }, + { + name: "zero delta returns original time in UTC", + timeStr: "2023-08-24T17:00:15.915Z", + delta: &utils.TDelta{Milliseconds: 0}, + expected: "2023-08-24T17:00:15.915000000Z", + wantErr: false, + }, + { + name: "1000ms delta rounds down", + timeStr: "2023-08-24T17:00:15.915Z", + delta: &utils.TDelta{Milliseconds: 1000}, + expected: "2023-08-24T17:00:15.000000000Z", + wantErr: false, + }, + { + name: "500ms delta rounds to nearest interval", + timeStr: "2023-08-24T17:00:15.750Z", + delta: &utils.TDelta{Milliseconds: 500}, + expected: "2023-08-24T17:00:15.500000000Z", + wantErr: false, + }, + { + name: "empty time string returns empty", + timeStr: "", + delta: &utils.TDelta{Milliseconds: 1000}, + expected: "", + wantErr: false, + }, + { + name: "invalid time format returns error", + timeStr: "invalid-time", + delta: &utils.TDelta{Milliseconds: 1000}, + expected: "", + wantErr: true, + }, + { + name: "handles non-UTC input", + timeStr: "2023-08-24T19:00:15.915+02:00", + delta: nil, + expected: "2023-08-24T17:00:15.915000000Z", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := extractTimeWithDelta(tt.timeStr, tt.delta) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result, "Time format mismatch") + } + }) + } +} + +/************************************************************************************************ +** Test cases for time-based criteria matching with delta +************************************************************************************************/ +func TestApplyCriteriaWithTimeDelta(t *testing.T) { + tests := []struct { + name string + assets []utils.TAsset + criteria []utils.TCriteria + want int // number of groups + }{ + { + name: "exact time match", + assets: []utils.TAsset{ + { + OriginalFileName: "IMG_001.jpg", + LocalDateTime: "2023-08-24T17:00:15.000Z", + }, + { + OriginalFileName: "IMG_002.jpg", + LocalDateTime: "2023-08-24T17:00:15.000Z", + }, + }, + criteria: []utils.TCriteria{ + { + Key: "localDateTime", + }, + }, + want: 1, // Should group together + }, + { + name: "time difference within delta", + assets: []utils.TAsset{ + { + OriginalFileName: "IMG_001.jpg", + LocalDateTime: "2023-08-24T17:00:15.915Z", + }, + { + OriginalFileName: "IMG_002.jpg", + LocalDateTime: "2023-08-24T17:00:15.810Z", + }, + }, + criteria: []utils.TCriteria{ + { + Key: "localDateTime", + Delta: &utils.TDelta{ + Milliseconds: 1000, + }, + }, + }, + want: 1, // Should group together with 1s delta + }, + { + name: "time difference outside delta", + assets: []utils.TAsset{ + { + OriginalFileName: "IMG_001.jpg", + LocalDateTime: "2023-08-24T17:00:15.915Z", + }, + { + OriginalFileName: "IMG_002.jpg", + LocalDateTime: "2023-08-24T17:00:16.810Z", + }, + }, + criteria: []utils.TCriteria{ + { + Key: "localDateTime", + Delta: &utils.TDelta{ + Milliseconds: 500, + }, + }, + }, + want: 0, // Should not group together with 500ms delta + }, + { + name: "multiple time fields with delta", + assets: []utils.TAsset{ + { + OriginalFileName: "IMG_001.jpg", + LocalDateTime: "2023-08-24T17:00:15.915Z", + FileCreatedAt: "2023-08-24T17:00:15.900Z", + }, + { + OriginalFileName: "IMG_002.jpg", + LocalDateTime: "2023-08-24T17:00:15.810Z", + FileCreatedAt: "2023-08-24T17:00:15.800Z", + }, + }, + criteria: []utils.TCriteria{ + { + Key: "localDateTime", + Delta: &utils.TDelta{ + Milliseconds: 1000, + }, + }, + { + Key: "fileCreatedAt", + Delta: &utils.TDelta{ + Milliseconds: 1000, + }, + }, + }, + want: 1, // Should group together with 1s delta on both fields + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set up test criteria in environment + t.Setenv("CRITERIA", mustMarshalJSON(t, tt.criteria)) + + groups, err := StackBy(tt.assets, "", "", "") + require.NoError(t, err) + assert.Equal(t, tt.want, len(groups), "Expected %d groups but got %d", tt.want, len(groups)) + + if tt.want > 0 && len(groups) > 0 { + // Verify all assets in the group have the same rounded time + group := groups[0] + firstAsset := group[0] + firstTime, err := extractTimeWithDelta(firstAsset.LocalDateTime, tt.criteria[0].Delta) + require.NoError(t, err) + + for _, asset := range group[1:] { + assetTime, err := extractTimeWithDelta(asset.LocalDateTime, tt.criteria[0].Delta) + require.NoError(t, err) + assert.Equal(t, firstTime, assetTime, "All times in group should round to same value") + } + } + }) + } +} + +// Helper function to marshal criteria to JSON for environment variable +func mustMarshalJSON(t *testing.T, v interface{}) string { + data, err := json.Marshal(v) + require.NoError(t, err) + return string(data) +} diff --git a/pkg/utils/constants.go b/pkg/utils/constants.go index c14d381..a542f27 100644 --- a/pkg/utils/constants.go +++ b/pkg/utils/constants.go @@ -2,10 +2,16 @@ package utils import "strings" +/************************************************************************************************** +** TimeFormat is the standard format for all time values in the application. +** It uses RFC3339Nano format to ensure consistent precision across all time operations. +**************************************************************************************************/ +const TimeFormat = "2006-01-02T15:04:05.000000000Z07:00" + /************************************************************************************************** ** DefaultCriteria is the default criteria for grouping photos. It groups photos by: ** 1. Original filename (before extension) -** 2. Local capture time +** 2. Local capture time (with no delta as default) **************************************************************************************************/ var DefaultCriteria = []TCriteria{ { @@ -17,6 +23,9 @@ var DefaultCriteria = []TCriteria{ }, { Key: "localDateTime", + Delta: &TDelta{ + Milliseconds: 0, // No delta by default + }, }, } diff --git a/pkg/utils/types.go b/pkg/utils/types.go index 5a7f654..c4819cb 100644 --- a/pkg/utils/types.go +++ b/pkg/utils/types.go @@ -1,5 +1,13 @@ package utils +/************************************************************************************************** +** TDelta represents a time delta configuration for comparing time-based values. +** It allows for a buffer when comparing timestamps. +**************************************************************************************************/ +type TDelta struct { + Milliseconds int `json:"milliseconds"` // Number of milliseconds to allow as difference +} + /************************************************************************************************** ** TCriteria represents a single criterion for grouping photos. It defines how to extract ** and process values from assets for comparison and grouping. @@ -8,6 +16,7 @@ type TCriteria struct { Key string `json:"key"` // Field name to extract from asset Split *TSplit `json:"split,omitempty"` // Optional split operation Regex *TRegex `json:"regex,omitempty"` // Optional regex operation + Delta *TDelta `json:"delta,omitempty"` // Optional time delta for time-based fields } /************************************************************************************************** From 83a26a76122affe8bb2d9ff23a2e6952789759b0 Mon Sep 17 00:00:00 2001 From: Major Date: Fri, 16 May 2025 14:30:48 +0200 Subject: [PATCH 2/3] feat: doc mkdoc --- .github/workflows/docs.yml | 28 ++ README.md | 466 +------------------- docs/api-reference/asset-operations.md | 186 ++++++++ docs/api-reference/cli-usage.md | 104 +++++ docs/api-reference/environment-variables.md | 54 +++ docs/api-reference/grouping.md | 195 ++++++++ docs/api-reference/stack-operations.md | 170 +++++++ docs/contributing/development.md | 59 +++ docs/contributing/testing.md | 71 +++ docs/features/custom-criteria.md | 155 +++++++ docs/features/multi-user.md | 75 ++++ docs/features/stacking-logic.md | 51 +++ docs/getting-started/configuration.md | 117 +++++ docs/getting-started/installation.md | 93 ++++ docs/getting-started/quick-start.md | 60 +++ docs/index.md | 25 ++ docs/integration/docker-compose.md | 80 ++++ docs/integration/docker.md | 143 ++++++ docs/requirements.txt | 2 + mkdocs.yml | 63 +++ 20 files changed, 1741 insertions(+), 456 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/api-reference/asset-operations.md create mode 100644 docs/api-reference/cli-usage.md create mode 100644 docs/api-reference/environment-variables.md create mode 100644 docs/api-reference/grouping.md create mode 100644 docs/api-reference/stack-operations.md create mode 100644 docs/contributing/development.md create mode 100644 docs/contributing/testing.md create mode 100644 docs/features/custom-criteria.md create mode 100644 docs/features/multi-user.md create mode 100644 docs/features/stacking-logic.md create mode 100644 docs/getting-started/configuration.md create mode 100644 docs/getting-started/installation.md create mode 100644 docs/getting-started/quick-start.md create mode 100644 docs/index.md create mode 100644 docs/integration/docker-compose.md create mode 100644 docs/integration/docker.md create mode 100644 docs/requirements.txt create mode 100644 mkdocs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..263c2f4 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,28 @@ +name: Deploy Documentation + +on: + push: + branches: + - main + paths: + - "docs/**" + - "mkdocs.yml" + - ".github/workflows/docs.yml" + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r docs/requirements.txt + - name: Deploy to GitHub Pages + run: mkdocs gh-deploy --force diff --git a/README.md b/README.md index c086b75..0b0857a 100644 --- a/README.md +++ b/README.md @@ -17,469 +17,23 @@ EOL docker run -d --name immich-stack --env-file .env -v ./logs:/app/logs majorfi/immich-stack:latest # Or using GitHub Container Registry -docker run -d --name immich-stack --env-file .env -v ./logs:/app/logs ghcr.io/majorfi/immich-stack:latest +docker run -d --name immich-stack --env-file .env -v ./logs:/app/logs ghcr.io/majorfi/immich-stack:latest ``` -## Environment Variables +## Documentation -| Variable | Description | Default | -| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | -| `API_KEY` | Your Immich API key(s), comma-separated for multiple users | (required) | -| `API_URL` | Immich API URL | `http://immich-server:2283/api` | -| `RUN_MODE` | Run mode (`once` or `cron`) | `once` | -| `CRON_INTERVAL` | Interval in seconds for cron mode | `86400` | -| `DRY_RUN` | Don't apply changes | `false` | -| `RESET_STACKS` | Delete all existing stacks | `false` | -| `CONFIRM_RESET_STACK` | Required for RESET_STACKS. Must be set to: 'I acknowledge all my current stacks will be deleted and new one will be created' | (required for RESET_STACKS) | -| `REPLACE_STACKS` | Replace stacks for new groups | `false` | -| `PARENT_FILENAME_PROMOTE` | Parent filename promote | `edit` | -| `PARENT_EXT_PROMOTE` | Parent extension promote | `.jpg,.dng` | -| `WITH_ARCHIVED` | Include archived assets | `false` | -| `WITH_DELETED` | Include deleted assets | `false` | -| `CRITERIA` | JSON array of custom criteria for grouping photos (see Default Configuration section) | See Default Configuration | - -## Docker Compose - -```yaml -version: "3.8" - -services: - immich-stack: - container_name: immich_stack - # Use Docker Hub image (recommended for Portainer) - image: majorfi/immich-stack:main # or :latest after next release - # Or use GitHub Container Registry - # image: ghcr.io/majorfi/immich-stack:latest - environment: - - API_KEY=${API_KEY} # Can be a single key or comma-separated for multiple users - - API_URL=${API_URL:-http://immich-server:2283/api} - - DRY_RUN=${DRY_RUN:-false} - - RESET_STACKS=${RESET_STACKS:-false} - - CONFIRM_RESET_STACK=${CONFIRM_RESET_STACK} - - REPLACE_STACKS=${REPLACE_STACKS:-false} - - PARENT_FILENAME_PROMOTE=${PARENT_FILENAME_PROMOTE:-edit} - - PARENT_EXT_PROMOTE=${PARENT_EXT_PROMOTE:-.jpg,.dng} - - WITH_ARCHIVED=${WITH_ARCHIVED:-false} - - WITH_DELETED=${WITH_DELETED:-false} - - RUN_MODE=${RUN_MODE:-once} - - CRON_INTERVAL=${CRON_INTERVAL:-86400} - volumes: - - ./logs:/app/logs - restart: on-failure -``` - -## Development - -```bash -# Build locally -docker build -t immich-stack . - -# Run locally -docker run -d \ - --name immich-stack \ - --env-file .env \ - -v ./logs:/app/logs \ - immich-stack -``` - -# Immich Stack - -Immich Stack is a Go CLI tool and library for automatically grouping ("stacking") similar photos in the [Immich](https://github.com/immich-app/immich) photo management system. It provides configurable, robust, and extensible logic for grouping, sorting, and managing photo stacks via the Immich API. -This project is heavily inspired by [immich-auto-stack](github.com/tenekev/immich-auto-stack). - ---- +For detailed documentation, please visit our [documentation site](https://majorfi.github.io/immich-stack/). ## Features -- **Automatic Stacking:** Groups similar photos into stacks based on filename, date, and custom criteria. -- **Multi-User Support:** Accepts multiple API keys (comma-separated) in `API_KEY` to process multiple users sequentially. Each user's name and email are logged before running. -- **Configurable Grouping:** Supports custom grouping logic via environment variables and command-line flags. -- **Parent/Child Promotion:** Fine-grained control over which files are promoted as stack parents (by substring or extension). -- **CLI Tool:** Command-line interface for batch processing and automation. -- **Safe Operations:** Supports dry-run mode, stack replacement, and reset with user confirmation. -- **Comprehensive Logging:** Colorful, structured logs for all operations. -- **Tested and Modular:** Table-driven tests, modular helpers, and clear separation of concerns. - ---- - -## Installation - -### Prerequisites - -- [Go](https://golang.org/doc/install) (version 1.21 or later) -- [Git](https://git-scm.com/downloads) - -### From Source - -1. Clone the repository: - - ```sh - git clone https://github.com/majorfi/immich-stack.git - cd immich-stack - ``` - -2. Build the binary: - - ```sh - go build -o immich-stack ./cmd/main.go - ``` - -3. Move the binary to your PATH (optional): - ```sh - sudo mv immich-stack /usr/local/bin/ - ``` - -### Using Pre-built Binaries - -1. Download the latest release from the [Releases page](https://github.com/majorfi/immich-stack/releases) -2. Extract the archive -3. Move the binary to your PATH (optional) - -## Docker Installation - -1. Clone the repository: - - ```sh - git clone https://github.com/majorfi/immich-stack.git - cd immich-stack - ``` - -2. Create a `.env` file from the example: - - ```sh - cp .env.example .env - ``` - -3. Edit the `.env` file with your Immich credentials and preferences: - - ```sh - # Required - API_KEY=your_immich_api_key - API_URL=http://your_immich_server:3001/api - - # Optional - Default values shown - DRY_RUN=false - RESET_STACKS=false - REPLACE_STACKS=false - PARENT_FILENAME_PROMOTE=edit - PARENT_EXT_PROMOTE=.jpg,.dng - WITH_ARCHIVED=false - WITH_DELETED=false - - # Run mode settings - RUN_MODE=once # Options: once, cron - CRON_INTERVAL=86400 # in seconds, only used if RUN_MODE=cron - ``` - -4. Start the service: - - ```sh - docker compose up -d - ``` - -5. To run in cron mode, set `RUN_MODE=cron` in your `.env` file and restart: - - ```sh - docker compose down - docker compose up -d - ``` - -6. To view logs: - - ```sh - docker compose logs -f - ``` - -7. To stop the service: - - ```sh - docker compose down - ``` - -## Integration with Immich Docker Compose - -To integrate with an existing Immich installation: - -1. Copy the `immich-stack` service from our `docker-compose.yml` into your Immich's `docker-compose.yml` - -2. Add these environment variables to your Immich's `.env` file (you can also add the optional ones): - - ```sh - # Immich Stack settings - API_KEY=your_immich_api_key - API_URL=http://immich-server:2283/api # Use internal Docker network - RUN_MODE=once # Options: once, cron - CRON_INTERVAL=86400 # in seconds, only used if RUN_MODE=cron - ``` - -3. Add the service dependency in Immich's `docker-compose.yml`: - - ```yaml - immich-stack: - container_name: immich_stack - image: ghcr.io/majorfi/immich-stack:latest - environment: - - API_KEY=${API_KEY} - - API_URL=${API_URL:-http://immich-server:2283/api} - - DRY_RUN=${DRY_RUN:-false} - - RESET_STACKS=${RESET_STACKS:-false} - - CONFIRM_RESET_STACK=${CONFIRM_RESET_STACK} - - REPLACE_STACKS=${REPLACE_STACKS:-false} - - PARENT_FILENAME_PROMOTE=${PARENT_FILENAME_PROMOTE:-edit} - - PARENT_EXT_PROMOTE=${PARENT_EXT_PROMOTE:-.jpg,.dng} - - WITH_ARCHIVED=${WITH_ARCHIVED:-false} - - WITH_DELETED=${WITH_DELETED:-false} - - RUN_MODE=${RUN_MODE:-once} - - CRON_INTERVAL=${CRON_INTERVAL:-86400} - volumes: - - ./logs:/app/logs - restart: on-failure - depends_on: - immich-server: - condition: service_healthy - ``` - -4. Restart your Immich stack: - ```sh - docker compose down - docker compose up -d - ``` - -## Running - -1. Create a `.env` file in your working directory with your Immich credentials: - - ```sh - API_KEY=your_immich_api_key - API_URL=http://your_immich_server:3001/api - ``` - -2. Run the stacker: - - ```sh - # Using the binary - ./immich-stack - - # Or if installed in PATH - immich-stack - ``` - -3. Optional: Configure additional options via environment variables or flags: - - ```sh - # Example with flags - ./immich-stack --dry-run --parent-filename-promote=edit --parent-ext-promote=.jpg,.dng --with-archived --with-deleted - - # Or using environment variables - export DRY_RUN=true - export PARENT_FILENAME_PROMOTE=edit - export PARENT_EXT_PROMOTE=.jpg,.dng - export WITH_ARCHIVED=true - export WITH_DELETED=true - ./immich-stack - ``` - ---- - -## Directory Structure - -``` -immich-auto-stack/ -├── cmd/ # CLI entrypoint (main.go) -├── pkg/ -│ ├── stacker/ # Stacking logic, types, and tests -│ ├── immich/ # Immich API client and integration -│ └── utils/ # Utility helpers and logging -``` - ---- - -## CLI Usage - -The main entrypoint is `cmd/main.go`, which provides a Cobra-based CLI: - -```sh -go run ./cmd/main.go --api-key --api-url [flags] -``` - -### Flags and Environment Variables - -| Flag | Env Var | Description | -| --------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `--api-key` | `API_KEY` | Immich API key (comma-separated for multiple users) | -| `--api-url` | `API_URL` | Immich API base URL | -| `--reset-stacks` | `RESET_STACKS` | Delete all existing stacks before processing | -| `--confirm-reset-stack` | `CONFIRM_RESET_STACK` | Required for RESET_STACKS. Must be set to: 'I acknowledge all my current stacks will be deleted and new one will be created' | -| `--replace-stacks` | `REPLACE_STACKS` | Replace stacks for new groups | -| `--dry-run` | `DRY_RUN` | Simulate actions without making changes | -| `--criteria` | `CRITERIA` | Custom grouping criteria | -| `--parent-filename-promote` | `PARENT_FILENAME_PROMOTE` | Substrings to promote as parent filenames | -| `--parent-ext-promote` | `PARENT_EXT_PROMOTE` | Extensions to promote as parent files | -| `--with-archived` | `WITH_ARCHIVED` | Include archived assets in processing | -| `--with-deleted` | `WITH_DELETED` | Include deleted assets in processing | -| `--run-mode` | `RUN_MODE` | Run mode: "once" (default) or "cron" | -| `--cron-interval` | `CRON_INTERVAL` | Interval in seconds for cron mode | - -- Flags take precedence over environment variables. -- If `--reset-stacks` is set, it is only allowed in 'once' mode and requires the environment variable `CONFIRM_RESET_STACK` to be set to: - `I acknowledge all my current stacks will be deleted and new one will be created` - -Otherwise, the program will fail with a clear error. - ---- - -## Stacking Logic - -### Grouping - -- **Default Criteria:** Groups by base filename (before extension) and local capture time. -- **Custom Criteria:** Override with the `--criteria` flag or `CRITERIA` environment variable. - -### Sorting - -- **Parent Promotion:** Use `--parent-filename-promote` or `PARENT_FILENAME_PROMOTE` (comma-separated substrings) to promote files as stack parents. -- **Extension Promotion:** Use `--parent-ext-promote` or `PARENT_EXT_PROMOTE` (comma-separated extensions) to further prioritize. -- **Extension Rank:** Built-in priority: `.jpeg` > `.jpg` > `.png` > others. -- **Alphabetical:** Final tiebreaker. - -### Example - -For files: `L1010229.JPG`, `L1010229.edit.jpg`, `L1010229.DNG` -With `PARENT_FILENAME_PROMOTE=edit` and `PARENT_EXT_PROMOTE=.jpg,.dng` in your .env file, or with `--parent-filename-promote=edit` and `--parent-ext-promote=.jpg,.dng`, the order will be: - -``` -L1010229.edit.jpg -L1010229.JPG -L1010229.DNG -``` - -## Default Configuration - -### Default Criteria - -By default, Immich Stack groups photos based on two criteria: - -1. Original filename (before extension) - - Splits the filename on "~" and "." delimiters - - Uses the first part (index 0) for grouping -2. Local capture time (localDateTime) - - By default, no delta is applied (exact time matching) - - Can be configured with a delta for flexible time matching - -### Time Delta Feature - -The delta feature allows for flexible time matching when grouping photos. It's particularly useful when dealing with burst photos or photos taken in quick succession that might have slight time differences. - -For example, these two timestamps would normally be considered different: - -``` -2023-08-24T17:00:15.915Z -2023-08-24T17:00:15.810Z -``` - -By setting a delta of 1000ms (1 second), both timestamps would be rounded to the nearest second and considered the same for grouping purposes: - -``` -2023-08-24T17:00:15.000Z -``` - -Delta can be configured for any time-based field: - -- `localDateTime` -- `fileCreatedAt` -- `fileModifiedAt` -- `updatedAt` - -### Custom Criteria Configuration - -You can override the default criteria by setting the `CRITERIA` environment variable with a JSON array. Example: - -```json -[ - { - "key": "originalFileName", - "split": { - "delimiters": ["~", "."], - "index": 0 - } - }, - { - "key": "localDateTime", - "delta": { - "milliseconds": 1000 - } - } -] -``` - -This configuration would: - -1. Group by the base filename (before any "~" or "." in the name) -2. Allow a 1-second (1000ms) difference in capture times when grouping - ---- - -## Library Structure - -### pkg/stacker - -- **StackBy:** Groups assets into stacks and sorts them based on promotion rules. -- **SortStack:** Sorts assets in a stack by promotion and extension rules. -- **Types:** `Asset`, `Stack`, `Criteria`, etc. - -### pkg/immich - -- **Client:** Handles all Immich API interactions (fetch, modify, delete stacks/assets). -- **FetchAllStacks:** Retrieves all stacks, with reset and cleanup logic. -- **FetchAssets:** Retrieves all assets, paginated. -- **ModifyStack/DeleteStack:** Stack management. -- **ListDuplicates:** Finds and logs duplicate assets. - -### pkg/utils - -- **helper.go:** Array comparison, string cleaning. -- **logs.go:** Colorful, structured logging helpers (info, error, debug, pretty-print). - ---- - -## Example Workflow - -1. **Fetch all stacks and assets** from Immich. -2. **Group assets** into stacks using criteria. -3. **Sort each stack** to determine the parent and children. -4. **Apply changes** via the Immich API (create, update, or delete stacks as needed). -5. **Log all actions** and optionally run in dry-run mode for safety. - ---- - -## Testing - -- Table-driven tests for all major logic in `pkg/stacker/stacker_test.go` and `pkg/immich/client_test.go`. -- Run with: - ```sh - go test ./pkg/... - ``` - ---- - -## Extending - -- **Custom Grouping:** Edit or override criteria via command-line flags or environment variables. -- **Custom Promotion:** Set `--parent-filename-promote` and/or `--parent-ext-promote` for your workflow. -- **API Integration:** Extend `pkg/immich/client.go` for new Immich endpoints. - ---- - -## Contributing - -- Follow the code style and comment conventions (see code for examples). -- Add tests for new features. -- Document all exported functions and types. - ---- +- **Automatic Stacking:** Groups similar photos into stacks based on filename, date, and custom criteria +- **Multi-User Support:** Process multiple users sequentially with comma-separated API keys +- **Configurable Grouping:** Custom grouping logic via environment variables and command-line flags +- **Parent/Child Promotion:** Fine-grained control over stack parent selection +- **Safe Operations:** Dry-run mode, stack replacement, and reset with confirmation +- **Comprehensive Logging:** Colorful, structured logs for all operations +- **Tested and Modular:** Table-driven tests and clear separation of concerns ## License MIT - -# Note: API_KEY can be a single key or a comma-separated list of keys for multiple users. The stacker will process each user sequentially, logging the user's name and email before processing. diff --git a/docs/api-reference/asset-operations.md b/docs/api-reference/asset-operations.md new file mode 100644 index 0000000..c411e95 --- /dev/null +++ b/docs/api-reference/asset-operations.md @@ -0,0 +1,186 @@ +# Asset Operations + +The asset operations are implemented in `internal/asset/asset.go`. + +## Asset Structure + +```go +type Asset struct { + ID string `json:"id"` + DeviceAssetID string `json:"deviceAssetId"` + OwnerID string `json:"ownerId"` + DeviceID string `json:"deviceId"` + Type string `json:"type"` + OriginalPath string `json:"originalPath"` + OriginalFileName string `json:"originalFileName"` + Resized bool `json:"resized"` + FileCreatedAt time.Time `json:"fileCreatedAt"` + FileModifiedAt time.Time `json:"fileModifiedAt"` + UpdatedAt time.Time `json:"updatedAt"` + IsFavorite bool `json:"isFavorite"` + IsArchived bool `json:"isArchived"` + IsReadOnly bool `json:"isReadOnly"` + Duration string `json:"duration"` + ExifInfo ExifInfo `json:"exifInfo"` +} +``` + +## Available Operations + +### List Assets + +```go +func ListAssets(ctx context.Context, client *immich.Client, options *ListOptions) ([]Asset, error) +``` + +Lists assets with optional filtering. + +**Parameters:** + +- `ctx`: Context for the operation +- `client`: Immich API client +- `options`: List options (filters, pagination, etc.) + +**Returns:** + +- `[]Asset`: Array of assets +- `error`: Any error that occurred + +### Get Asset + +```go +func GetAsset(ctx context.Context, client *immich.Client, assetID string) (*Asset, error) +``` + +Retrieves a single asset by ID. + +**Parameters:** + +- `ctx`: Context for the operation +- `client`: Immich API client +- `assetID`: ID of the asset to retrieve + +**Returns:** + +- `*Asset`: Retrieved asset +- `error`: Any error that occurred + +### Update Asset + +```go +func UpdateAsset(ctx context.Context, client *immich.Client, asset *Asset) error +``` + +Updates an existing asset. + +**Parameters:** + +- `ctx`: Context for the operation +- `client`: Immich API client +- `asset`: Asset to update + +**Returns:** + +- `error`: Any error that occurred + +### Delete Asset + +```go +func DeleteAsset(ctx context.Context, client *immich.Client, assetID string) error +``` + +Deletes an asset by ID. + +**Parameters:** + +- `ctx`: Context for the operation +- `client`: Immich API client +- `assetID`: ID of the asset to delete + +**Returns:** + +- `error`: Any error that occurred + +## List Options + +```go +type ListOptions struct { + IsArchived *bool + IsFavorite *bool + Skip int + Take int +} +``` + +## Error Handling + +All operations handle the following error cases: + +- Invalid asset ID +- Asset not found +- API errors +- Network errors +- Invalid asset data + +## Best Practices + +1. **Error Handling** + + - Always check returned errors + - Use appropriate error handling strategies + - Log errors for debugging + +2. **Context Usage** + + - Pass context through all operations + - Use context for cancellation + - Set appropriate timeouts + +3. **Asset Filtering** + + - Use appropriate filters + - Handle pagination properly + - Consider performance implications + +4. **Asset Updates** + - Validate changes before updating + - Handle conflicts gracefully + - Maintain data consistency + +## Example Usage + +```go +// List assets with filters +options := &ListOptions{ + IsArchived: &false, + IsFavorite: &true, + Take: 100, +} +assets, err := ListAssets(ctx, client, options) +if err != nil { + log.Printf("Error listing assets: %v", err) + return +} + +// Get single asset +asset, err := GetAsset(ctx, client, "asset-id") +if err != nil { + log.Printf("Error getting asset: %v", err) + return +} + +// Update asset +asset.IsFavorite = true +err = UpdateAsset(ctx, client, asset) +if err != nil { + log.Printf("Error updating asset: %v", err) + return +} + +// Delete asset +err = DeleteAsset(ctx, client, "asset-id") +if err != nil { + log.Printf("Error deleting asset: %v", err) + return +} +``` diff --git a/docs/api-reference/cli-usage.md b/docs/api-reference/cli-usage.md new file mode 100644 index 0000000..ae8df6c --- /dev/null +++ b/docs/api-reference/cli-usage.md @@ -0,0 +1,104 @@ +# CLI Usage + +The main entrypoint is `cmd/main.go`, which provides a Cobra-based CLI. + +## Basic Usage + +```sh +# Using the binary +./immich-stack + +# Or if installed in PATH +immich-stack +``` + +## Command Line Flags + +| Flag | Env Var | Description | +| --------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `--api-key` | `API_KEY` | Immich API key (comma-separated for multiple users) | +| `--api-url` | `API_URL` | Immich API base URL | +| `--reset-stacks` | `RESET_STACKS` | Delete all existing stacks before processing | +| `--confirm-reset-stack` | `CONFIRM_RESET_STACK` | Required for RESET_STACKS. Must be set to: 'I acknowledge all my current stacks will be deleted and new one will be created' | +| `--replace-stacks` | `REPLACE_STACKS` | Replace stacks for new groups | +| `--dry-run` | `DRY_RUN` | Simulate actions without making changes | +| `--criteria` | `CRITERIA` | Custom grouping criteria | +| `--parent-filename-promote` | `PARENT_FILENAME_PROMOTE` | Substrings to promote as parent filenames | +| `--parent-ext-promote` | `PARENT_EXT_PROMOTE` | Extensions to promote as parent files | +| `--with-archived` | `WITH_ARCHIVED` | Include archived assets in processing | +| `--with-deleted` | `WITH_DELETED` | Include deleted assets in processing | +| `--run-mode` | `RUN_MODE` | Run mode: "once" (default) or "cron" | +| `--cron-interval` | `CRON_INTERVAL` | Interval in seconds for cron mode | + +## Examples + +### Basic Run + +```sh +immich-stack --api-key your_key --api-url http://immich-server:2283/api +``` + +### Dry Run + +```sh +immich-stack --dry-run --api-key your_key +``` + +### Custom Parent Selection + +```sh +immich-stack \ + --parent-filename-promote edit,raw \ + --parent-ext-promote .jpg,.dng \ + --api-key your_key +``` + +### Include Archived/Deleted + +```sh +immich-stack \ + --with-archived \ + --with-deleted \ + --api-key your_key +``` + +### Custom Criteria + +```sh +immich-stack \ + --criteria '[{"key":"originalFileName","split":{"delimiters":["~","."],"index":0}},{"key":"localDateTime","delta":{"milliseconds":1000}}]' \ + --api-key your_key +``` + +### Reset Stacks + +```sh +immich-stack \ + --reset-stacks \ + --confirm-reset-stack "I acknowledge all my current stacks will be deleted and new one will be created" \ + --api-key your_key +``` + +## Flag Precedence + +- Command line flags take precedence over environment variables +- If both are set, the command line flag value is used + +## Error Handling + +The CLI provides clear error messages for: + +- Missing required flags +- Invalid flag values +- API connection issues +- Stack operation failures + +## Exit Codes + +| Code | Description | +| ---- | --------------------- | +| 0 | Success | +| 1 | General error | +| 2 | Configuration error | +| 3 | API error | +| 4 | Stack operation error | diff --git a/docs/api-reference/environment-variables.md b/docs/api-reference/environment-variables.md new file mode 100644 index 0000000..eef5e90 --- /dev/null +++ b/docs/api-reference/environment-variables.md @@ -0,0 +1,54 @@ +# Environment Variables + +| Variable | Description | Default | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| `API_KEY` | Your Immich API key(s), comma-separated for multiple users | (required) | +| `API_URL` | Immich API URL | `http://immich-server:2283/api` | +| `RUN_MODE` | Run mode (`once` or `cron`) | `once` | +| `CRON_INTERVAL` | Interval in seconds for cron mode | `86400` | +| `DRY_RUN` | Don't apply changes | `false` | +| `RESET_STACKS` | Delete all existing stacks | `false` | +| `CONFIRM_RESET_STACK` | Required for RESET_STACKS. Must be set to: 'I acknowledge all my current stacks will be deleted and new one will be created' | (required for RESET_STACKS) | +| `REPLACE_STACKS` | Replace stacks for new groups | `false` | +| `PARENT_FILENAME_PROMOTE` | Parent filename promote | `edit` | +| `PARENT_EXT_PROMOTE` | Parent extension promote | `.jpg,.dng` | +| `WITH_ARCHIVED` | Include archived assets | `false` | +| `WITH_DELETED` | Include deleted assets | `false` | +| `CRITERIA` | JSON array of custom criteria for grouping photos (see [Custom Criteria](features/custom-criteria.md)) | See Default Configuration | + +## Default Configuration + +### Default Criteria + +By default, Immich Stack groups photos based on two criteria: + +1. Original filename (before extension) + - Splits the filename on "~" and "." delimiters + - Uses the first part (index 0) for grouping +2. Local capture time (localDateTime) + - By default, no delta is applied (exact time matching) + - Can be configured with a delta for flexible time matching + +### Time Delta Feature + +The delta feature allows for flexible time matching when grouping photos. It's particularly useful when dealing with burst photos or photos taken in quick succession that might have slight time differences. + +For example, these two timestamps would normally be considered different: + +``` +2023-08-24T17:00:15.915Z +2023-08-24T17:00:15.810Z +``` + +By setting a delta of 1000ms (1 second), both timestamps would be rounded to the nearest second and considered the same for grouping purposes: + +``` +2023-08-24T17:00:15.000Z +``` + +Delta can be configured for any time-based field: + +- `localDateTime` +- `fileCreatedAt` +- `fileModifiedAt` +- `updatedAt` diff --git a/docs/api-reference/grouping.md b/docs/api-reference/grouping.md new file mode 100644 index 0000000..b5ac21f --- /dev/null +++ b/docs/api-reference/grouping.md @@ -0,0 +1,195 @@ +# Grouping Operations + +The grouping operations are implemented in `internal/grouping/grouping.go`. + +## Group Structure + +```go +type Group struct { + ID string `json:"id"` + Name string `json:"name"` + Assets []Asset `json:"assets"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} +``` + +## Available Operations + +### Group Assets + +```go +func GroupAssets(ctx context.Context, client *immich.Client, assets []Asset, criteria []Criterion) ([]Group, error) +``` + +Groups assets based on specified criteria. + +**Parameters:** + +- `ctx`: Context for the operation +- `client`: Immich API client +- `assets`: Array of assets to group +- `criteria`: Array of grouping criteria + +**Returns:** + +- `[]Group`: Array of groups +- `error`: Any error that occurred + +### Get Group + +```go +func GetGroup(ctx context.Context, client *immich.Client, groupID string) (*Group, error) +``` + +Retrieves a group by ID. + +**Parameters:** + +- `ctx`: Context for the operation +- `client`: Immich API client +- `groupID`: ID of the group to retrieve + +**Returns:** + +- `*Group`: Retrieved group +- `error`: Any error that occurred + +### Update Group + +```go +func UpdateGroup(ctx context.Context, client *immich.Client, group *Group) error +``` + +Updates an existing group. + +**Parameters:** + +- `ctx`: Context for the operation +- `client`: Immich API client +- `group`: Group to update + +**Returns:** + +- `error`: Any error that occurred + +### Delete Group + +```go +func DeleteGroup(ctx context.Context, client *immich.Client, groupID string) error +``` + +Deletes a group by ID. + +**Parameters:** + +- `ctx`: Context for the operation +- `client`: Immich API client +- `groupID`: ID of the group to delete + +**Returns:** + +- `error`: Any error that occurred + +## Grouping Criteria + +```go +type Criterion struct { + Key string `json:"key"` + Split *SplitConfig `json:"split,omitempty"` + Delta *DeltaConfig `json:"delta,omitempty"` +} + +type SplitConfig struct { + Delimiters []string `json:"delimiters"` + Index int `json:"index"` +} + +type DeltaConfig struct { + Milliseconds int64 `json:"milliseconds"` +} +``` + +## Error Handling + +All operations handle the following error cases: + +- Invalid group ID +- Group not found +- API errors +- Network errors +- Invalid group data +- Invalid criteria + +## Best Practices + +1. **Error Handling** + + - Always check returned errors + - Use appropriate error handling strategies + - Log errors for debugging + +2. **Context Usage** + + - Pass context through all operations + - Use context for cancellation + - Set appropriate timeouts + +3. **Group Management** + + - Validate groups before operations + - Handle missing groups gracefully + - Maintain group consistency + +4. **Criteria Usage** + - Use appropriate criteria + - Handle edge cases + - Consider performance implications + +## Example Usage + +```go +// Group assets with criteria +criteria := []Criterion{ + { + Key: "originalFileName", + Split: &SplitConfig{ + Delimiters: []string{"~", "."}, + Index: 0, + }, + }, + { + Key: "localDateTime", + Delta: &DeltaConfig{ + Milliseconds: 1000, + }, + }, +} +groups, err := GroupAssets(ctx, client, assets, criteria) +if err != nil { + log.Printf("Error grouping assets: %v", err) + return +} + +// Get single group +group, err := GetGroup(ctx, client, "group-id") +if err != nil { + log.Printf("Error getting group: %v", err) + return +} + +// Update group +group.Name = "New Name" +err = UpdateGroup(ctx, client, group) +if err != nil { + log.Printf("Error updating group: %v", err) + return +} + +// Delete group +err = DeleteGroup(ctx, client, "group-id") +if err != nil { + log.Printf("Error deleting group: %v", err) + return +} +``` diff --git a/docs/api-reference/stack-operations.md b/docs/api-reference/stack-operations.md new file mode 100644 index 0000000..6445ae7 --- /dev/null +++ b/docs/api-reference/stack-operations.md @@ -0,0 +1,170 @@ +# Stack Operations + +The stack operations are implemented in `internal/stack/stack.go`. + +## Stack Structure + +```go +type Stack struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Assets []Asset `json:"assets"` +} +``` + +## Available Operations + +### Create Stack + +```go +func CreateStack(ctx context.Context, client *immich.Client, assets []Asset) (*Stack, error) +``` + +Creates a new stack with the given assets. + +**Parameters:** + +- `ctx`: Context for the operation +- `client`: Immich API client +- `assets`: Array of assets to include in the stack + +**Returns:** + +- `*Stack`: Created stack +- `error`: Any error that occurred + +### Get Stack + +```go +func GetStack(ctx context.Context, client *immich.Client, stackID string) (*Stack, error) +``` + +Retrieves a stack by its ID. + +**Parameters:** + +- `ctx`: Context for the operation +- `client`: Immich API client +- `stackID`: ID of the stack to retrieve + +**Returns:** + +- `*Stack`: Retrieved stack +- `error`: Any error that occurred + +### Update Stack + +```go +func UpdateStack(ctx context.Context, client *immich.Client, stack *Stack) error +``` + +Updates an existing stack. + +**Parameters:** + +- `ctx`: Context for the operation +- `client`: Immich API client +- `stack`: Stack to update + +**Returns:** + +- `error`: Any error that occurred + +### Delete Stack + +```go +func DeleteStack(ctx context.Context, client *immich.Client, stackID string) error +``` + +Deletes a stack by its ID. + +**Parameters:** + +- `ctx`: Context for the operation +- `client`: Immich API client +- `stackID`: ID of the stack to delete + +**Returns:** + +- `error`: Any error that occurred + +### List Stacks + +```go +func ListStacks(ctx context.Context, client *immich.Client) ([]Stack, error) +``` + +Lists all stacks. + +**Parameters:** + +- `ctx`: Context for the operation +- `client`: Immich API client + +**Returns:** + +- `[]Stack`: Array of stacks +- `error`: Any error that occurred + +## Error Handling + +All operations handle the following error cases: + +- Invalid stack ID +- Stack not found +- API errors +- Network errors +- Invalid asset data + +## Best Practices + +1. **Error Handling** + + - Always check returned errors + - Use appropriate error handling strategies + - Log errors for debugging + +2. **Context Usage** + + - Pass context through all operations + - Use context for cancellation + - Set appropriate timeouts + +3. **Asset Management** + + - Validate assets before operations + - Handle missing assets gracefully + - Maintain asset order + +4. **Stack Naming** + - Use descriptive names + - Include relevant metadata + - Follow consistent naming patterns + +## Example Usage + +```go +// Create a new stack +stack, err := CreateStack(ctx, client, assets) +if err != nil { + log.Printf("Error creating stack: %v", err) + return +} + +// Update stack assets +stack.Assets = append(stack.Assets, newAsset) +err = UpdateStack(ctx, client, stack) +if err != nil { + log.Printf("Error updating stack: %v", err) + return +} + +// Delete stack +err = DeleteStack(ctx, client, stack.ID) +if err != nil { + log.Printf("Error deleting stack: %v", err) + return +} +``` diff --git a/docs/contributing/development.md b/docs/contributing/development.md new file mode 100644 index 0000000..1c88f3f --- /dev/null +++ b/docs/contributing/development.md @@ -0,0 +1,59 @@ +# Development Guide + +## Directory Structure + +``` +immich-auto-stack/ +├── cmd/ # CLI entrypoint (main.go) +├── pkg/ +│ ├── stacker/ # Stacking logic, types, and tests +│ ├── immich/ # Immich API client and integration +│ └── utils/ # Utility helpers and logging +``` + +## Building Locally + +```bash +# Build locally +docker build -t immich-stack . + +# Run locally +docker run -d \ + --name immich-stack \ + --env-file .env \ + -v ./logs:/app/logs \ + immich-stack +``` + +## Code Style + +- Follow the code style and comment conventions (see code for examples) +- Add tests for new features +- Document all exported functions and types + +## Extending + +- **Custom Grouping:** Edit or override criteria via command-line flags or environment variables +- **Custom Promotion:** Set `--parent-filename-promote` and/or `--parent-ext-promote` for your workflow +- **API Integration:** Extend `pkg/immich/client.go` for new Immich endpoints + +## Library Structure + +### pkg/stacker + +- **StackBy:** Groups assets into stacks and sorts them based on promotion rules +- **SortStack:** Sorts assets in a stack by promotion and extension rules +- **Types:** `Asset`, `Stack`, `Criteria`, etc. + +### pkg/immich + +- **Client:** Handles all Immich API interactions (fetch, modify, delete stacks/assets) +- **FetchAllStacks:** Retrieves all stacks, with reset and cleanup logic +- **FetchAssets:** Retrieves all assets, paginated +- **ModifyStack/DeleteStack:** Stack management +- **ListDuplicates:** Finds and logs duplicate assets + +### pkg/utils + +- **helper.go:** Array comparison, string cleaning +- **logs.go:** Colorful, structured logging helpers (info, error, debug, pretty-print) diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md new file mode 100644 index 0000000..6dd6eb9 --- /dev/null +++ b/docs/contributing/testing.md @@ -0,0 +1,71 @@ +# Testing Guide + +## Running Tests + +Run all tests with: + +```sh +go test ./pkg/... +``` + +## Test Structure + +The project uses table-driven tests for all major logic in: + +- `pkg/stacker/stacker_test.go` +- `pkg/immich/client_test.go` + +## Test Coverage + +To check test coverage: + +```sh +go test -cover ./pkg/... +``` + +For a detailed coverage report: + +```sh +go test -coverprofile=coverage.out ./pkg/... +go tool cover -html=coverage.out +``` + +## Writing Tests + +When writing new tests: + +1. Use table-driven tests for similar test cases +2. Test both success and failure scenarios +3. Include edge cases +4. Mock external dependencies +5. Use descriptive test names + +Example test structure: + +```go +func TestStackBy(t *testing.T) { + tests := []struct { + name string + input []Asset + expected []Stack + }{ + { + name: "basic stacking", + input: []Asset{ + // test data + }, + expected: []Stack{ + // expected results + }, + }, + // more test cases + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := StackBy(tt.input) + // assertions + }) + } +} +``` diff --git a/docs/features/custom-criteria.md b/docs/features/custom-criteria.md new file mode 100644 index 0000000..38d8f7e --- /dev/null +++ b/docs/features/custom-criteria.md @@ -0,0 +1,155 @@ +# Custom Criteria + +Immich Stack allows you to define custom criteria for grouping photos using a JSON configuration. This gives you fine-grained control over how photos are grouped into stacks. + +## Basic Structure + +The `CRITERIA` environment variable accepts a JSON array of criteria objects. Each criterion has: + +- `key`: The field to group by +- Optional configuration (split, delta, etc.) + +Example: + +```json +[ + { + "key": "originalFileName", + "split": { + "delimiters": ["~", "."], + "index": 0 + } + }, + { + "key": "localDateTime", + "delta": { + "milliseconds": 1000 + } + } +] +``` + +## Available Keys + +You can use any of these keys in your criteria: + +| Key | Description | +| ------------------ | ------------------------------ | +| `originalFileName` | Original filename of the asset | +| `localDateTime` | Local capture time | +| `fileCreatedAt` | File creation time | +| `fileModifiedAt` | File modification time | +| `updatedAt` | Last update time | + +## Split Configuration + +The `split` configuration allows you to extract parts of string values: + +```json +{ + "key": "originalFileName", + "split": { + "delimiters": ["~", "."], // Array of delimiters to split on + "index": 0 // Which part to use (0-based) + } +} +``` + +For example, with a file named `IMG_1234~edit.jpg`: + +1. Split on `~` and `.` gives `["IMG_1234", "edit", "jpg"]` +2. Using `index: 0` selects `"IMG_1234"` + +## Delta Configuration + +The `delta` configuration allows for flexible time matching: + +```json +{ + "key": "localDateTime", + "delta": { + "milliseconds": 1000 // Time difference to allow (in milliseconds) + } +} +``` + +This is useful for: + +- Burst photos +- Photos taken in quick succession +- Different time zones +- Camera clock differences + +## Examples + +### Basic Filename Grouping + +```json +[ + { + "key": "originalFileName", + "split": { + "delimiters": ["~", "."], + "index": 0 + } + } +] +``` + +### Time-Based Grouping + +```json +[ + { + "key": "localDateTime", + "delta": { + "milliseconds": 5000 + } + } +] +``` + +### Combined Criteria + +```json +[ + { + "key": "originalFileName", + "split": { + "delimiters": ["~", "."], + "index": 0 + } + }, + { + "key": "localDateTime", + "delta": { + "milliseconds": 1000 + } + }, + { + "key": "fileCreatedAt", + "delta": { + "milliseconds": 5000 + } + } +] +``` + +## Best Practices + +1. **Start Simple:** + + - Begin with basic filename grouping + - Add time-based criteria if needed + - Test with small sets first + +2. **Delta Values:** + + - Use smaller deltas for burst photos (1000ms) + - Use larger deltas for time zone differences (3600000ms = 1 hour) + - Consider your camera's burst mode settings + +3. **Testing:** + - Use `DRY_RUN=true` to test configurations + - Check logs for grouping results + - Adjust criteria based on results diff --git a/docs/features/multi-user.md b/docs/features/multi-user.md new file mode 100644 index 0000000..aa398c3 --- /dev/null +++ b/docs/features/multi-user.md @@ -0,0 +1,75 @@ +# Multi-User Support + +Immich Stack supports processing multiple users' photos by accepting multiple API keys. This is useful for: + +- Family accounts +- Shared photo libraries +- Multiple user management + +## Configuration + +To use multiple API keys, separate them with commas in the `API_KEY` environment variable: + +```sh +API_KEY=key1,key2,key3 +``` + +Or when using Docker: + +```yaml +environment: + - API_KEY=key1,key2,key3 +``` + +## Processing Flow + +1. The stacker will process each user sequentially +2. Each user's name and email are logged before processing +3. Stacks are created and managed separately for each user +4. Logs clearly indicate which user is being processed + +## Example + +```sh +# .env file +API_KEY=abc123,def456,ghi789 +API_URL=http://immich-server:2283/api +``` + +When running, you'll see logs like: + +``` +Processing user: John Doe (john@example.com) +Found 1000 assets +Created 50 stacks +... + +Processing user: Jane Doe (jane@example.com) +Found 800 assets +Created 40 stacks +... + +Processing user: Bob Smith (bob@example.com) +Found 1200 assets +Created 60 stacks +... +``` + +## Best Practices + +1. **API Key Management:** + + - Keep API keys secure + - Rotate keys periodically + - Use different keys for different users + +2. **Resource Usage:** + + - Consider running during off-peak hours + - Monitor system resources + - Adjust cron interval based on library size + +3. **Error Handling:** + - If one user fails, others will still be processed + - Check logs for any user-specific issues + - Retry failed users if needed diff --git a/docs/features/stacking-logic.md b/docs/features/stacking-logic.md new file mode 100644 index 0000000..e827eff --- /dev/null +++ b/docs/features/stacking-logic.md @@ -0,0 +1,51 @@ +# Stacking Logic + +## Grouping + +- **Default Criteria:** Groups by base filename (before extension) and local capture time +- **Custom Criteria:** Override with the `--criteria` flag or `CRITERIA` environment variable + +## Sorting + +- **Parent Promotion:** Use `--parent-filename-promote` or `PARENT_FILENAME_PROMOTE` (comma-separated substrings) to promote files as stack parents +- **Extension Promotion:** Use `--parent-ext-promote` or `PARENT_EXT_PROMOTE` (comma-separated extensions) to further prioritize +- **Extension Rank:** Built-in priority: `.jpeg` > `.jpg` > `.png` > others +- **Alphabetical:** Final tiebreaker + +## Example + +For files: `L1010229.JPG`, `L1010229.edit.jpg`, `L1010229.DNG` + +With `PARENT_FILENAME_PROMOTE=edit` and `PARENT_EXT_PROMOTE=.jpg,.dng` in your .env file, or with `--parent-filename-promote=edit` and `--parent-ext-promote=.jpg,.dng`, the order will be: + +``` +L1010229.edit.jpg +L1010229.JPG +L1010229.DNG +``` + +## Stacking Process + +1. **Fetch all stacks and assets** from Immich +2. **Group assets** into stacks using criteria +3. **Sort each stack** to determine the parent and children +4. **Apply changes** via the Immich API (create, update, or delete stacks as needed) +5. **Log all actions** and optionally run in dry-run mode for safety + +## Safe Operations + +The stacker includes several safety features: + +- **Dry Run Mode:** Use `--dry-run` or `DRY_RUN=true` to simulate actions without making changes +- **Stack Replacement:** Use `--replace-stacks` or `REPLACE_STACKS=true` to replace existing stacks +- **Stack Reset:** Use `--reset-stacks` or `RESET_STACKS=true` with confirmation to delete all stacks +- **Confirmation Required:** Stack reset requires explicit confirmation via `CONFIRM_RESET_STACK` + +## Logging + +The stacker provides comprehensive logging: + +- Colorful, structured logs for all operations +- Clear indication of actions taken +- Error reporting with context +- Progress updates for long-running operations diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md new file mode 100644 index 0000000..061fdcf --- /dev/null +++ b/docs/getting-started/configuration.md @@ -0,0 +1,117 @@ +# Configuration + +## Basic Configuration + +The basic configuration requires two environment variables: + +```sh +API_KEY=your_immich_api_key +API_URL=http://your_immich_server:3001/api +``` + +## Run Modes + +Immich Stack supports two run modes: + +1. **Once Mode** (default) + + - Runs once and exits + - Good for manual runs or scheduled tasks + - Use: `RUN_MODE=once` + +2. **Cron Mode** + - Runs periodically + - Good for continuous operation + - Use: `RUN_MODE=cron` + - Configure interval with `CRON_INTERVAL` (in seconds) + +Example cron configuration: + +```sh +RUN_MODE=cron +CRON_INTERVAL=3600 # Run every hour +``` + +## Stack Management + +### Parent Selection + +Control which files become stack parents using: + +1. **Filename Promotion:** + + ```sh + PARENT_FILENAME_PROMOTE=edit,raw,original + ``` + + Files containing these substrings will be promoted + +2. **Extension Promotion:** + ```sh + PARENT_EXT_PROMOTE=.jpg,.dng + ``` + Files with these extensions will be promoted + +### Stack Operations + +1. **Dry Run:** + + ```sh + DRY_RUN=true + ``` + + Simulates actions without making changes + +2. **Reset Stacks:** + + ```sh + RESET_STACKS=true + CONFIRM_RESET_STACK="I acknowledge all my current stacks will be deleted and new one will be created" + ``` + + Deletes all existing stacks before processing + +3. **Replace Stacks:** + ```sh + REPLACE_STACKS=true + ``` + Replaces existing stacks with new groups + +## Asset Inclusion + +Control which assets are processed: + +```sh +WITH_ARCHIVED=true # Include archived assets +WITH_DELETED=true # Include deleted assets +``` + +## Custom Criteria + +Configure custom grouping criteria using the `CRITERIA` environment variable. See [Custom Criteria](../features/custom-criteria.md) for details. + +## Example Configuration + +```sh +# Required +API_KEY=your_immich_api_key +API_URL=http://immich-server:2283/api + +# Run mode +RUN_MODE=cron +CRON_INTERVAL=3600 + +# Stack management +PARENT_FILENAME_PROMOTE=edit,raw +PARENT_EXT_PROMOTE=.jpg,.dng +DRY_RUN=false +RESET_STACKS=false +REPLACE_STACKS=false + +# Asset inclusion +WITH_ARCHIVED=false +WITH_DELETED=false + +# Custom criteria +CRITERIA='[{"key":"originalFileName","split":{"delimiters":["~","."],"index":0}},{"key":"localDateTime","delta":{"milliseconds":1000}}]' +``` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000..9dac026 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,93 @@ +# Installation + +## Prerequisites + +- [Go](https://golang.org/doc/install) (version 1.21 or later) +- [Git](https://git-scm.com/downloads) + +## From Source + +1. Clone the repository: + + ```sh + git clone https://github.com/majorfi/immich-stack.git + cd immich-stack + ``` + +2. Build the binary: + + ```sh + go build -o immich-stack ./cmd/main.go + ``` + +3. Move the binary to your PATH (optional): + ```sh + sudo mv immich-stack /usr/local/bin/ + ``` + +## Using Pre-built Binaries + +1. Download the latest release from the [Releases page](https://github.com/majorfi/immich-stack/releases) +2. Extract the archive +3. Move the binary to your PATH (optional) + +## Docker Installation + +1. Clone the repository: + + ```sh + git clone https://github.com/majorfi/immich-stack.git + cd immich-stack + ``` + +2. Create a `.env` file from the example: + + ```sh + cp .env.example .env + ``` + +3. Edit the `.env` file with your Immich credentials and preferences: + + ```sh + # Required + API_KEY=your_immich_api_key + API_URL=http://your_immich_server:3001/api + + # Optional - Default values shown + DRY_RUN=false + RESET_STACKS=false + REPLACE_STACKS=false + PARENT_FILENAME_PROMOTE=edit + PARENT_EXT_PROMOTE=.jpg,.dng + WITH_ARCHIVED=false + WITH_DELETED=false + + # Run mode settings + RUN_MODE=once # Options: once, cron + CRON_INTERVAL=86400 # in seconds, only used if RUN_MODE=cron + ``` + +4. Start the service: + + ```sh + docker compose up -d + ``` + +5. To run in cron mode, set `RUN_MODE=cron` in your `.env` file and restart: + + ```sh + docker compose down + docker compose up -d + ``` + +6. To view logs: + + ```sh + docker compose logs -f + ``` + +7. To stop the service: + + ```sh + docker compose down + ``` diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md new file mode 100644 index 0000000..2541df0 --- /dev/null +++ b/docs/getting-started/quick-start.md @@ -0,0 +1,60 @@ +# Quick Start + +## Basic Usage + +1. Create a `.env` file: + +```bash +cat > .env << EOL +API_KEY=your_immich_api_key +API_URL=http://immich-server:2283/api +RUN_MODE=cron +CRON_INTERVAL=60 +EOL +``` + +2. Run with Docker (using Docker Hub): + +```bash +docker run -d --name immich-stack --env-file .env -v ./logs:/app/logs majorfi/immich-stack:latest +``` + +Or using GitHub Container Registry: + +```bash +docker run -d --name immich-stack --env-file .env -v ./logs:/app/logs ghcr.io/majorfi/immich-stack:latest +``` + +## Running Locally + +1. Create a `.env` file in your working directory with your Immich credentials: + +```sh +API_KEY=your_immich_api_key +API_URL=http://your_immich_server:3001/api +``` + +2. Run the stacker: + +```sh +# Using the binary +./immich-stack + +# Or if installed in PATH +immich-stack +``` + +3. Optional: Configure additional options via environment variables or flags: + +```sh +# Example with flags +./immich-stack --dry-run --parent-filename-promote=edit --parent-ext-promote=.jpg,.dng --with-archived --with-deleted + +# Or using environment variables +export DRY_RUN=true +export PARENT_FILENAME_PROMOTE=edit +export PARENT_EXT_PROMOTE=.jpg,.dng +export WITH_ARCHIVED=true +export WITH_DELETED=true +./immich-stack +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..23553d9 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,25 @@ +# Immich Stack + +Immich Stack is a Go CLI tool and library for automatically grouping ("stacking") similar photos in the [Immich](https://github.com/immich-app/immich) photo management system. It provides configurable, robust, and extensible logic for grouping, sorting, and managing photo stacks via the Immich API. + +## Features + +- **Automatic Stacking:** Groups similar photos into stacks based on filename, date, and custom criteria +- **Multi-User Support:** Process multiple users sequentially with comma-separated API keys +- **Configurable Grouping:** Custom grouping logic via environment variables and command-line flags +- **Parent/Child Promotion:** Fine-grained control over stack parent selection +- **Safe Operations:** Dry-run mode, stack replacement, and reset with confirmation +- **Comprehensive Logging:** Colorful, structured logs for all operations +- **Tested and Modular:** Table-driven tests and clear separation of concerns + +## Quick Links + +- [Installation](getting-started/installation.md) +- [Quick Start](getting-started/quick-start.md) +- [Configuration](getting-started/configuration.md) +- [Stacking Logic](features/stacking-logic.md) +- [API Reference](api-reference/environment-variables.md) + +## License + +MIT diff --git a/docs/integration/docker-compose.md b/docs/integration/docker-compose.md new file mode 100644 index 0000000..549fa75 --- /dev/null +++ b/docs/integration/docker-compose.md @@ -0,0 +1,80 @@ +# Docker Compose Integration + +## Basic Configuration + +```yaml +version: "3.8" + +services: + immich-stack: + container_name: immich_stack + # Use Docker Hub image (recommended for Portainer) + image: majorfi/immich-stack:main # or :latest after next release + # Or use GitHub Container Registry + # image: ghcr.io/majorfi/immich-stack:latest + environment: + - API_KEY=${API_KEY} # Can be a single key or comma-separated for multiple users + - API_URL=${API_URL:-http://immich-server:2283/api} + - DRY_RUN=${DRY_RUN:-false} + - RESET_STACKS=${RESET_STACKS:-false} + - CONFIRM_RESET_STACK=${CONFIRM_RESET_STACK} + - REPLACE_STACKS=${REPLACE_STACKS:-false} + - PARENT_FILENAME_PROMOTE=${PARENT_FILENAME_PROMOTE:-edit} + - PARENT_EXT_PROMOTE=${PARENT_EXT_PROMOTE:-.jpg,.dng} + - WITH_ARCHIVED=${WITH_ARCHIVED:-false} + - WITH_DELETED=${WITH_DELETED:-false} + - RUN_MODE=${RUN_MODE:-once} + - CRON_INTERVAL=${CRON_INTERVAL:-86400} + volumes: + - ./logs:/app/logs + restart: on-failure +``` + +## Integration with Immich Docker Compose + +To integrate with an existing Immich installation: + +1. Copy the `immich-stack` service from our `docker-compose.yml` into your Immich's `docker-compose.yml` + +2. Add these environment variables to your Immich's `.env` file (you can also add the optional ones): + + ```sh + # Immich Stack settings + API_KEY=your_immich_api_key + API_URL=http://immich-server:2283/api # Use internal Docker network + RUN_MODE=once # Options: once, cron + CRON_INTERVAL=86400 # in seconds, only used if RUN_MODE=cron + ``` + +3. Add the service dependency in Immich's `docker-compose.yml`: + + ```yaml + immich-stack: + container_name: immich_stack + image: ghcr.io/majorfi/immich-stack:latest + environment: + - API_KEY=${API_KEY} + - API_URL=${API_URL:-http://immich-server:2283/api} + - DRY_RUN=${DRY_RUN:-false} + - RESET_STACKS=${RESET_STACKS:-false} + - CONFIRM_RESET_STACK=${CONFIRM_RESET_STACK} + - REPLACE_STACKS=${REPLACE_STACKS:-false} + - PARENT_FILENAME_PROMOTE=${PARENT_FILENAME_PROMOTE:-edit} + - PARENT_EXT_PROMOTE=${PARENT_EXT_PROMOTE:-.jpg,.dng} + - WITH_ARCHIVED=${WITH_ARCHIVED:-false} + - WITH_DELETED=${WITH_DELETED:-false} + - RUN_MODE=${RUN_MODE:-once} + - CRON_INTERVAL=${CRON_INTERVAL:-86400} + volumes: + - ./logs:/app/logs + restart: on-failure + depends_on: + immich-server: + condition: service_healthy + ``` + +4. Restart your Immich stack: + ```sh + docker compose down + docker compose up -d + ``` diff --git a/docs/integration/docker.md b/docs/integration/docker.md new file mode 100644 index 0000000..dad080f --- /dev/null +++ b/docs/integration/docker.md @@ -0,0 +1,143 @@ +# Docker Integration + +## Quick Start + +Run Immich Stack using Docker: + +```bash +# Create a .env file +cat > .env << EOL +API_KEY=your_immich_api_key +API_URL=http://immich-server:2283/api +RUN_MODE=cron +CRON_INTERVAL=60 +EOL + +# Run with Docker Hub +docker run -d --name immich-stack --env-file .env -v ./logs:/app/logs majorfi/immich-stack:latest + +# Or using GitHub Container Registry +docker run -d --name immich-stack --env-file .env -v ./logs:/app/logs ghcr.io/majorfi/immich-stack:latest +``` + +## Image Sources + +Immich Stack is available from two container registries: + +1. **Docker Hub** (recommended for Portainer): + + ```bash + docker pull majorfi/immich-stack:latest + ``` + +2. **GitHub Container Registry**: + ```bash + docker pull ghcr.io/majorfi/immich-stack:latest + ``` + +## Container Configuration + +### Environment Variables + +All configuration is done through environment variables. See [Environment Variables](../api-reference/environment-variables.md) for details. + +### Volumes + +The container uses one volume: + +- `/app/logs`: For storing log files + ```bash + -v ./logs:/app/logs + ``` + +### Network + +When running with Immich, use the same Docker network: + +```bash +--network immich_default +``` + +## Building Locally + +Build the Docker image locally: + +```bash +# Clone the repository +git clone https://github.com/majorfi/immich-stack.git +cd immich-stack + +# Build the image +docker build -t immich-stack . + +# Run the container +docker run -d \ + --name immich-stack \ + --env-file .env \ + -v ./logs:/app/logs \ + immich-stack +``` + +## Container Management + +### View Logs + +```bash +# View logs +docker logs immich-stack + +# Follow logs +docker logs -f immich-stack +``` + +### Stop Container + +```bash +docker stop immich-stack +``` + +### Remove Container + +```bash +docker rm immich-stack +``` + +### Update Container + +```bash +# Pull new image +docker pull majorfi/immich-stack:latest + +# Stop and remove old container +docker stop immich-stack +docker rm immich-stack + +# Run new container +docker run -d \ + --name immich-stack \ + --env-file .env \ + -v ./logs:/app/logs \ + majorfi/immich-stack:latest +``` + +## Best Practices + +1. **Version Pinning:** + + - Use specific versions in production + - Test new versions before updating + +2. **Resource Limits:** + + - Set memory limits for large libraries + - Monitor container resource usage + +3. **Backup:** + + - Backup your `.env` file + - Consider backing up logs + +4. **Security:** + - Use Docker secrets for sensitive data + - Restrict container capabilities + - Use non-root user diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..ef0b76f --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +mkdocs-material>=9.5.0 +mkdocs-git-revision-date-localized-plugin>=1.2.0 \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..6600403 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,63 @@ +site_name: Immich Stack +site_description: Automatically groups similar photos into stacks within the Immich photo management system +site_author: MajorFi +repo_url: https://github.com/majorfi/immich-stack +repo_name: majorfi/immich-stack + +theme: + name: material + features: + - navigation.tabs + - navigation.sections + - navigation.expand + - search.highlight + - search.share + - search.suggest + palette: + - scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + +markdown_extensions: + - pymdownx.highlight + - pymdownx.superfences + - pymdownx.tabbed + - pymdownx.arithmatex: + generic: true + - admonition + - footnotes + - toc: + permalink: true + +plugins: + - search + - git-revision-date-localized + +nav: + - Home: index.md + - Getting Started: + - Installation: getting-started/installation.md + - Quick Start: getting-started/quick-start.md + - Configuration: getting-started/configuration.md + - Features: + - Stacking Logic: features/stacking-logic.md + - Multi-User Support: features/multi-user.md + - Custom Criteria: features/custom-criteria.md + - Integration: + - Docker: integration/docker.md + - Docker Compose: integration/docker-compose.md + - API Reference: + - Environment Variables: api-reference/environment-variables.md + - CLI Usage: api-reference/cli-usage.md + - Contributing: + - Development: contributing/development.md + - Testing: contributing/testing.md From cf887491db9bc44195d842f6035da615fd03bc72 Mon Sep 17 00:00:00 2001 From: Major Date: Fri, 16 May 2025 14:54:11 +0200 Subject: [PATCH 3/3] feat: expand doc --- docs/api-reference/environment-variables.md | 134 +++++++++---- docs/development.md | 200 ++++++++++++++++++++ docs/troubleshooting.md | 181 ++++++++++++++++++ 3 files changed, 479 insertions(+), 36 deletions(-) create mode 100644 docs/development.md create mode 100644 docs/troubleshooting.md diff --git a/docs/api-reference/environment-variables.md b/docs/api-reference/environment-variables.md index eef5e90..c189d3b 100644 --- a/docs/api-reference/environment-variables.md +++ b/docs/api-reference/environment-variables.md @@ -1,54 +1,116 @@ # Environment Variables -| Variable | Description | Default | -| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | -| `API_KEY` | Your Immich API key(s), comma-separated for multiple users | (required) | -| `API_URL` | Immich API URL | `http://immich-server:2283/api` | -| `RUN_MODE` | Run mode (`once` or `cron`) | `once` | -| `CRON_INTERVAL` | Interval in seconds for cron mode | `86400` | -| `DRY_RUN` | Don't apply changes | `false` | -| `RESET_STACKS` | Delete all existing stacks | `false` | -| `CONFIRM_RESET_STACK` | Required for RESET_STACKS. Must be set to: 'I acknowledge all my current stacks will be deleted and new one will be created' | (required for RESET_STACKS) | -| `REPLACE_STACKS` | Replace stacks for new groups | `false` | -| `PARENT_FILENAME_PROMOTE` | Parent filename promote | `edit` | -| `PARENT_EXT_PROMOTE` | Parent extension promote | `.jpg,.dng` | -| `WITH_ARCHIVED` | Include archived assets | `false` | -| `WITH_DELETED` | Include deleted assets | `false` | -| `CRITERIA` | JSON array of custom criteria for grouping photos (see [Custom Criteria](features/custom-criteria.md)) | See Default Configuration | +This document provides a complete reference of all environment variables supported by Immich Stack. -## Default Configuration +## Required Variables -### Default Criteria +| Variable | Description | Example | +| --------- | ------------------- | -------------------------------- | +| `API_KEY` | Immich API key(s) | `API_KEY=key1,key2` | +| `API_URL` | Immich API base URL | `API_URL=http://immich:2283/api` | -By default, Immich Stack groups photos based on two criteria: +## Run Mode Configuration -1. Original filename (before extension) - - Splits the filename on "~" and "." delimiters - - Uses the first part (index 0) for grouping -2. Local capture time (localDateTime) - - By default, no delta is applied (exact time matching) - - Can be configured with a delta for flexible time matching +| Variable | Description | Default | Example | +| --------------- | ---------------------------- | ------- | ------- | +| `RUN_MODE` | Run mode: "once" or "cron" | "once" | `cron` | +| `CRON_INTERVAL` | Interval in seconds for cron | 60 | `3600` | -### Time Delta Feature +## Stack Management -The delta feature allows for flexible time matching when grouping photos. It's particularly useful when dealing with burst photos or photos taken in quick succession that might have slight time differences. +| Variable | Description | Default | Example | +| --------------------- | -------------------------------------------- | ------- | -------------------- | +| `RESET_STACKS` | Delete all existing stacks before processing | false | `true` | +| `CONFIRM_RESET_STACK` | Confirmation message for reset | - | `"I acknowledge..."` | +| `REPLACE_STACKS` | Replace stacks for new groups | false | `true` | +| `DRY_RUN` | Simulate actions without making changes | false | `true` | -For example, these two timestamps would normally be considered different: +## Parent Selection +| Variable | Description | Default | Example | +| ------------------------- | ----------------------------------------- | ------- | ----------- | +| `PARENT_FILENAME_PROMOTE` | Substrings to promote as parent filenames | - | `edit,raw` | +| `PARENT_EXT_PROMOTE` | Extensions to promote as parent files | - | `.jpg,.dng` | + +## Asset Inclusion + +| Variable | Description | Default | Example | +| --------------- | ------------------------------------- | ------- | ------- | +| `WITH_ARCHIVED` | Include archived assets in processing | false | `true` | +| `WITH_DELETED` | Include deleted assets in processing | false | `true` | + +## Custom Criteria + +| Variable | Description | Default | Example | +| ---------- | ----------------------------- | ------- | ----------------------------------------------------- | +| `CRITERIA` | Custom grouping criteria JSON | - | See [Custom Criteria](../features/custom-criteria.md) | + +## Logging + +| Variable | Description | Default | Example | +| ------------ | --------------------------------- | ------- | ------- | +| `LOG_LEVEL` | Log level (debug,info,warn,error) | info | `debug` | +| `LOG_FORMAT` | Log format (json,text) | text | `json` | + +## Examples + +### Basic Configuration + +```sh +API_KEY=your_key +API_URL=http://immich:2283/api +``` + +### Cron Mode + +```sh +RUN_MODE=cron +CRON_INTERVAL=3600 ``` -2023-08-24T17:00:15.915Z -2023-08-24T17:00:15.810Z + +### Stack Management + +```sh +RESET_STACKS=true +CONFIRM_RESET_STACK="I acknowledge all my current stacks will be deleted and new one will be created" +REPLACE_STACKS=true +DRY_RUN=false ``` -By setting a delta of 1000ms (1 second), both timestamps would be rounded to the nearest second and considered the same for grouping purposes: +### Parent Selection +```sh +PARENT_FILENAME_PROMOTE=edit,raw +PARENT_EXT_PROMOTE=.jpg,.dng ``` -2023-08-24T17:00:15.000Z + +### Custom Criteria + +```sh +CRITERIA='[{"key":"originalFileName","split":{"delimiters":["~","."],"index":0}},{"key":"localDateTime","delta":{"milliseconds":1000}}]' ``` -Delta can be configured for any time-based field: +## Best Practices + +1. **Security** + + - Never commit API keys to version control + - Use environment-specific .env files + - Rotate API keys regularly + +2. **Configuration** + + - Use specific versions in production + - Document all custom configurations + - Test changes in development first + +3. **Monitoring** + + - Enable debug logging when needed + - Monitor cron job execution + - Check stack operation results -- `localDateTime` -- `fileCreatedAt` -- `fileModifiedAt` -- `updatedAt` +4. **Maintenance** + - Review and update configurations regularly + - Clean up old stacks periodically + - Monitor API usage and limits diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..6fe7a28 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,200 @@ +# Development Guide + +This guide helps you set up and contribute to Immich Stack development. + +## Prerequisites + +- Go 1.21 or later +- Docker and Docker Compose +- Make (optional, for using Makefile) + +## Setup + +1. Clone the repository + + ```sh + git clone https://github.com/majorfi/immich-stack.git + cd immich-stack + ``` + +2. Install dependencies + + ```sh + go mod download + ``` + +3. Create development environment + ```sh + cp .env.example .env + ``` + +## Development Workflow + +### Running Tests + +```sh +# Run all tests +go test ./... + +# Run specific test +go test ./internal/stack + +# Run with coverage +go test -cover ./... +``` + +### Building + +```sh +# Build binary +go build -o immich-stack + +# Build for specific platform +GOOS=linux GOARCH=amd64 go build -o immich-stack +``` + +### Docker Development + +```sh +# Build development image +docker build -t immich-stack:dev . + +# Run development container +docker run -it --rm \ + --name immich-stack-dev \ + --env-file .env \ + -v $(pwd):/app \ + immich-stack:dev +``` + +## Code Structure + +``` +. +├── cmd/ # Command-line interface +│ └── main.go # Entry point +├── internal/ # Internal packages +│ ├── asset/ # Asset operations +│ ├── grouping/ # Grouping logic +│ └── stack/ # Stack operations +├── pkg/ # Public packages +│ ├── immich/ # Immich API client +│ ├── stacker/ # Stacking logic +│ └── utils/ # Utility functions +└── docs/ # Documentation +``` + +## Coding Standards + +### Go Code + +1. **Formatting** + + - Use `go fmt` + - Follow Go standard formatting + - Use `gofmt` for consistency + +2. **Documentation** + + - Document all exported functions + - Include examples where helpful + - Follow Go doc conventions + +3. **Testing** + - Write unit tests + - Use table-driven tests + - Test edge cases + +### Error Handling + +1. **Error Types** + + ```go + type StackError struct { + Code string + Message string + Err error + } + ``` + +2. **Error Wrapping** + + ```go + return nil, fmt.Errorf("failed to create stack: %w", err) + ``` + +3. **Error Checking** + ```go + if err != nil { + return nil, err + } + ``` + +## Contributing + +1. **Fork and Clone** + + - Fork the repository + - Clone your fork + - Create a feature branch + +2. **Development** + + - Write code + - Add tests + - Update documentation + +3. **Testing** + + - Run all tests + - Check formatting + - Verify documentation + +4. **Pull Request** + - Push changes + - Create pull request + - Wait for review + +## Best Practices + +1. **Code Quality** + + - Write clean, readable code + - Follow Go best practices + - Use meaningful names + +2. **Testing** + + - Write comprehensive tests + - Test edge cases + - Maintain test coverage + +3. **Documentation** + + - Keep docs up to date + - Add examples + - Document changes + +4. **Performance** + - Profile code + - Optimize bottlenecks + - Consider memory usage + +## Release Process + +1. **Versioning** + + - Follow semantic versioning + - Update version in code + - Tag releases + +2. **Building** + + - Build for all platforms + - Create Docker images + - Sign releases + +3. **Deployment** + - Push to registries + - Update documentation + - Announce release diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..a832f10 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,181 @@ +# Troubleshooting Guide + +This guide helps you resolve common issues with Immich Stack. + +## Common Issues + +### API Connection Issues + +**Symptoms:** + +- "Failed to connect to Immich API" +- "API request failed" +- "Invalid API key" + +**Solutions:** + +1. Verify API URL is correct + ```sh + API_URL=http://immich-server:2283/api + ``` +2. Check API key validity + ```sh + API_KEY=your_valid_api_key + ``` +3. Ensure network connectivity + ```sh + curl -I http://immich-server:2283/api + ``` + +### Stack Creation Issues + +**Symptoms:** + +- "Failed to create stack" +- "Invalid stack data" +- "Stack already exists" + +**Solutions:** + +1. Enable dry run mode to test + ```sh + DRY_RUN=true + ``` +2. Check stack criteria + ```sh + CRITERIA='[{"key":"originalFileName","split":{"delimiters":["~","."],"index":0}}]' + ``` +3. Verify asset data + ```sh + WITH_ARCHIVED=true + WITH_DELETED=false + ``` + +### Grouping Issues + +**Symptoms:** + +- "Invalid grouping criteria" +- "No assets grouped" +- "Unexpected grouping results" + +**Solutions:** + +1. Review criteria configuration + ```sh + CRITERIA='[{"key":"localDateTime","delta":{"milliseconds":1000}}]' + ``` +2. Check parent selection + ```sh + PARENT_FILENAME_PROMOTE=edit,raw + PARENT_EXT_PROMOTE=.jpg,.dng + ``` +3. Enable debug logging + ```sh + LOG_LEVEL=debug + ``` + +### Cron Mode Issues + +**Symptoms:** + +- "Cron job not running" +- "Invalid interval" +- "Unexpected execution" + +**Solutions:** + +1. Verify run mode + ```sh + RUN_MODE=cron + ``` +2. Check interval setting + ```sh + CRON_INTERVAL=3600 + ``` +3. Monitor logs + ```sh + LOG_LEVEL=debug + LOG_FORMAT=json + ``` + +## Debugging + +### Enable Debug Logging + +```sh +LOG_LEVEL=debug +LOG_FORMAT=json +``` + +### Check Logs + +```sh +# View logs +docker logs immich-stack + +# Follow logs +docker logs -f immich-stack +``` + +### Test Configuration + +1. Use dry run mode + + ```sh + DRY_RUN=true + ``` + +2. Test with minimal criteria + + ```sh + CRITERIA='[{"key":"originalFileName"}]' + ``` + +3. Verify API connection + ```sh + curl -I $API_URL + ``` + +## Performance Issues + +### High Memory Usage + +**Solutions:** + +1. Process fewer assets at once +2. Use more specific criteria +3. Enable pagination + +### Slow Processing + +**Solutions:** + +1. Optimize criteria +2. Use appropriate delta values +3. Consider batch processing + +## Best Practices + +1. **Testing** + + - Always use dry run mode first + - Test with small asset sets + - Verify criteria before production + +2. **Monitoring** + + - Enable debug logging + - Monitor resource usage + - Check operation results + +3. **Maintenance** + + - Regular stack cleanup + - API key rotation + - Configuration review + +4. **Security** + - Secure API keys + - Regular updates + - Access control