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 e5ed000..0b0857a 100644 --- a/README.md +++ b/README.md @@ -17,404 +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` | - -## 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 -``` - ---- - -## 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..c189d3b --- /dev/null +++ b/docs/api-reference/environment-variables.md @@ -0,0 +1,116 @@ +# Environment Variables + +This document provides a complete reference of all environment variables supported by Immich Stack. + +## Required Variables + +| 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` | + +## Run Mode Configuration + +| Variable | Description | Default | Example | +| --------------- | ---------------------------- | ------- | ------- | +| `RUN_MODE` | Run mode: "once" or "cron" | "once" | `cron` | +| `CRON_INTERVAL` | Interval in seconds for cron | 60 | `3600` | + +## Stack Management + +| 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` | + +## 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 +``` + +### 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 +``` + +### Parent Selection + +```sh +PARENT_FILENAME_PROMOTE=edit,raw +PARENT_EXT_PROMOTE=.jpg,.dng +``` + +### Custom Criteria + +```sh +CRITERIA='[{"key":"originalFileName","split":{"delimiters":["~","."],"index":0}},{"key":"localDateTime","delta":{"milliseconds":1000}}]' +``` + +## 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 + +4. **Maintenance** + - Review and update configurations regularly + - Clean up old stacks periodically + - Monitor API usage and limits 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/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/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/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 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 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 } /**************************************************************************************************