diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
deleted file mode 100644
index c6c2b3b51e..0000000000
--- a/.devcontainer/devcontainer.json
+++ /dev/null
@@ -1,147 +0,0 @@
-{
- "name": "Immich - Backend, Frontend and ML",
- "service": "immich-server",
- "runServices": [
- "immich-server",
- "redis",
- "database",
- "immich-machine-learning"
- ],
- "dockerComposeFile": [
- "../docker/docker-compose.dev.yml",
- "./server/container-compose-overrides.yml"
- ],
- "customizations": {
- "vscode": {
- "extensions": [
- "dbaeumer.vscode-eslint",
- "esbenp.prettier-vscode",
- "svelte.svelte-vscode",
- "ms-vscode-remote.remote-containers",
- "foxundermoon.shell-format",
- "timonwong.shellcheck",
- "rvest.vs-code-prettier-eslint",
- "bluebrown.yamlfmt",
- "vkrishna04.cspell-sync",
- "vitest.explorer",
- "ms-playwright.playwright",
- "ms-azuretools.vscode-docker"
- ],
- "settings": {
- "tasks": {
- "version": "2.0.0",
- "tasks": [
- {
- "label": "Fix Permissions, Install Dependencies",
- "type": "shell",
- "command": "[ -f /immich-devcontainer/container-start.sh ] && /immich-devcontainer/container-start.sh || exit 0",
- "isBackground": true,
- "presentation": {
- "echo": true,
- "reveal": "always",
- "focus": false,
- "panel": "dedicated",
- "showReuseMessage": true,
- "clear": false,
- "group": "Devcontainer tasks",
- "close": true
- },
- "runOptions": {
- "runOn": "default"
- },
- "problemMatcher": []
- },
- {
- "label": "Immich API Server (Nest)",
- "dependsOn": ["Fix Permissions, Install Dependencies"],
- "type": "shell",
- "command": "[ -f /immich-devcontainer/container-start-backend.sh ] && /immich-devcontainer/container-start-backend.sh || exit 0",
- "isBackground": true,
- "presentation": {
- "echo": true,
- "reveal": "always",
- "focus": false,
- "panel": "dedicated",
- "showReuseMessage": true,
- "clear": false,
- "group": "Devcontainer tasks",
- "close": true
- },
- "runOptions": {
- "runOn": "folderOpen"
- },
- "problemMatcher": []
- },
- {
- "label": "Immich Web Server (Vite)",
- "dependsOn": ["Fix Permissions, Install Dependencies"],
- "type": "shell",
- "command": "[ -f /immich-devcontainer/container-start-frontend.sh ] && /immich-devcontainer/container-start-frontend.sh || exit 0",
- "isBackground": true,
- "presentation": {
- "echo": true,
- "reveal": "always",
- "focus": false,
- "panel": "dedicated",
- "showReuseMessage": true,
- "clear": false,
- "group": "Devcontainer tasks",
- "close": true
- },
- "runOptions": {
- "runOn": "folderOpen"
- },
- "problemMatcher": []
- },
- {
- "label": "Build Immich CLI",
- "type": "shell",
- "command": "pnpm --filter cli build:dev"
- }
- ]
- }
- }
- }
- },
- "features": {
- "ghcr.io/devcontainers/features/docker-in-docker:2": {
- // https://github.com/devcontainers/features/issues/1466
- "moby": false
- }
- },
- "forwardPorts": [3000, 9231, 9230, 2283],
- "portsAttributes": {
- "3000": {
- "label": "Immich - Frontend HTTP",
- "description": "The frontend of the Immich project",
- "onAutoForward": "openBrowserOnce"
- },
- "2283": {
- "label": "Immich - API Server - HTTP",
- "description": "The API server of the Immich project"
- },
- "9231": {
- "label": "Immich - API Server - DEBUG",
- "description": "The API server of the Immich project"
- },
- "9230": {
- "label": "Immich - Workers - DEBUG",
- "description": "The workers of the Immich project"
- }
- },
- "overrideCommand": true,
- "workspaceFolder": "/workspaces/immich",
- "remoteUser": "node",
- "userEnvProbe": "loginInteractiveShell",
- "remoteEnv": {
- // The location where your uploaded files are stored
- "UPLOAD_LOCATION": "${localEnv:UPLOAD_LOCATION:./library}",
- // Connection secret for postgres. You should change it to a random password
- // Please use only the characters `A-Za-z0-9`, without special characters or spaces
- "DB_PASSWORD": "${localEnv:DB_PASSWORD:postgres}",
- // The database username
- "DB_USERNAME": "${localEnv:DB_USERNAME:postgres}",
- // The database name
- "DB_DATABASE_NAME": "${localEnv:DB_DATABASE_NAME:immich}"
- }
-}
diff --git a/.devcontainer/mobile/container-compose-overrides.yml b/.devcontainer/mobile/container-compose-overrides.yml
deleted file mode 100644
index 99e41cbece..0000000000
--- a/.devcontainer/mobile/container-compose-overrides.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-services:
- immich-server:
- build:
- target: dev-container-mobile
- environment:
- - IMMICH_SERVER_URL=http://127.0.0.1:2283/
- volumes: !override # bind mount host to /workspaces/immich
- - ..:/workspaces/immich
- - ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- - pnpm-store:/usr/src/app/.pnpm-store
- - server-node_modules:/usr/src/app/server/node_modules
- - web-node_modules:/usr/src/app/web/node_modules
- - github-node_modules:/usr/src/app/.github/node_modules
- - cli-node_modules:/usr/src/app/cli/node_modules
- - docs-node_modules:/usr/src/app/docs/node_modules
- - e2e-node_modules:/usr/src/app/e2e/node_modules
- - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- - app-node_modules:/usr/src/app/node_modules
- - sveltekit:/usr/src/app/web/.svelte-kit
- - coverage:/usr/src/app/web/coverage
- - /etc/localtime:/etc/localtime:ro
- immich-web:
- env_file: !reset []
- immich-machine-learning:
- env_file: !reset []
- database:
- env_file: !reset []
- environment: !override
- POSTGRES_PASSWORD: ${DB_PASSWORD-postgres}
- POSTGRES_USER: ${DB_USERNAME-postgres}
- POSTGRES_DB: ${DB_DATABASE_NAME-immich}
- POSTGRES_INITDB_ARGS: '--data-checksums'
- POSTGRES_HOST_AUTH_METHOD: md5
- volumes:
- - ${UPLOAD_LOCATION:-postgres-devcontainer-volume}${UPLOAD_LOCATION:+/postgres}:/var/lib/postgresql/data
- redis:
- env_file: !reset []
-volumes:
- upload-devcontainer-volume:
- postgres-devcontainer-volume:
diff --git a/.devcontainer/mobile/devcontainer.json b/.devcontainer/mobile/devcontainer.json
deleted file mode 100644
index 140a2ecac3..0000000000
--- a/.devcontainer/mobile/devcontainer.json
+++ /dev/null
@@ -1,52 +0,0 @@
-{
- "name": "Immich - Mobile",
- "service": "immich-server",
- "runServices": [
- "immich-server",
- "redis",
- "database",
- "immich-machine-learning"
- ],
- "dockerComposeFile": [
- "../../docker/docker-compose.dev.yml",
- "./container-compose-overrides.yml"
- ],
- "customizations": {
- "vscode": {
- "extensions": [
- "Dart-Code.dart-code",
- "Dart-Code.flutter",
- "dcmdev.dcm-vscode-extension",
- "esbenp.prettier-vscode",
- "dbaeumer.vscode-eslint",
- "esbenp.prettier-vscode",
- "svelte.svelte-vscode",
- "ms-vscode-remote.remote-containers",
- "foxundermoon.shell-format",
- "timonwong.shellcheck",
- "rvest.vs-code-prettier-eslint",
- "bluebrown.yamlfmt",
- "vkrishna04.cspell-sync",
- "vitest.explorer",
- "ms-playwright.playwright",
- "ms-azuretools.vscode-docker"
- ]
- }
- },
- "forwardPorts": [],
- "overrideCommand": true,
- "workspaceFolder": "/workspaces/immich",
- "remoteUser": "node",
- "userEnvProbe": "loginInteractiveShell",
- "remoteEnv": {
- // The location where your uploaded files are stored
- "UPLOAD_LOCATION": "${localEnv:UPLOAD_LOCATION:./library}",
- // Connection secret for postgres. You should change it to a random password
- // Please use only the characters `A-Za-z0-9`, without special characters or spaces
- "DB_PASSWORD": "${localEnv:DB_PASSWORD:postgres}",
- // The database username
- "DB_USERNAME": "${localEnv:DB_USERNAME:postgres}",
- // The database name
- "DB_DATABASE_NAME": "${localEnv:DB_DATABASE_NAME:immich}"
- }
-}
diff --git a/.devcontainer/server/container-common.sh b/.devcontainer/server/container-common.sh
deleted file mode 100755
index 3aa72379c3..0000000000
--- a/.devcontainer/server/container-common.sh
+++ /dev/null
@@ -1,81 +0,0 @@
-#!/bin/bash
-export IMMICH_PORT="${DEV_SERVER_PORT:-2283}"
-export DEV_PORT="${DEV_PORT:-3000}"
-
-# search for immich directory inside workspace.
-# /workspaces/immich is the bind mount, but other directories can be mounted if runing
-# Devcontainer: Clone [repository|pull request] in container volumne
-WORKSPACES_DIR="/workspaces"
-IMMICH_DIR="$WORKSPACES_DIR/immich"
-IMMICH_DEVCONTAINER_LOG="$HOME/immich-devcontainer.log"
-
-log() {
- # Display command on console, log with timestamp to file
- echo "$*"
- echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >>"$IMMICH_DEVCONTAINER_LOG"
-}
-
-run_cmd() {
- # Ensure log directory exists
- mkdir -p "$(dirname "$IMMICH_DEVCONTAINER_LOG")"
-
- log "$@"
-
- # Execute command: display normally on console, log with timestamps to file
- "$@" 2>&1 | tee >(while IFS= read -r line; do
- echo "[$(date '+%Y-%m-%d %H:%M:%S')] $line" >>"$IMMICH_DEVCONTAINER_LOG"
- done)
-
- # Preserve exit status
- return "${PIPESTATUS[0]}"
-}
-
-# Find directories excluding /workspaces/immich
-mapfile -t other_dirs < <(find "$WORKSPACES_DIR" -mindepth 1 -maxdepth 1 -type d ! -path "$IMMICH_DIR" ! -name ".*")
-
-if [ ${#other_dirs[@]} -gt 1 ]; then
- log "Error: More than one directory found in $WORKSPACES_DIR other than $IMMICH_DIR."
- exit 1
-elif [ ${#other_dirs[@]} -eq 1 ]; then
- export IMMICH_WORKSPACE="${other_dirs[0]}"
-else
- export IMMICH_WORKSPACE="$IMMICH_DIR"
-fi
-
-log "Found immich workspace in $IMMICH_WORKSPACE"
-log ""
-
-fix_permissions() {
-
- log "Fixing permissions for ${IMMICH_WORKSPACE}"
-
- # Change ownership for directories that exist
- for dir in "${IMMICH_WORKSPACE}/.vscode" \
- "${IMMICH_WORKSPACE}/server/upload" \
- "${IMMICH_WORKSPACE}/.pnpm-store" \
- "${IMMICH_WORKSPACE}/.github/node_modules" \
- "${IMMICH_WORKSPACE}/cli/node_modules" \
- "${IMMICH_WORKSPACE}/e2e/node_modules" \
- "${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \
- "${IMMICH_WORKSPACE}/server/node_modules" \
- "${IMMICH_WORKSPACE}/server/dist" \
- "${IMMICH_WORKSPACE}/web/node_modules" \
- "${IMMICH_WORKSPACE}/web/dist"; do
- if [ -d "$dir" ]; then
- run_cmd sudo chown node -R "$dir"
- fi
- done
-
- log ""
-}
-
-install_dependencies() {
-
- log "Installing dependencies"
- (
- cd "${IMMICH_WORKSPACE}" || exit 1
- export CI=1 FROZEN=1 OFFLINE=1
- run_cmd make setup-web-dev setup-server-dev
- )
- log ""
-}
diff --git a/.devcontainer/server/container-compose-overrides.yml b/.devcontainer/server/container-compose-overrides.yml
deleted file mode 100644
index cc2b0c907b..0000000000
--- a/.devcontainer/server/container-compose-overrides.yml
+++ /dev/null
@@ -1,43 +0,0 @@
-services:
- immich-server:
- build:
- target: dev-container-server
- env_file: !reset []
- hostname: immich-dev
- environment:
- - IMMICH_SERVER_URL=http://127.0.0.1:2283/
- volumes: !override
- - ..:/workspaces/immich
- - ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- - /etc/localtime:/etc/localtime:ro
- - pnpm-store:/usr/src/app/.pnpm-store
- - server-node_modules:/usr/src/app/server/node_modules
- - web-node_modules:/usr/src/app/web/node_modules
- - github-node_modules:/usr/src/app/.github/node_modules
- - cli-node_modules:/usr/src/app/cli/node_modules
- - docs-node_modules:/usr/src/app/docs/node_modules
- - e2e-node_modules:/usr/src/app/e2e/node_modules
- - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- - app-node_modules:/usr/src/app/node_modules
- - sveltekit:/usr/src/app/web/.svelte-kit
- - coverage:/usr/src/app/web/coverage
- - ../plugins:/build/corePlugin
- immich-web:
- env_file: !reset []
- immich-machine-learning:
- env_file: !reset []
- database:
- env_file: !reset []
- environment: !override
- POSTGRES_PASSWORD: ${DB_PASSWORD-postgres}
- POSTGRES_USER: ${DB_USERNAME-postgres}
- POSTGRES_DB: ${DB_DATABASE_NAME-immich}
- POSTGRES_INITDB_ARGS: '--data-checksums'
- POSTGRES_HOST_AUTH_METHOD: md5
- volumes:
- - ${UPLOAD_LOCATION:-postgres-devcontainer-volume}${UPLOAD_LOCATION:+/postgres}:/var/lib/postgresql/data
- redis:
- env_file: !reset []
-volumes:
- upload-devcontainer-volume:
- postgres-devcontainer-volume:
diff --git a/.devcontainer/server/container-start-backend.sh b/.devcontainer/server/container-start-backend.sh
deleted file mode 100755
index 35fa60f89b..0000000000
--- a/.devcontainer/server/container-start-backend.sh
+++ /dev/null
@@ -1,22 +0,0 @@
-#!/bin/bash
-# shellcheck source=common.sh
-# shellcheck disable=SC1091
-source /immich-devcontainer/container-common.sh
-
-log "Preparing Immich Nest API Server"
-log ""
-export CI=1
-run_cmd pnpm --filter immich install
-
-log "Starting Nest API Server"
-log ""
-cd "${IMMICH_WORKSPACE}/server" || (
- log "Immich workspace not found"
- exit 1
-)
-
-while true; do
- run_cmd pnpm --filter immich exec nest start --debug "0.0.0.0:9230" --watch
- log "Nest API Server crashed with exit code $?. Respawning in 3s ..."
- sleep 3
-done
diff --git a/.devcontainer/server/container-start-frontend.sh b/.devcontainer/server/container-start-frontend.sh
deleted file mode 100755
index 9a0d617d41..0000000000
--- a/.devcontainer/server/container-start-frontend.sh
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/bin/bash
-# shellcheck source=common.sh
-# shellcheck disable=SC1091
-source /immich-devcontainer/container-common.sh
-
-export CI=1
-log "Preparing Immich Web Frontend"
-log ""
-run_cmd pnpm --filter @immich/sdk install
-run_cmd pnpm --filter @immich/sdk build
-run_cmd pnpm --filter immich-web install
-
-log "Starting Immich Web Frontend"
-log ""
-cd "${IMMICH_WORKSPACE}/web" || (
- log "Immich Workspace not found"
- exit 1
-)
-
-until curl --output /dev/null --silent --head --fail "http://127.0.0.1:${IMMICH_PORT}/api/server/config"; do
- log "Waiting for api server..."
- sleep 1
-done
-
-while true; do
- run_cmd pnpm --filter immich-web exec vite dev --host 0.0.0.0 --port "${DEV_PORT}"
- log "Web crashed with exit code $?. Respawning in 3s ..."
- sleep 3
-done
diff --git a/.devcontainer/server/container-start.sh b/.devcontainer/server/container-start.sh
deleted file mode 100755
index 0edd38172e..0000000000
--- a/.devcontainer/server/container-start.sh
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/bin/bash
-# shellcheck source=common.sh
-# shellcheck disable=SC1091
-source /immich-devcontainer/container-common.sh
-
-log "Setting up Immich dev container..."
-fix_permissions
-
-log "Setup complete, please wait while backend and frontend services automatically start"
-log
-log "If necessary, the services may be manually started using"
-log
-log "$ /immich-devcontainer/container-start-backend.sh"
-log "$ /immich-devcontainer/container-start-frontend.sh"
-log
-log "From different terminal windows, as these scripts automatically restart the server"
-log "on error, and will continuously run in a loop"
diff --git a/.env.example b/.env.example
index 0a443884a1..4dbd8797e5 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,6 @@
# Database
DB_HOSTNAME=localhost
-DB_PORT=5432
+DB_PORT=5435
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE_NAME=app
diff --git a/.github/.nvmrc b/.github/.nvmrc
deleted file mode 100644
index 3fe3b1570a..0000000000
--- a/.github/.nvmrc
+++ /dev/null
@@ -1 +0,0 @@
-24.13.0
diff --git a/.github/.prettierignore b/.github/.prettierignore
deleted file mode 100644
index cc41cea9b2..0000000000
--- a/.github/.prettierignore
+++ /dev/null
@@ -1,4 +0,0 @@
-# Ignore files for PNPM, NPM and YARN
-pnpm-lock.yaml
-package-lock.json
-yarn.lock
diff --git a/.github/DISCUSSION_TEMPLATE/feature-request.yaml b/.github/DISCUSSION_TEMPLATE/feature-request.yaml
deleted file mode 100644
index 2c7492fd5b..0000000000
--- a/.github/DISCUSSION_TEMPLATE/feature-request.yaml
+++ /dev/null
@@ -1,33 +0,0 @@
-title: '[Feature] feature-name-goes-here'
-labels: ['feature']
-
-body:
- - type: markdown
- attributes:
- value: |
- Please use this form to request new feature for Immich.
- Stick to only a single feature per request. If you list multiple different features at once,
- your request will be closed.
-
- - type: checkboxes
- attributes:
- label: I have searched the existing feature requests, both open and closed, to make sure this is not a duplicate request.
- options:
- - label: 'Yes'
-
- - type: textarea
- id: feature
- attributes:
- label: The feature
- validations:
- required: true
-
- - type: checkboxes
- validations:
- required: true
- attributes:
- label: Platform
- options:
- - label: Server
- - label: Web
- - label: Mobile
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index acbb7c785b..0000000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1 +0,0 @@
-custom: ['https://buy.immich.app', 'https://immich.store']
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
deleted file mode 100644
index 9ed1be3655..0000000000
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ /dev/null
@@ -1,119 +0,0 @@
-name: Report an issue with Immich
-description: Report an issue with Immich
-body:
- - type: checkboxes
- attributes:
- label: I have searched the existing issues, both open and closed, to make sure this is not a duplicate report.
- options:
- - label: 'Yes'
-
- - type: markdown
- attributes:
- value: |
- This issue form is for reporting bugs only!
-
- If you have a feature or enhancement request, please use the [feature request][fr] section of our [GitHub Discussions][fr].
-
- [fr]: https://github.com/immich-app/immich/discussions/new?category=feature-request
-
- - type: textarea
- validations:
- required: true
- attributes:
- label: The bug
- description: >-
- Describe the issue you are experiencing here, to communicate to the
- maintainers. Tell us what you were trying to do and what happened.
-
- Provide a clear and concise description of what the problem is.
-
- - type: markdown
- attributes:
- value: |
- ## Environment
-
- - type: input
- validations:
- required: true
- attributes:
- label: The OS that Immich Server is running on
- placeholder: Ubuntu 22.10, Debian, Arch...etc
-
- - type: input
- id: version
- validations:
- required: true
- attributes:
- label: Version of Immich Server
- placeholder: v1.0.0
-
- - type: input
- validations:
- required: true
- attributes:
- label: Version of Immich Mobile App
- placeholder: v1.0.0
-
- - type: checkboxes
- validations:
- required: true
- attributes:
- label: Platform with the issue
- options:
- - label: Server
- - label: Web
- - label: Mobile
-
- - type: input
- attributes:
- label: Device make and model
- placeholder: Samsung S25 Android 16
-
- - type: textarea
- validations:
- required: true
- attributes:
- label: Your docker-compose.yml content
- render: YAML
-
- - type: textarea
- validations:
- required: true
- attributes:
- label: Your .env content
- description: Please provide the redacted .env content of your setup
- render: Shell
-
- - type: textarea
- id: repro
- attributes:
- label: Reproduction steps
- description: 'How do you trigger this bug? Please walk us through it step by step.'
- value: |
- 1.
- 2.
- 3.
- ...
- validations:
- required: true
-
- - type: textarea
- id: logs
- attributes:
- label: Relevant log output
- description:
- Please copy and paste any relevant logs below. (code formatting is
- enabled, no need for backticks)
- render: shell
- validations:
- required: false
-
- - type: textarea
- attributes:
- label: Additional information
- description: >
- If you have any additional information for us, use the field below.
-
- - type: markdown
- attributes:
- value: Thank you for submitting the form
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
deleted file mode 100644
index 0b0cfbafd9..0000000000
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ /dev/null
@@ -1,14 +0,0 @@
-blank_issues_enabled: false
-contact_links:
- - name: โ I have a question or need support
- url: https://discord.immich.app
- about: We use GitHub for tracking bugs, please check out our Discord channel for freaky fast support.
- - name: ๐ท My photo or video has a date, time, or timezone problem
- url: https://github.com/immich-app/immich/discussions/12650
- about: Upload a sample file to this discussion and we will take a look
- - name: ๐ Feature request
- url: https://github.com/immich-app/immich/discussions/new?category=feature-request
- about: Please use our GitHub Discussion for making feature requests.
- - name: ๐ซฃ I'm unsure where to go
- url: https://discord.immich.app
- about: If you are unsure where to go, then joining our Discord is recommended; Just ask!
diff --git a/.github/PULL_REQUEST_TEMPLATE/config.yml b/.github/PULL_REQUEST_TEMPLATE/config.yml
deleted file mode 100644
index 4172e3df95..0000000000
--- a/.github/PULL_REQUEST_TEMPLATE/config.yml
+++ /dev/null
@@ -1 +0,0 @@
-blank_pull_request_template_enabled: false
diff --git a/.github/labeler.yml b/.github/labeler.yml
deleted file mode 100644
index d0e4a3097b..0000000000
--- a/.github/labeler.yml
+++ /dev/null
@@ -1,37 +0,0 @@
-cli:
- - changed-files:
- - any-glob-to-any-file:
- - cli/src/**
-
-documentation:
- - changed-files:
- - any-glob-to-any-file:
- - docs/docs/**
- - docs/src/**
- - docs/static/**
-
-๐ฅ๏ธweb:
- - changed-files:
- - any-glob-to-any-file:
- - web/src/**
- - web/static/**
-
-๐ฑmobile:
- - changed-files:
- - any-glob-to-any-file:
- - mobile/lib/**
- - mobile/test/**
-
-๐๏ธserver:
- - changed-files:
- - any-glob-to-any-file:
- - server/src/**
- - server/test/**
-
-๐ง machine-learning:
- - changed-files:
- - any-glob-to-any-file:
- - machine-learning/**
-
-changelog:translation:
- - head-branch: ['^chore/translations$']
diff --git a/.github/mise.toml b/.github/mise.toml
deleted file mode 100644
index 6930d41187..0000000000
--- a/.github/mise.toml
+++ /dev/null
@@ -1,10 +0,0 @@
-[tasks.install]
-run = "pnpm install --filter github --frozen-lockfile"
-
-[tasks.format]
-env._.path = "./node_modules/.bin"
-run = "prettier --check ."
-
-[tasks."format-fix"]
-env._.path = "./node_modules/.bin"
-run = "prettier --write ."
diff --git a/.github/package.json b/.github/package.json
deleted file mode 100644
index 9b41cc7b4e..0000000000
--- a/.github/package.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "scripts": {
- "format": "prettier --check .",
- "format:fix": "prettier --write ."
- },
- "devDependencies": {
- "prettier": "^3.7.4"
- }
-}
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
deleted file mode 100644
index 0bd3b30814..0000000000
--- a/.github/pull_request_template.md
+++ /dev/null
@@ -1,40 +0,0 @@
-## Description
-
-
-
-
-
-Fixes # (issue)
-
-## How Has This Been Tested?
-
-
-
-- [ ] Test A
-- [ ] Test B
-
-Screenshots (if appropriate)
-
-
-
-
-
-
-
-## Checklist:
-
-- [ ] I have performed a self-review of my own code
-- [ ] I have made corresponding changes to the documentation if applicable
-- [ ] I have no unrelated changes in the PR.
-- [ ] I have confirmed that any new dependencies are strictly necessary.
-- [ ] I have written tests for new code (if applicable)
-- [ ] I have followed naming conventions/patterns in the surrounding code
-- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
-- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
-
-## Please describe to which degree, if any, an LLM was used in creating this pull request.
-
-...
diff --git a/.github/release.yml b/.github/release.yml
deleted file mode 100644
index 108daaf40f..0000000000
--- a/.github/release.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-changelog:
- categories:
- - title: ๐จ Breaking Changes
- labels:
- - changelog:breaking-change
-
- - title: ๐ซฅ Deprecated Changes
- labels:
- - changelog:deprecated
-
- - title: ๐ Security
- labels:
- - changelog:security
-
- - title: ๐ Features
- labels:
- - changelog:feature
-
- - title: ๐ Enhancements
- labels:
- - changelog:enhancement
-
- - title: ๐ Bug fixes
- labels:
- - changelog:bugfix
-
- - title: ๐ Documentation
- labels:
- - changelog:documentation
-
- - title: ๐ Translations
- labels:
- - changelog:translation
diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml
deleted file mode 100644
index b8ce6387af..0000000000
--- a/.github/workflows/build-mobile.yml
+++ /dev/null
@@ -1,297 +0,0 @@
-name: Build Mobile
-
-on:
- workflow_call:
- inputs:
- ref:
- required: false
- type: string
- environment:
- description: 'Target environment'
- required: true
- default: 'development'
- type: string
- secrets:
- KEY_JKS:
- required: true
- ALIAS:
- required: true
- ANDROID_KEY_PASSWORD:
- required: true
- ANDROID_STORE_PASSWORD:
- required: true
- APP_STORE_CONNECT_API_KEY_ID:
- required: true
- APP_STORE_CONNECT_API_KEY_ISSUER_ID:
- required: true
- APP_STORE_CONNECT_API_KEY:
- required: true
- IOS_CERTIFICATE_P12:
- required: true
- IOS_CERTIFICATE_PASSWORD:
- required: true
- FASTLANE_TEAM_ID:
- required: true
- pull_request:
- push:
- branches: [main]
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-permissions: {}
-
-jobs:
- pre-job:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- outputs:
- should_run: ${{ steps.check.outputs.should_run }}
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Check what should run
- id: check
- uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
- with:
- github-token: ${{ steps.token.outputs.token }}
- filters: |
- mobile:
- - 'mobile/**'
- force-filters: |
- - '.github/workflows/build-mobile.yml'
- force-events: 'workflow_call,workflow_dispatch'
-
- build-sign-android:
- name: Build and sign Android
- needs: pre-job
- permissions:
- contents: read
- # Skip when PR from a fork
- if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
- runs-on: mich
-
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- ref: ${{ inputs.ref || github.sha }}
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
-
- - name: Create the Keystore
- env:
- KEY_JKS: ${{ secrets.KEY_JKS }}
- working-directory: ./mobile
- run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
-
- - uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
- with:
- distribution: 'zulu'
- java-version: '17'
-
- - name: Restore Gradle Cache
- id: cache-gradle-restore
- uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- ~/.android/sdk
- mobile/android/.gradle
- mobile/.dart_tool
- key: build-mobile-gradle-${{ runner.os }}-main
-
- - name: Setup Flutter SDK
- uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
- with:
- channel: 'stable'
- flutter-version-file: ./mobile/pubspec.yaml
- cache: true
-
- - name: Setup Android SDK
- uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3.2.2
- with:
- packages: ''
-
- - name: Get Packages
- working-directory: ./mobile
- run: flutter pub get
-
- - name: Generate translation file
- run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart
- working-directory: ./mobile
-
- - name: Generate platform APIs
- run: make pigeon
- working-directory: ./mobile
-
- - name: Build Android App Bundle
- working-directory: ./mobile
- env:
- ALIAS: ${{ secrets.ALIAS }}
- ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
- ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
- IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
- run: |
- if [[ $IS_MAIN == 'true' ]]; then
- flutter build apk --release
- flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
- else
- flutter build apk --debug --split-per-abi --target-platform android-arm64
- fi
-
- - name: Publish Android Artifact
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
- with:
- name: release-apk-signed
- path: mobile/build/app/outputs/flutter-apk/*.apk
-
- - name: Save Gradle Cache
- id: cache-gradle-save
- uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
- if: github.ref == 'refs/heads/main'
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- ~/.android/sdk
- mobile/android/.gradle
- mobile/.dart_tool
- key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }}
-
- build-sign-ios:
- name: Build and sign iOS
- needs: pre-job
- permissions:
- contents: read
- # Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload)
- if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
- runs-on: macos-15
-
- steps:
- - name: Select Xcode 26
- run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- with:
- ref: ${{ inputs.ref || github.sha }}
- persist-credentials: false
-
- - name: Setup Flutter SDK
- uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2
- with:
- channel: 'stable'
- flutter-version-file: ./mobile/pubspec.yaml
- cache: true
-
- - name: Install Flutter dependencies
- working-directory: ./mobile
- run: flutter pub get
-
- - name: Generate translation files
- run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart
- working-directory: ./mobile
-
- - name: Generate platform APIs
- run: make pigeon
- working-directory: ./mobile
-
- - name: Setup Ruby
- uses: ruby/setup-ruby@v1
- with:
- ruby-version: '3.3'
- bundler-cache: true
- working-directory: ./mobile/ios
-
- - name: Install CocoaPods dependencies
- working-directory: ./mobile/ios
- run: |
- pod install
-
- - name: Create API Key
- env:
- API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
- API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
- API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
- working-directory: ./mobile/ios
- run: |
- mkdir -p ~/.appstoreconnect/private_keys
- echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8
-
- - name: Import Certificate
- env:
- IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
- working-directory: ./mobile/ios
- run: |
- # Decode certificate
- echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
-
- - name: Create keychain and import certificate
- env:
- KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
- CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
- working-directory: ./mobile/ios
- run: |
- # Create keychain
- security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
- security default-keychain -s build.keychain
- security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
- security set-keychain-settings -t 3600 -u build.keychain
-
- # Import certificate
- security import certificate.p12 -k build.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
- security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
-
- # Verify certificate was imported
- security find-identity -v -p codesigning build.keychain
-
- - name: Build and deploy to TestFlight
- env:
- FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
- IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
- KEYCHAIN_NAME: build.keychain
- KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
- APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
- APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
- ENVIRONMENT: ${{ inputs.environment || 'development' }}
- BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }}
- GITHUB_REF: ${{ github.ref }}
- FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120
- FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 6
- working-directory: ./mobile/ios
- run: |
- # Only upload to TestFlight on main branch
- if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
- if [[ "$ENVIRONMENT" == "development" ]]; then
- bundle exec fastlane gha_testflight_dev
- else
- bundle exec fastlane gha_release_prod
- fi
- else
- # Build only, no TestFlight upload for non-main branches
- bundle exec fastlane gha_build_only
- fi
-
- - name: Clean up keychain
- if: always()
- run: |
- security delete-keychain build.keychain || true
-
- - name: Upload IPA artifact
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
- with:
- name: ios-release-ipa
- path: mobile/ios/Runner.ipa
diff --git a/.github/workflows/cache-cleanup.yml b/.github/workflows/cache-cleanup.yml
deleted file mode 100644
index 55f91e7989..0000000000
--- a/.github/workflows/cache-cleanup.yml
+++ /dev/null
@@ -1,52 +0,0 @@
-name: Cache Cleanup
-on:
- pull_request:
- types:
- - closed
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-permissions: {}
-
-jobs:
- cleanup:
- name: Cleanup
- runs-on: ubuntu-latest
- permissions:
- contents: read
- actions: write
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Check out code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
-
- - name: Cleanup
- env:
- GH_TOKEN: ${{ steps.token.outputs.token }}
- REF: ${{ github.ref }}
- run: |
- gh extension install actions/gh-actions-cache
-
- REPO=${{ github.repository }}
-
- echo "Fetching list of cache keys"
- cacheKeysForPR=$(gh actions-cache list -R $REPO -B ${REF} -L 100 | cut -f 1 )
-
- ## Setting this to not fail the workflow while deleting cache keys.
- set +e
- echo "Deleting caches..."
- for cacheKey in $cacheKeysForPR
- do
- gh actions-cache delete $cacheKey -R "$REPO" -B "${REF}" --confirm
- done
- echo "Done"
diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml
deleted file mode 100644
index 3591539b68..0000000000
--- a/.github/workflows/cli.yml
+++ /dev/null
@@ -1,126 +0,0 @@
-name: CLI Build
-on:
- push:
- branches: [main]
- paths:
- - 'cli/**'
- - '.github/workflows/cli.yml'
- pull_request:
- paths:
- - 'cli/**'
- - '.github/workflows/cli.yml'
- release:
- types: [published]
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-permissions: {}
-
-jobs:
- publish:
- name: CLI Publish
- runs-on: ubuntu-latest
- permissions:
- contents: read
- id-token: write
- packages: write
- defaults:
- run:
- working-directory: ./cli
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
-
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
-
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './cli/.nvmrc'
- registry-url: 'https://registry.npmjs.org'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
-
- - name: Setup typescript-sdk
- run: pnpm install && pnpm run build
- working-directory: ./open-api/typescript-sdk
-
- - run: pnpm install --frozen-lockfile
- - run: pnpm build
- - run: pnpm publish --provenance --no-git-checks
- if: ${{ github.event_name == 'release' }}
-
- docker:
- name: Docker
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
- needs: publish
-
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
- - name: Login to GitHub Container Registry
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
- if: ${{ !github.event.pull_request.head.repo.fork }}
- with:
- registry: ghcr.io
- username: ${{ github.repository_owner }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Get package version
- id: package-version
- run: |
- version=$(jq -r '.version' cli/package.json)
- echo "version=$version" >> "$GITHUB_OUTPUT"
-
- - name: Generate docker image tags
- id: metadata
- uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
- with:
- flavor: |
- latest=false
- images: |
- name=ghcr.io/${{ github.repository_owner }}/immich-cli
- tags: |
- type=raw,value=${{ steps.package-version.outputs.version }},enable=${{ github.event_name == 'release' }}
- type=raw,value=latest,enable=${{ github.event_name == 'release' }}
-
- - name: Build and push image
- uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
- with:
- file: cli/Dockerfile
- platforms: linux/amd64,linux/arm64
- push: ${{ github.event_name == 'release' }}
- cache-from: type=gha
- cache-to: type=gha,mode=max
- tags: ${{ steps.metadata.outputs.tags }}
- labels: ${{ steps.metadata.outputs.labels }}
diff --git a/.github/workflows/close-duplicates.yml b/.github/workflows/close-duplicates.yml
deleted file mode 100644
index 09e9dbb338..0000000000
--- a/.github/workflows/close-duplicates.yml
+++ /dev/null
@@ -1,107 +0,0 @@
-on:
- issues:
- types: [opened]
- discussion:
- types: [created]
-
-name: Close likely duplicates
-permissions: {}
-
-jobs:
- should_run:
- runs-on: ubuntu-latest
- outputs:
- should_run: ${{ steps.should_run.outputs.run }}
- steps:
- - id: should_run
- run: echo "run=${{ github.event_name == 'issues' || github.event.discussion.category.name == 'Feature Request' }}" >> $GITHUB_OUTPUT
-
- get_body:
- runs-on: ubuntu-latest
- needs: should_run
- if: ${{ needs.should_run.outputs.should_run == 'true' }}
- env:
- EVENT: ${{ toJSON(github.event) }}
- outputs:
- body: ${{ steps.get_body.outputs.body }}
- steps:
- - id: get_body
- run: |
- BODY=$(echo """$EVENT""" | jq -r '.issue // .discussion | .body' | base64 -w 0)
- echo "body=$BODY" >> $GITHUB_OUTPUT
-
- get_checkbox_json:
- runs-on: ubuntu-latest
- needs: [get_body, should_run]
- if: ${{ needs.should_run.outputs.should_run == 'true' }}
- container:
- image: ghcr.io/immich-app/mdq:main@sha256:ab9f163cd5d5cec42704a26ca2769ecf3f10aa8e7bae847f1d527cdf075946e6
- outputs:
- checked: ${{ steps.get_checkbox.outputs.checked }}
- steps:
- - id: get_checkbox
- env:
- BODY: ${{ needs.get_body.outputs.body }}
- run: |
- CHECKED=$(echo "$BODY" | base64 -d | /mdq --output json '# I have searched | - [?] Yes' | jq '.items[0].list[0].checked // false')
- echo "checked=$CHECKED" >> $GITHUB_OUTPUT
-
- close_and_comment:
- runs-on: ubuntu-latest
- needs: [get_checkbox_json, should_run]
- if: ${{ needs.should_run.outputs.should_run == 'true' && needs.get_checkbox_json.outputs.checked != 'true' }}
- permissions:
- issues: write
- discussions: write
- steps:
- - name: Close issue
- if: ${{ github.event_name == 'issues' }}
- env:
- GH_TOKEN: ${{ github.token }}
- NODE_ID: ${{ github.event.issue.node_id }}
- run: |
- gh api graphql \
- -f issueId="$NODE_ID" \
- -f body="This issue has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one. If you're sure this is not a duplicate, please leave a comment and we will reopen the thread if necessary." \
- -f query='
- mutation CommentAndCloseIssue($issueId: ID!, $body: String!) {
- addComment(input: {
- subjectId: $issueId,
- body: $body
- }) {
- __typename
- }
-
- closeIssue(input: {
- issueId: $issueId,
- stateReason: DUPLICATE
- }) {
- __typename
- }
- }'
-
- - name: Close discussion
- if: ${{ github.event_name == 'discussion' && github.event.discussion.category.name == 'Feature Request' }}
- env:
- GH_TOKEN: ${{ github.token }}
- NODE_ID: ${{ github.event.discussion.node_id }}
- run: |
- gh api graphql \
- -f discussionId="$NODE_ID" \
- -f body="This discussion has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one. If you're sure this is not a duplicate, please leave a comment and we will reopen the thread if necessary." \
- -f query='
- mutation CommentAndCloseDiscussion($discussionId: ID!, $body: String!) {
- addDiscussionComment(input: {
- discussionId: $discussionId,
- body: $body
- }) {
- __typename
- }
-
- closeDiscussion(input: {
- discussionId: $discussionId,
- reason: DUPLICATE
- }) {
- __typename
- }
- }'
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
deleted file mode 100644
index 71b5968960..0000000000
--- a/.github/workflows/codeql-analysis.yml
+++ /dev/null
@@ -1,88 +0,0 @@
-# For most projects, this workflow file will not need changing; you simply need
-# to commit it to your repository.
-#
-# You may wish to alter this file to override the set of languages analyzed,
-# or to provide custom queries or build logic.
-#
-# ******** NOTE ********
-# We have attempted to detect the languages in your repository. Please check
-# the `language` matrix defined below to confirm you have the correct set of
-# supported CodeQL languages.
-#
-name: 'CodeQL'
-
-on:
- push:
- branches: ['main']
- pull_request:
- # The branches below must be a subset of the branches above
- branches: ['main']
- schedule:
- - cron: '20 13 * * 1'
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-permissions: {}
-
-jobs:
- analyze:
- name: Analyze
- runs-on: ubuntu-latest
- permissions:
- actions: read
- contents: read
- security-events: write
-
- strategy:
- fail-fast: false
- matrix:
- language: ['javascript', 'python']
- # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
- # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
-
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout repository
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
-
- # Initializes the CodeQL tools for scanning.
- - name: Initialize CodeQL
- uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
- with:
- languages: ${{ matrix.language }}
- # If you wish to specify custom queries, you can do so here or in a config file.
- # By default, queries listed here will override any specified in a config file.
- # Prefix the list here with "+" to use these queries and those in the config file.
-
- # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
- # queries: security-extended,security-and-quality
-
- # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
- # If this step fails, then you should remove it and run the build manually (see below)
- - name: Autobuild
- uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
-
- # โน๏ธ Command-line programs to run using the OS shell.
- # ๐ See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
-
- # If the Autobuild fails above, remove it and uncomment the following three lines.
- # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
-
- # - run: |
- # echo "Run, Build Application using script"
- # ./location_of_script_within_repo/buildscript.sh
-
- - name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
- with:
- category: '/language:${{matrix.language}}'
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
deleted file mode 100644
index e27f1ebdf9..0000000000
--- a/.github/workflows/docker.yml
+++ /dev/null
@@ -1,194 +0,0 @@
-name: Docker
-
-on:
- workflow_dispatch:
- push:
- branches: [main]
- pull_request:
- release:
- types: [published]
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-permissions: {}
-
-jobs:
- pre-job:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- outputs:
- should_run: ${{ steps.check.outputs.should_run }}
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Check what should run
- id: check
- uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
- with:
- github-token: ${{ steps.token.outputs.token }}
- filters: |
- server:
- - 'server/**'
- - 'openapi/**'
- - 'web/**'
- - 'i18n/**'
- machine-learning:
- - 'machine-learning/**'
- force-filters: |
- - '.github/workflows/docker.yml'
- - '.github/workflows/multi-runner-build.yml'
- - '.github/actions/image-build'
- force-events: 'workflow_dispatch,release'
-
- retag_ml:
- name: Re-Tag ML
- needs: pre-job
- permissions:
- contents: read
- packages: write
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == false && !github.event.pull_request.head.repo.fork }}
- runs-on: ubuntu-latest
- strategy:
- matrix:
- suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
- steps:
- - name: Login to GitHub Container Registry
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
- with:
- registry: ghcr.io
- username: ${{ github.repository_owner }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Re-tag image
- env:
- REGISTRY_NAME: 'ghcr.io'
- REPOSITORY: ${{ github.repository_owner }}/immich-machine-learning
- TAG_OLD: main${{ matrix.suffix }}
- TAG_PR: ${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }}
- TAG_COMMIT: commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}${{ matrix.suffix }}
- run: |
- docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_PR}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
- docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_COMMIT}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
-
- retag_server:
- name: Re-Tag Server
- needs: pre-job
- permissions:
- contents: read
- packages: write
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == false && !github.event.pull_request.head.repo.fork }}
- runs-on: ubuntu-latest
- strategy:
- matrix:
- suffix: ['']
- steps:
- - name: Login to GitHub Container Registry
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
- with:
- registry: ghcr.io
- username: ${{ github.repository_owner }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Re-tag image
- env:
- REGISTRY_NAME: 'ghcr.io'
- REPOSITORY: ${{ github.repository_owner }}/immich-server
- TAG_OLD: main${{ matrix.suffix }}
- TAG_PR: ${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }}
- TAG_COMMIT: commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}${{ matrix.suffix }}
- run: |
- docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_PR}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
- docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_COMMIT}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
-
- machine-learning:
- name: Build and Push ML
- needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == true }}
- strategy:
- fail-fast: false
- matrix:
- include:
- - device: cpu
- - device: cuda
- suffixes: '-cuda'
- platforms: linux/amd64
- - device: openvino
- suffixes: '-openvino'
- platforms: linux/amd64
- - device: armnn
- suffixes: '-armnn'
- platforms: linux/arm64
- - device: rknn
- suffixes: '-rknn'
- platforms: linux/arm64
- - device: rocm
- suffixes: '-rocm'
- platforms: linux/amd64
- runner-mapping: '{"linux/amd64": "mich"}'
- uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
- permissions:
- contents: read
- actions: read
- packages: write
- secrets:
- DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
- DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
- with:
- image: immich-machine-learning
- context: machine-learning
- dockerfile: machine-learning/Dockerfile
- platforms: ${{ matrix.platforms }}
- runner-mapping: ${{ matrix.runner-mapping }}
- suffixes: ${{ matrix.suffixes }}
- dockerhub-push: ${{ github.event_name == 'release' }}
- build-args: |
- DEVICE=${{ matrix.device }}
-
- server:
- name: Build and Push Server
- needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
- uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
- permissions:
- contents: read
- actions: read
- packages: write
- secrets:
- DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
- DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
- with:
- image: immich-server
- context: .
- dockerfile: server/Dockerfile
- dockerhub-push: ${{ github.event_name == 'release' }}
- build-args: |
- DEVICE=cpu
-
- success-check-server:
- name: Docker Build & Push Server Success
- needs: [server, retag_server]
- permissions: {}
- runs-on: ubuntu-latest
- if: always()
- steps:
- - uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- with:
- needs: ${{ toJSON(needs) }}
-
- success-check-ml:
- name: Docker Build & Push ML Success
- needs: [machine-learning, retag_ml]
- permissions: {}
- runs-on: ubuntu-latest
- if: always()
- steps:
- - uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- with:
- needs: ${{ toJSON(needs) }}
diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml
deleted file mode 100644
index 91916e4ed2..0000000000
--- a/.github/workflows/docs-build.yml
+++ /dev/null
@@ -1,94 +0,0 @@
-name: Docs build
-on:
- push:
- branches: [main]
- pull_request:
- release:
- types: [published]
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-permissions: {}
-
-jobs:
- pre-job:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- outputs:
- should_run: ${{ steps.check.outputs.should_run }}
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Check what should run
- id: check
- uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
- with:
- github-token: ${{ steps.token.outputs.token }}
- filters: |
- docs:
- - 'docs/**'
- open-api:
- - 'open-api/immich-openapi-specs.json'
- force-filters: |
- - '.github/workflows/docs-build.yml'
- force-events: 'release'
- force-branches: 'main'
-
- build:
- name: Docs Build
- needs: pre-job
- permissions:
- contents: read
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).docs == true }}
- runs-on: ubuntu-latest
- defaults:
- run:
- working-directory: ./docs
-
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
- fetch-depth: 0
-
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
-
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './docs/.nvmrc'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
-
- - name: Run install
- run: pnpm install
-
- - name: Check formatting
- run: pnpm format
-
- - name: Run build
- run: pnpm build
-
- - name: Upload build output
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
- with:
- name: docs-build-output
- path: docs/build/
- include-hidden-files: true
- retention-days: 1
diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml
deleted file mode 100644
index 8c0bf76f30..0000000000
--- a/.github/workflows/docs-deploy.yml
+++ /dev/null
@@ -1,225 +0,0 @@
-name: Docs deploy
-on:
- workflow_run: # zizmor: ignore[dangerous-triggers] no attacker inputs are used here
- workflows: ['Docs build']
- types:
- - completed
-
-env:
- TG_NON_INTERACTIVE: 'true'
-
-jobs:
- checks:
- name: Docs Deploy Checks
- runs-on: ubuntu-latest
- permissions:
- actions: read
- pull-requests: read
- outputs:
- parameters: ${{ steps.parameters.outputs.result }}
- artifact: ${{ steps.get-artifact.outputs.result }}
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - if: ${{ github.event.workflow_run.conclusion != 'success' }}
- run: echo 'The triggering workflow did not succeed' && exit 1
- - name: Get artifact
- id: get-artifact
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- with:
- github-token: ${{ steps.token.outputs.token }}
- script: |
- let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
- owner: context.repo.owner,
- repo: context.repo.repo,
- run_id: context.payload.workflow_run.id,
- });
- let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
- return artifact.name == "docs-build-output"
- })[0];
- if (!matchArtifact) {
- console.log("No artifact found with the name docs-build-output, build job was skipped")
- return { found: false };
- }
- return { found: true, id: matchArtifact.id };
- - name: Determine deploy parameters
- id: parameters
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- env:
- HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
- with:
- github-token: ${{ steps.token.outputs.token }}
- script: |
- const eventType = context.payload.workflow_run.event;
- const isFork = context.payload.workflow_run.repository.fork;
-
- let parameters;
-
- console.log({eventType, isFork});
-
- if (eventType == "push") {
- const branch = context.payload.workflow_run.head_branch;
- console.log({branch});
- const shouldDeploy = !isFork && branch == "main";
- parameters = {
- event: "branch",
- name: "main",
- shouldDeploy
- };
- } else if (eventType == "pull_request") {
- let pull_number = context.payload.workflow_run.pull_requests[0]?.number;
- if(!pull_number) {
- const {HEAD_SHA} = process.env;
- const response = await github.rest.search.issuesAndPullRequests({q: `repo:${{ github.repository }} is:pr sha:${HEAD_SHA}`,per_page: 1,})
- const items = response.data.items
- if (items.length < 1) {
- throw new Error("No pull request found for the commit")
- }
- const pullRequestNumber = items[0].number
- console.info("Pull request number is", pullRequestNumber)
- pull_number = pullRequestNumber
- }
- const {data: pr} = await github.rest.pulls.get({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number
- });
-
- console.log({pull_number});
-
- parameters = {
- event: "pr",
- name: `pr-${pull_number}`,
- pr_number: pull_number,
- shouldDeploy: true
- };
- } else if (eventType == "release") {
- parameters = {
- event: "release",
- name: context.payload.workflow_run.head_branch,
- shouldDeploy: !isFork
- };
- }
-
- console.log(parameters);
- return parameters;
-
- deploy:
- name: Docs Deploy
- runs-on: ubuntu-latest
- needs: checks
- permissions:
- contents: read
- actions: read
- pull-requests: write
- if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
-
- - name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
-
- - name: Load parameters
- id: parameters
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- env:
- PARAM_JSON: ${{ needs.checks.outputs.parameters }}
- with:
- github-token: ${{ steps.token.outputs.token }}
- script: |
- const parameters = JSON.parse(process.env.PARAM_JSON);
- core.setOutput("event", parameters.event);
- core.setOutput("name", parameters.name);
- core.setOutput("shouldDeploy", parameters.shouldDeploy);
-
- - name: Download artifact
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- env:
- ARTIFACT_JSON: ${{ needs.checks.outputs.artifact }}
- with:
- github-token: ${{ steps.token.outputs.token }}
- script: |
- let artifact = JSON.parse(process.env.ARTIFACT_JSON);
- let download = await github.rest.actions.downloadArtifact({
- owner: context.repo.owner,
- repo: context.repo.repo,
- artifact_id: artifact.id,
- archive_format: 'zip',
- });
- let fs = require('fs');
- fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/docs-build-output.zip`, Buffer.from(download.data));
-
- - name: Unzip artifact
- run: unzip "${{ github.workspace }}/docs-build-output.zip" -d "${{ github.workspace }}/docs/build"
-
- - name: Deploy Docs Subdomain
- env:
- TF_VAR_prefix_name: ${{ steps.parameters.outputs.name}}
- TF_VAR_prefix_event_type: ${{ steps.parameters.outputs.event }}
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
- working-directory: 'deployment/modules/cloudflare/docs'
- run: 'mise run //deployment:tf apply'
-
- - name: Deploy Docs Subdomain Output
- id: docs-output
- env:
- TF_VAR_prefix_name: ${{ steps.parameters.outputs.name}}
- TF_VAR_prefix_event_type: ${{ steps.parameters.outputs.event }}
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
- working-directory: 'deployment/modules/cloudflare/docs'
- run: |
- mise run //deployment:tf output -- -json | jq -r '
- "projectName=\(.pages_project_name.value)",
- "subdomain=\(.immich_app_branch_subdomain.value)"
- ' >> $GITHUB_OUTPUT
-
- - name: Publish to Cloudflare Pages
- # TODO: Action is deprecated
- uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1.5.0
- with:
- apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }}
- accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- projectName: ${{ steps.docs-output.outputs.projectName }}
- workingDirectory: 'docs'
- directory: 'build'
- branch: ${{ steps.parameters.outputs.name }}
- wranglerVersion: '3'
-
- - name: Deploy Docs Release Domain
- if: ${{ steps.parameters.outputs.event == 'release' }}
- env:
- TF_VAR_prefix_name: ${{ steps.parameters.outputs.name}}
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
- working-directory: 'deployment/modules/cloudflare/docs-release'
- run: 'mise run //deployment:tf apply'
-
- - name: Comment
- uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
- if: ${{ steps.parameters.outputs.event == 'pr' }}
- with:
- token: ${{ steps.token.outputs.token }}
- number: ${{ fromJson(needs.checks.outputs.parameters).pr_number }}
- body: |
- ๐ Documentation deployed to [${{ steps.docs-output.outputs.subdomain }}](https://${{ steps.docs-output.outputs.subdomain }})
- emojis: 'rocket'
- body-include: ''
diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml
deleted file mode 100644
index a7d068cb43..0000000000
--- a/.github/workflows/docs-destroy.yml
+++ /dev/null
@@ -1,50 +0,0 @@
-name: Docs destroy
-on:
- pull_request_target: # zizmor: ignore[dangerous-triggers] no attacker inputs are used here
- types: [closed]
-
-permissions: {}
-
-env:
- TG_NON_INTERACTIVE: 'true'
-
-jobs:
- deploy:
- name: Docs Destroy
- runs-on: ubuntu-latest
- permissions:
- contents: read
- pull-requests: write
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
-
- - name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
-
- - name: Destroy Docs Subdomain
- env:
- TF_VAR_prefix_name: 'pr-${{ github.event.number }}'
- TF_VAR_prefix_event_type: 'pr'
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
- working-directory: 'deployment/modules/cloudflare/docs'
- run: 'mise run //deployment:tf destroy -- -refresh=false'
-
- - name: Comment
- uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
- with:
- token: ${{ steps.token.outputs.token }}
- number: ${{ github.event.number }}
- delete: true
- body-include: ''
diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml
deleted file mode 100644
index 11a9ef06e4..0000000000
--- a/.github/workflows/fix-format.yml
+++ /dev/null
@@ -1,61 +0,0 @@
-name: Fix formatting
-
-on:
- pull_request:
- types: [labeled]
-
-permissions: {}
-
-jobs:
- fix-formatting:
- runs-on: ubuntu-latest
- if: ${{ github.event.label.name == 'fix:formatting' }}
- permissions:
- contents: write
- pull-requests: write
- steps:
- - name: Generate a token
- id: generate-token
- uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: 'Checkout'
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- ref: ${{ github.event.pull_request.head.ref }}
- token: ${{ steps.generate-token.outputs.token }}
- persist-credentials: true
-
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
-
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './server/.nvmrc'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
-
- - name: Fix formatting
- run: pnpm --recursive install && pnpm run --recursive --parallel fix:format
-
- - name: Commit and push
- uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
- with:
- default_author: github_actions
- message: 'chore: fix formatting'
-
- - name: Remove label
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- if: always()
- with:
- github-token: ${{ steps.generate-token.outputs.token }}
- script: |
- github.rest.issues.removeLabel({
- issue_number: context.payload.pull_request.number,
- owner: context.repo.owner,
- repo: context.repo.repo,
- name: 'fix:formatting'
- })
diff --git a/.github/workflows/merge-translations.yml b/.github/workflows/merge-translations.yml
deleted file mode 100644
index 392dec5e37..0000000000
--- a/.github/workflows/merge-translations.yml
+++ /dev/null
@@ -1,128 +0,0 @@
-name: Merge translations
-
-on:
- workflow_dispatch:
- workflow_call:
- secrets:
- PUSH_O_MATIC_APP_ID:
- required: true
- PUSH_O_MATIC_APP_KEY:
- required: true
- WEBLATE_TOKEN:
- required: true
- inputs:
- skip:
- description: 'Skip translations'
- required: false
- type: boolean
-
-permissions: {}
-
-env:
- WEBLATE_HOST: 'https://hosted.weblate.org'
- WEBLATE_COMPONENT: 'immich/immich'
-
-jobs:
- merge:
- runs-on: ubuntu-latest
- permissions:
- pull-requests: write
- steps:
- - name: Generate a token
- id: generate_token
- if: ${{ inputs.skip != true }}
- uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Find translation PR
- id: find_pr
- if: ${{ inputs.skip != true }}
- env:
- GH_TOKEN: ${{ steps.generate_token.outputs.token }}
- run: |
- set -euo pipefail
-
- PR=$(gh pr list --repo $GITHUB_REPOSITORY --author weblate --json number,mergeable)
- echo "$PR"
-
- PR_NUMBER=$(echo "$PR" | jq '
- if length == 1 then
- .[0].number
- else
- error("Expected exactly 1 entry, got \(length)")
- end
- ' 2>&1) || exit 1
-
- echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_OUTPUT
- echo "Selected PR $PR_NUMBER"
-
- if ! echo "$PR" | jq -e '.[0].mergeable == "MERGEABLE"'; then
- echo "PR is not mergeable"
- exit 1
- fi
-
- - name: Lock weblate
- if: ${{ inputs.skip != true }}
- env:
- WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
- run: |
- curl --fail-with-body -X POST -H "Authorization: Token $WEBLATE_TOKEN" "$WEBLATE_HOST/api/components/$WEBLATE_COMPONENT/lock/" -d lock=true
-
- - name: Commit translations
- if: ${{ inputs.skip != true }}
- env:
- WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
- run: |
- curl --fail-with-body -X POST -H "Authorization: Token $WEBLATE_TOKEN" "$WEBLATE_HOST/api/components/$WEBLATE_COMPONENT/repository/" -d operation=commit
- curl --fail-with-body -X POST -H "Authorization: Token $WEBLATE_TOKEN" "$WEBLATE_HOST/api/components/$WEBLATE_COMPONENT/repository/" -d operation=push
-
- - name: Merge PR
- id: merge_pr
- if: ${{ inputs.skip != true }}
- env:
- GH_TOKEN: ${{ steps.generate_token.outputs.token }}
- PR_NUMBER: ${{ steps.find_pr.outputs.PR_NUMBER }}
- run: |
- set -euo pipefail
-
- REVIEW_ID=$(gh api -X POST "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews" --field event='APPROVE' --field body='Automatically merging translations PR' \
- | jq '.id')
- echo "REVIEW_ID=$REVIEW_ID" >> $GITHUB_OUTPUT
- gh pr merge "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --auto --squash
-
- - name: Wait for PR to merge
- if: ${{ inputs.skip != true }}
- env:
- GH_TOKEN: ${{ steps.generate_token.outputs.token }}
- PR_NUMBER: ${{ steps.find_pr.outputs.PR_NUMBER }}
- REVIEW_ID: ${{ steps.merge_pr.outputs.REVIEW_ID }}
- run: |
- # So we clean up no matter what
- set +e
-
- for i in {1..100}; do
- if gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state | jq -e '.state == "MERGED"'; then
- echo "PR merged"
- exit 0
- else
- echo "PR not merged yet, waiting..."
- sleep 6
- fi
- done
- echo "PR did not merge in time"
- gh api -X PUT "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews/$REVIEW_ID/dismissals" --field message='Merge attempt timed out' --field event='DISMISS'
- gh pr merge "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --disable-auto
- exit 1
-
- - name: Unlock weblate
- if: ${{ inputs.skip != true }}
- env:
- WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
- run: |
- curl --fail-with-body -X POST -H "Authorization: Token $WEBLATE_TOKEN" "$WEBLATE_HOST/api/components/$WEBLATE_COMPONENT/lock/" -d lock=false
-
- - name: Report success
- run: |
- echo "Workflow completed successfully (or was skipped)"
diff --git a/.github/workflows/org-pr-require-conventional-commit.yml b/.github/workflows/org-pr-require-conventional-commit.yml
deleted file mode 100644
index 5e5f84ef39..0000000000
--- a/.github/workflows/org-pr-require-conventional-commit.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-name: PR Conventional Commit
-
-on:
- pull_request:
- types: [opened, synchronize, reopened, edited]
-
-jobs:
- validate-pr-title:
- name: Validate PR Title (conventional commit)
- uses: immich-app/devtools/.github/workflows/shared-pr-require-conventional-commit.yml@main
- permissions:
- pull-requests: write
diff --git a/.github/workflows/org-zizmor.yml b/.github/workflows/org-zizmor.yml
deleted file mode 100644
index 8510fd85b4..0000000000
--- a/.github/workflows/org-zizmor.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-name: Zizmor
-
-on:
- pull_request:
- push:
- branches: [main]
-
-jobs:
- zizmor:
- name: Zizmor
- uses: immich-app/devtools/.github/workflows/shared-zizmor.yml@main
- permissions:
- actions: read
- contents: read
- security-events: write
diff --git a/.github/workflows/pr-label-validation.yml b/.github/workflows/pr-label-validation.yml
deleted file mode 100644
index 0544de3dad..0000000000
--- a/.github/workflows/pr-label-validation.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-name: PR Label Validation
-
-on:
- pull_request_target: # zizmor: ignore[dangerous-triggers] no attacker inputs are used here
- types: [opened, labeled, unlabeled, synchronize]
-
-permissions: {}
-
-jobs:
- validate-release-label:
- runs-on: ubuntu-latest
- permissions:
- issues: write
- pull-requests: write
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Require PR to have a changelog label
- uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5.5.1
- with:
- token: ${{ steps.token.outputs.token }}
- mode: exactly
- count: 1
- use_regex: true
- labels: 'changelog:.*'
- add_comment: true
- message: 'Label error. Requires {{errorString}} {{count}} of: {{ provided }}. Found: {{ applied }}. A maintainer will add the required label.'
diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml
deleted file mode 100644
index 263426e548..0000000000
--- a/.github/workflows/pr-labeler.yml
+++ /dev/null
@@ -1,22 +0,0 @@
-name: 'Pull Request Labeler'
-on:
- - pull_request_target # zizmor: ignore[dangerous-triggers] no attacker inputs are used here
-
-permissions: {}
-
-jobs:
- labeler:
- permissions:
- contents: read
- pull-requests: write
- runs-on: ubuntu-latest
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
- with:
- repo-token: ${{ steps.token.outputs.token }}
diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
deleted file mode 100644
index 373fbaf6c1..0000000000
--- a/.github/workflows/prepare-release.yml
+++ /dev/null
@@ -1,158 +0,0 @@
-name: Prepare new release
-
-on:
- workflow_dispatch:
- inputs:
- serverBump:
- description: 'Bump server version'
- required: true
- default: 'false'
- type: choice
- options:
- - 'false'
- - major
- - minor
- - patch
- mobileBump:
- description: 'Bump mobile build number'
- required: false
- type: boolean
- skipTranslations:
- description: 'Skip translations'
- required: false
- type: boolean
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}-root
- cancel-in-progress: true
-
-permissions: {}
-
-jobs:
- merge_translations:
- uses: ./.github/workflows/merge-translations.yml
- with:
- skip: ${{ inputs.skipTranslations }}
- permissions:
- pull-requests: write
- secrets:
- PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
-
- bump_version:
- runs-on: ubuntu-latest
- needs: [merge_translations]
- outputs:
- ref: ${{ steps.push-tag.outputs.commit_long_sha }}
- version: ${{ steps.output.outputs.version }}
- permissions: {} # No job-level permissions are needed because it uses the app-token
- steps:
- - name: Generate a token
- id: generate-token
- uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- token: ${{ steps.generate-token.outputs.token }}
- persist-credentials: true
- ref: main
-
- - name: Install uv
- uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
-
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
-
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './server/.nvmrc'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
-
- - name: Bump version
- env:
- SERVER_BUMP: ${{ inputs.serverBump }}
- MOBILE_BUMP: ${{ inputs.mobileBump }}
- run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
-
- - id: output
- run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
-
- - name: Commit and tag
- id: push-tag
- uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
- with:
- default_author: github_actions
- message: 'chore: version ${{ steps.output.outputs.version }}'
- tag: ${{ steps.output.outputs.version }}
- push: true
-
- build_mobile:
- uses: ./.github/workflows/build-mobile.yml
- needs: bump_version
- permissions:
- contents: read
- secrets:
- KEY_JKS: ${{ secrets.KEY_JKS }}
- ALIAS: ${{ secrets.ALIAS }}
- ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
- ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
- # iOS secrets
- APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
- APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
- APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
- IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
- IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
- FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
-
- with:
- ref: ${{ needs.bump_version.outputs.ref }}
- environment: production
-
- prepare_release:
- runs-on: ubuntu-latest
- needs: [build_mobile, bump_version]
- permissions:
- actions: read # To download the app artifact
- # No content permissions are needed because it uses the app-token
- steps:
- - name: Generate a token
- id: generate-token
- uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- token: ${{ steps.generate-token.outputs.token }}
- persist-credentials: false
-
- - name: Download APK
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
- with:
- name: release-apk-signed
- github-token: ${{ steps.generate-token.outputs.token }}
-
- - name: Create draft release
- uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
- with:
- draft: true
- tag_name: ${{ needs.bump_version.outputs.version }}
- token: ${{ steps.generate-token.outputs.token }}
- generate_release_notes: true
- body_path: misc/release/notes.tmpl
- files: |
- docker/docker-compose.yml
- docker/example.env
- docker/hwaccel.ml.yml
- docker/hwaccel.transcoding.yml
- docker/prometheus.yml
- *.apk
diff --git a/.github/workflows/preview-label.yaml b/.github/workflows/preview-label.yaml
deleted file mode 100644
index 8760b67fc0..0000000000
--- a/.github/workflows/preview-label.yaml
+++ /dev/null
@@ -1,63 +0,0 @@
-name: Preview label
-
-on:
- pull_request:
- types: [labeled, closed]
-
-permissions: {}
-
-jobs:
- comment-status:
- runs-on: ubuntu-latest
- if: ${{ github.event.action == 'labeled' && github.event.label.name == 'preview' }}
- permissions:
- pull-requests: write
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
- with:
- github-token: ${{ steps.token.outputs.token }}
- message-id: 'preview-status'
- message: 'Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.build/'
-
- remove-label:
- runs-on: ubuntu-latest
- if: ${{ (github.event.action == 'closed' || github.event.pull_request.head.repo.fork) && contains(github.event.pull_request.labels.*.name, 'preview') }}
- permissions:
- pull-requests: write
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- with:
- github-token: ${{ steps.token.outputs.token }}
- script: |
- github.rest.issues.removeLabel({
- issue_number: context.payload.pull_request.number,
- owner: context.repo.owner,
- repo: context.repo.repo,
- name: 'preview'
- })
-
- - uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
- if: ${{ github.event.pull_request.head.repo.fork }}
- with:
- github-token: ${{ steps.token.outputs.token }}
- message-id: 'preview-status'
- message: 'PRs from forks cannot have preview environments.'
-
- - uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
- if: ${{ !github.event.pull_request.head.repo.fork }}
- with:
- github-token: ${{ steps.token.outputs.token }}
- message-id: 'preview-status'
- message: 'Preview environment has been removed.'
diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml
deleted file mode 100644
index 3ee96c45b7..0000000000
--- a/.github/workflows/release-pr.yml
+++ /dev/null
@@ -1,170 +0,0 @@
-name: Manage release PR
-on:
- workflow_dispatch:
- push:
- branches:
- - main
-
-concurrency:
- group: ${{ github.workflow }}
- cancel-in-progress: true
-
-permissions: {}
-
-jobs:
- bump:
- runs-on: ubuntu-latest
- steps:
- - name: Generate a token
- id: generate-token
- uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- token: ${{ steps.generate-token.outputs.token }}
- persist-credentials: true
- ref: main
-
- - name: Install uv
- uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
-
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
-
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './server/.nvmrc'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
-
- - name: Determine release type
- id: bump-type
- uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0
- with:
- token: ${{ steps.generate-token.outputs.token }}
-
- - name: Bump versions
- env:
- TYPE: ${{ steps.bump-type.outputs.bump }}
- run: |
- if [ "$TYPE" == "none" ]; then
- exit 1 # TODO: Is there a cleaner way to abort the workflow?
- fi
- misc/release/pump-version.sh -s $TYPE -m true
-
- - name: Manage Outline release document
- id: outline
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- env:
- OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
- NEXT_VERSION: ${{ steps.bump-type.outputs.next }}
- with:
- github-token: ${{ steps.generate-token.outputs.token }}
- script: |
- const fs = require('fs');
-
- const outlineKey = process.env.OUTLINE_API_KEY;
- const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'
- const collectionId = 'e2910656-714c-4871-8721-447d9353bd73';
- const baseUrl = 'https://outline.immich.cloud';
-
- const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
- method: 'POST',
- headers: {
- 'Authorization': `Bearer ${outlineKey}`,
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({ parentDocumentId })
- });
-
- if (!listResponse.ok) {
- throw new Error(`Outline list failed: ${listResponse.statusText}`);
- }
-
- const listData = await listResponse.json();
- const allDocuments = listData.data || [];
-
- const document = allDocuments.find(doc => doc.title === 'next');
-
- let documentId;
- let documentUrl;
- let documentText;
-
- if (!document) {
- // Create new document
- console.log('No existing document found. Creating new one...');
- const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8');
- const createResponse = await fetch(`${baseUrl}/api/documents.create`, {
- method: 'POST',
- headers: {
- 'Authorization': `Bearer ${outlineKey}`,
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- title: 'next',
- text: notesTmpl,
- collectionId: collectionId,
- parentDocumentId: parentDocumentId,
- publish: true
- })
- });
-
- if (!createResponse.ok) {
- throw new Error(`Failed to create document: ${createResponse.statusText}`);
- }
-
- const createData = await createResponse.json();
- documentId = createData.data.id;
- const urlId = createData.data.urlId;
- documentUrl = `${baseUrl}/doc/next-${urlId}`;
- documentText = createData.data.text || '';
- console.log(`Created new document: ${documentUrl}`);
- } else {
- documentId = document.id;
- const docPath = document.url;
- documentUrl = `${baseUrl}${docPath}`;
- documentText = document.text || '';
- console.log(`Found existing document: ${documentUrl}`);
- }
-
- // Generate GitHub release notes
- console.log('Generating GitHub release notes...');
- const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({
- owner: context.repo.owner,
- repo: context.repo.repo,
- tag_name: `${process.env.NEXT_VERSION}`,
- });
-
- // Combine the content
- const changelog = `
- # ${process.env.NEXT_VERSION}
-
- ${documentText}
-
- ${releaseNotesResponse.data.body}
-
- ---
-
- `
-
- const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '';
- fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8');
-
- core.setOutput('document_url', documentUrl);
-
- - name: Create PR
- id: create-pr
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
- with:
- token: ${{ steps.generate-token.outputs.token }}
- commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
- title: 'chore: release ${{ steps.bump-type.outputs.next }}'
- body: 'Release notes: ${{ steps.outline.outputs.document_url }}'
- labels: 'changelog:skip'
- branch: 'release/next'
- draft: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index 30783f5e9b..0000000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,148 +0,0 @@
-name: release.yml
-on:
- pull_request:
- types: [closed]
- paths:
- - CHANGELOG.md
-
-jobs:
- # Maybe double check PR source branch?
-
- merge_translations:
- uses: ./.github/workflows/merge-translations.yml
- permissions:
- pull-requests: write
- secrets:
- PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
-
- build_mobile:
- uses: ./.github/workflows/build-mobile.yml
- needs: merge_translations
- permissions:
- contents: read
- secrets:
- KEY_JKS: ${{ secrets.KEY_JKS }}
- ALIAS: ${{ secrets.ALIAS }}
- ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
- ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
- # iOS secrets
- APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
- APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
- APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
- IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
- IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
- IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
- IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl
- IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
- IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
- IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
- IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
- FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
- with:
- ref: main
- environment: production
-
- prepare_release:
- runs-on: ubuntu-latest
- needs: build_mobile
- permissions:
- actions: read # To download the app artifact
- steps:
- - name: Generate a token
- id: generate-token
- uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- token: ${{ steps.generate-token.outputs.token }}
- persist-credentials: false
- ref: main
-
- - name: Extract changelog
- id: changelog
- run: |
- CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
- sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
- echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
- VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
- echo "version=$VERSION" >> $GITHUB_OUTPUT
-
- - name: Download APK
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
- with:
- name: release-apk-signed
- github-token: ${{ steps.generate-token.outputs.token }}
-
- - name: Create draft release
- uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
- with:
- tag_name: ${{ steps.version.outputs.result }}
- token: ${{ steps.generate-token.outputs.token }}
- body_path: ${{ steps.changelog.outputs.path }}
- draft: true
- files: |
- docker/docker-compose.yml
- docker/example.env
- docker/hwaccel.ml.yml
- docker/hwaccel.transcoding.yml
- docker/prometheus.yml
- *.apk
-
- - name: Rename Outline document
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- continue-on-error: true
- env:
- OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
- VERSION: ${{ steps.changelog.outputs.version }}
- with:
- github-token: ${{ steps.generate-token.outputs.token }}
- script: |
- const outlineKey = process.env.OUTLINE_API_KEY;
- const version = process.env.VERSION;
- const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
- const baseUrl = 'https://outline.immich.cloud';
-
- const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
- method: 'POST',
- headers: {
- 'Authorization': `Bearer ${outlineKey}`,
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({ parentDocumentId })
- });
-
- if (!listResponse.ok) {
- throw new Error(`Outline list failed: ${listResponse.statusText}`);
- }
-
- const listData = await listResponse.json();
- const allDocuments = listData.data || [];
- const document = allDocuments.find(doc => doc.title === 'next');
-
- if (document) {
- console.log(`Found document 'next', renaming to '${version}'...`);
-
- const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
- method: 'POST',
- headers: {
- 'Authorization': `Bearer ${outlineKey}`,
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- id: document.id,
- title: version
- })
- });
-
- if (!updateResponse.ok) {
- throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
- }
- } else {
- console.log('No document titled "next" found to rename');
- }
diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml
deleted file mode 100644
index bd2c292ad5..0000000000
--- a/.github/workflows/sdk.yml
+++ /dev/null
@@ -1,47 +0,0 @@
-name: Update Immich SDK
-
-on:
- release:
- types: [published]
-
-permissions: {}
-
-jobs:
- publish:
- name: Publish `@immich/sdk`
- runs-on: ubuntu-latest
- permissions:
- contents: read
- id-token: write
- packages: write
- defaults:
- run:
- working-directory: ./open-api/typescript-sdk
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
-
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
-
- # Setup .npmrc file to publish to npm
- - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './open-api/typescript-sdk/.nvmrc'
- registry-url: 'https://registry.npmjs.org'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
- - name: Install deps
- run: pnpm install --frozen-lockfile
- - name: Build
- run: pnpm build
- - name: Publish
- run: pnpm publish --provenance --no-git-checks
diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml
deleted file mode 100644
index c0d53388c6..0000000000
--- a/.github/workflows/static_analysis.yml
+++ /dev/null
@@ -1,118 +0,0 @@
-name: Static Code Analysis
-on:
- workflow_dispatch:
- pull_request:
- push:
- branches: [main]
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-permissions: {}
-
-jobs:
- pre-job:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- outputs:
- should_run: ${{ steps.check.outputs.should_run }}
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Check what should run
- id: check
- uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
- with:
- github-token: ${{ steps.token.outputs.token }}
- filters: |
- mobile:
- - 'mobile/**'
- force-filters: |
- - '.github/workflows/static_analysis.yml'
- force-events: 'workflow_dispatch,release'
-
- mobile-dart-analyze:
- name: Run Dart Code Analysis
- needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
- runs-on: ubuntu-latest
- permissions:
- contents: read
- defaults:
- run:
- working-directory: ./mobile
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
-
- - name: Setup Flutter SDK
- uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
- with:
- channel: 'stable'
- flutter-version-file: ./mobile/pubspec.yaml
-
- - name: Install dependencies
- run: dart pub get
-
- - name: Install DCM
- uses: CQLabs/setup-dcm@8697ae0790c0852e964a6ef1d768d62a6675481a # v2.0.1
- with:
- github-token: ${{ steps.token.outputs.token }}
- version: auto
- working-directory: ./mobile
-
- - name: Generate translation file
- run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart
-
- - name: Run Build Runner
- run: make build
-
- - name: Generate platform API
- run: make pigeon
-
- - name: Find file changes
- uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
- id: verify-changed-files
- with:
- files: |
- mobile/**/*.g.dart
- mobile/**/*.gr.dart
- mobile/**/*.drift.dart
-
- - name: Verify files have not changed
- if: steps.verify-changed-files.outputs.files_changed == 'true'
- env:
- CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }}
- run: |
- echo "ERROR: Generated files not up to date! Run 'make build' and 'make pigeon' inside the mobile directory"
- echo "Changed files: ${CHANGED_FILES}"
- exit 1
-
- - name: Run dart analyze
- run: dart analyze --fatal-infos
-
- - name: Run dart format
- run: make format
-
- # TODO: Re-enable after upgrading custom_lint
- # - name: Run dart custom_lint
- # run: dart run custom_lint
-
- # TODO: Use https://github.com/CQLabs/dcm-action
- - name: Run DCM
- run: dcm analyze lib --fatal-style --fatal-warnings
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
deleted file mode 100644
index 28a74ff33f..0000000000
--- a/.github/workflows/test.yml
+++ /dev/null
@@ -1,865 +0,0 @@
-name: Test
-on:
- workflow_dispatch:
- pull_request:
- push:
- branches: [main]
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-permissions: {}
-jobs:
- pre-job:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- outputs:
- should_run: ${{ steps.check.outputs.should_run }}
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Check what should run
- id: check
- uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
- with:
- github-token: ${{ steps.token.outputs.token }}
- filters: |
- i18n:
- - 'i18n/**'
- web:
- - 'web/**'
- - 'i18n/**'
- - 'open-api/typescript-sdk/**'
- server:
- - 'server/**'
- cli:
- - 'cli/**'
- - 'open-api/typescript-sdk/**'
- e2e:
- - 'e2e/**'
- mobile:
- - 'mobile/**'
- machine-learning:
- - 'machine-learning/**'
- .github:
- - '.github/**'
- force-filters: |
- - '.github/workflows/test.yml'
- force-events: 'workflow_dispatch'
-
- server-unit-tests:
- name: Test & Lint Server
- needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
- runs-on: ubuntu-latest
- permissions:
- contents: read
- defaults:
- run:
- working-directory: ./server
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
-
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './server/.nvmrc'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
- - name: Run package manager install
- run: pnpm install
- - name: Run linter
- run: pnpm lint
- if: ${{ !cancelled() }}
- - name: Run formatter
- run: pnpm format
- if: ${{ !cancelled() }}
- - name: Run tsc
- run: pnpm check
- if: ${{ !cancelled() }}
- - name: Run small tests & coverage
- run: pnpm test
- if: ${{ !cancelled() }}
- cli-unit-tests:
- name: Unit Test CLI
- needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).cli == true }}
- runs-on: ubuntu-latest
- permissions:
- contents: read
- defaults:
- run:
- working-directory: ./cli
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './cli/.nvmrc'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
- - name: Setup typescript-sdk
- run: pnpm install && pnpm run build
- working-directory: ./open-api/typescript-sdk
- - name: Install deps
- run: pnpm install
- - name: Run linter
- run: pnpm lint
- if: ${{ !cancelled() }}
- - name: Run formatter
- run: pnpm format
- if: ${{ !cancelled() }}
- - name: Run tsc
- run: pnpm check
- if: ${{ !cancelled() }}
- - name: Run unit tests & coverage
- run: pnpm test
- if: ${{ !cancelled() }}
- cli-unit-tests-win:
- name: Unit Test CLI (Windows)
- needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).cli == true }}
- runs-on: windows-latest
- permissions:
- contents: read
- defaults:
- run:
- working-directory: ./cli
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './cli/.nvmrc'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
- - name: Setup typescript-sdk
- run: pnpm install --frozen-lockfile && pnpm build
- working-directory: ./open-api/typescript-sdk
- - name: Install deps
- run: pnpm install --frozen-lockfile
- # Skip linter & formatter in Windows test.
- - name: Run tsc
- run: pnpm check
- if: ${{ !cancelled() }}
- - name: Run unit tests & coverage
- run: pnpm test
- if: ${{ !cancelled() }}
- web-lint:
- name: Lint Web
- needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).web == true }}
- runs-on: mich
- permissions:
- contents: read
- defaults:
- run:
- working-directory: ./web
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './web/.nvmrc'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
- - name: Run setup typescript-sdk
- run: pnpm install --frozen-lockfile && pnpm build
- working-directory: ./open-api/typescript-sdk
- - name: Run pnpm install
- run: pnpm rebuild && pnpm install --frozen-lockfile
- - name: Run linter
- run: pnpm lint
- if: ${{ !cancelled() }}
- - name: Run formatter
- run: pnpm format
- if: ${{ !cancelled() }}
- - name: Run svelte checks
- run: pnpm check:svelte
- if: ${{ !cancelled() }}
- web-unit-tests:
- name: Test Web
- needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).web == true }}
- runs-on: ubuntu-latest
- permissions:
- contents: read
- defaults:
- run:
- working-directory: ./web
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './web/.nvmrc'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
- - name: Run setup typescript-sdk
- run: pnpm install --frozen-lockfile && pnpm build
- working-directory: ./open-api/typescript-sdk
- - name: Run npm install
- run: pnpm install --frozen-lockfile
- - name: Run tsc
- run: pnpm check:typescript
- if: ${{ !cancelled() }}
- - name: Run unit tests & coverage
- run: pnpm test
- if: ${{ !cancelled() }}
- i18n-tests:
- name: Test i18n
- needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
- runs-on: ubuntu-latest
- permissions:
- contents: read
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './web/.nvmrc'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
- - name: Install dependencies
- run: pnpm --filter=immich-i18n install --frozen-lockfile
- - name: Format
- run: pnpm --filter=immich-i18n format:fix
- - name: Find file changes
- uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
- id: verify-changed-files
- with:
- files: |
- i18n/**
- - name: Verify files have not changed
- if: steps.verify-changed-files.outputs.files_changed == 'true'
- env:
- CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }}
- run: |
- echo "ERROR: i18n files not up to date!"
- echo "Changed files: ${CHANGED_FILES}"
- exit 1
- e2e-tests-lint:
- name: End-to-End Lint
- needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true }}
- runs-on: ubuntu-latest
- permissions:
- contents: read
- defaults:
- run:
- working-directory: ./e2e
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './e2e/.nvmrc'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
- - name: Run setup typescript-sdk
- run: pnpm install --frozen-lockfile && pnpm build
- working-directory: ./open-api/typescript-sdk
- if: ${{ !cancelled() }}
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
- if: ${{ !cancelled() }}
- - name: Run linter
- run: pnpm lint
- if: ${{ !cancelled() }}
- - name: Run formatter
- run: pnpm format
- if: ${{ !cancelled() }}
- - name: Run tsc
- run: pnpm check
- if: ${{ !cancelled() }}
- server-medium-tests:
- name: Medium Tests (Server)
- needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
- runs-on: ubuntu-latest
- permissions:
- contents: read
- defaults:
- run:
- working-directory: ./server
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- submodules: 'recursive'
- token: ${{ steps.token.outputs.token }}
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './server/.nvmrc'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
- - name: Run pnpm install
- run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- - name: Run medium tests
- run: pnpm test:medium
- if: ${{ !cancelled() }}
- e2e-tests-server-cli:
- name: End-to-End Tests (Server & CLI)
- needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).server == true || fromJSON(needs.pre-job.outputs.should_run).cli == true }}
- runs-on: ${{ matrix.runner }}
- permissions:
- contents: read
- defaults:
- run:
- working-directory: ./e2e
- strategy:
- matrix:
- runner: [ubuntu-latest, ubuntu-24.04-arm]
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- submodules: 'recursive'
- token: ${{ steps.token.outputs.token }}
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './e2e/.nvmrc'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
- - name: Run setup typescript-sdk
- run: pnpm install --frozen-lockfile && pnpm build
- working-directory: ./open-api/typescript-sdk
- if: ${{ !cancelled() }}
- - name: Run setup web
- run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
- working-directory: ./web
- if: ${{ !cancelled() }}
- - name: Run setup cli
- run: pnpm install --frozen-lockfile && pnpm build
- working-directory: ./cli
- if: ${{ !cancelled() }}
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
- if: ${{ !cancelled() }}
- - name: Docker build
- run: docker compose build
- if: ${{ !cancelled() }}
- - name: Run e2e tests (api & cli)
- run: pnpm test
- if: ${{ !cancelled() }}
- e2e-tests-web:
- name: End-to-End Tests (Web)
- needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).web == true }}
- runs-on: ${{ matrix.runner }}
- permissions:
- contents: read
- defaults:
- run:
- working-directory: ./e2e
- strategy:
- matrix:
- runner: [ubuntu-latest, ubuntu-24.04-arm]
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- submodules: 'recursive'
- token: ${{ steps.token.outputs.token }}
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './e2e/.nvmrc'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
- - name: Run setup typescript-sdk
- run: pnpm install --frozen-lockfile && pnpm build
- working-directory: ./open-api/typescript-sdk
- if: ${{ !cancelled() }}
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
- if: ${{ !cancelled() }}
- - name: Install Playwright Browsers
- run: npx playwright install chromium --only-shell
- if: ${{ !cancelled() }}
- - name: Docker build
- run: docker compose build
- if: ${{ !cancelled() }}
- - name: Run e2e tests (web)
- env:
- CI: true
- run: npx playwright test --project=chromium
- if: ${{ !cancelled() }}
- - name: Archive web results
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
- if: success() || failure()
- with:
- name: e2e-web-test-results-${{ matrix.runner }}
- path: e2e/playwright-report/
- - name: Run ui tests (web)
- env:
- CI: true
- run: npx playwright test --project=ui
- if: ${{ !cancelled() }}
- - name: Archive ui results
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
- if: success() || failure()
- with:
- name: e2e-ui-test-results-${{ matrix.runner }}
- path: e2e/playwright-report/
- success-check-e2e:
- name: End-to-End Tests Success
- needs: [e2e-tests-server-cli, e2e-tests-web]
- permissions: {}
- runs-on: ubuntu-latest
- if: always()
- steps:
- - uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- with:
- needs: ${{ toJSON(needs) }}
- mobile-unit-tests:
- name: Unit Test Mobile
- needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
- runs-on: ubuntu-latest
- permissions:
- contents: read
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
- - name: Setup Flutter SDK
- uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
- with:
- channel: 'stable'
- flutter-version-file: ./mobile/pubspec.yaml
- - name: Generate translation file
- run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart
- working-directory: ./mobile
- - name: Run tests
- working-directory: ./mobile
- run: flutter test -j 1
- ml-unit-tests:
- name: Unit Test ML
- needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == true }}
- runs-on: ubuntu-latest
- permissions:
- contents: read
- defaults:
- run:
- working-directory: ./machine-learning
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
- - name: Install uv
- uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
- with:
- python-version: 3.11
- - name: Install dependencies
- run: |
- uv sync --extra cpu
- - name: Lint with ruff
- run: |
- uv run ruff check --output-format=github immich_ml
- - name: Format with ruff
- run: |
- uv run ruff format --check immich_ml
- - name: Run mypy type checking
- run: |
- uv run mypy --strict immich_ml/
- - name: Run tests and coverage
- run: |
- uv run pytest --cov=immich_ml --cov-report term-missing
- github-files-formatting:
- name: .github Files Formatting
- needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run)['.github'] == true }}
- runs-on: ubuntu-latest
- permissions:
- contents: read
- defaults:
- run:
- working-directory: ./.github
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './.github/.nvmrc'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
- - name: Run pnpm install
- run: pnpm install --frozen-lockfile
- - name: Run formatter
- run: pnpm format
- if: ${{ !cancelled() }}
- shellcheck:
- name: ShellCheck
- runs-on: ubuntu-latest
- permissions:
- contents: read
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
- - name: Run ShellCheck
- uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
- with:
- ignore_paths: >-
- **/open-api/** **/openapi** **/node_modules/**
- generated-api-up-to-date:
- name: OpenAPI Clients
- runs-on: ubuntu-latest
- permissions:
- contents: read
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './server/.nvmrc'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
- - name: Install server dependencies
- run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
- - name: Build the app
- run: pnpm --filter immich build
- - name: Run API generation
- run: ./bin/generate-open-api.sh
- working-directory: open-api
- - name: Find file changes
- uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
- id: verify-changed-files
- with:
- files: |
- mobile/openapi
- open-api/typescript-sdk
- open-api/immich-openapi-specs.json
- - name: Verify files have not changed
- if: steps.verify-changed-files.outputs.files_changed == 'true'
- env:
- CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }}
- run: |
- echo "ERROR: Generated files not up to date!"
- echo "Changed files: ${CHANGED_FILES}"
- exit 1
- sql-schema-up-to-date:
- name: SQL Schema Checks
- runs-on: ubuntu-latest
- permissions:
- contents: read
- services:
- postgres:
- image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3@sha256:dbf18b3ffea4a81434c65b71e20d27203baf903a0275f4341e4c16dfd901fd67
- env:
- POSTGRES_PASSWORD: postgres
- POSTGRES_USER: postgres
- POSTGRES_DB: immich
- options: >-
- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
- ports:
- - 5432:5432
- defaults:
- run:
- working-directory: ./server
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Checkout code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- with:
- persist-credentials: false
- token: ${{ steps.token.outputs.token }}
- - name: Setup pnpm
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- - name: Setup Node
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- with:
- node-version-file: './server/.nvmrc'
- cache: 'pnpm'
- cache-dependency-path: '**/pnpm-lock.yaml'
- - name: Install server dependencies
- run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- - name: Build the app
- run: pnpm build
- - name: Run existing migrations
- run: pnpm migrations:run
- - name: Test npm run schema:reset command works
- run: pnpm schema:reset
- - name: Generate new migrations
- continue-on-error: true
- run: pnpm migrations:generate src/TestMigration
- - name: Find file changes
- uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
- id: verify-changed-files
- with:
- files: |
- server/src
- - name: Verify migration files have not changed
- if: steps.verify-changed-files.outputs.files_changed == 'true'
- env:
- CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }}
- run: |
- echo "ERROR: Generated migration files not up to date!"
- echo "Changed files: ${CHANGED_FILES}"
- cat ./src/*-TestMigration.ts
- exit 1
- - name: Run SQL generation
- run: pnpm sync:sql
- env:
- DB_URL: postgres://postgres:postgres@localhost:5432/immich
- - name: Find file changes
- uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
- id: verify-changed-sql-files
- with:
- files: |
- server/src/queries
- - name: Verify SQL files have not changed
- if: steps.verify-changed-sql-files.outputs.files_changed == 'true'
- env:
- CHANGED_FILES: ${{ steps.verify-changed-sql-files.outputs.changed_files }}
- run: |
- echo "ERROR: Generated SQL files not up to date!"
- echo "Changed files: ${CHANGED_FILES}"
- git diff
- exit 1
-
-# mobile-integration-tests:
-# name: Run mobile end-to-end integration tests
-# runs-on: macos-latest
-# steps:
-# - uses: actions/checkout@v4
-# - uses: actions/setup-java@v3
-# with:
-# distribution: 'zulu'
-# java-version: '12.x'
-# cache: 'gradle'
-# - name: Cache android SDK
-# uses: actions/cache@v3
-# id: android-sdk
-# with:
-# key: android-sdk
-# path: |
-# /usr/local/lib/android/
-# ~/.android
-# - name: Cache Gradle
-# uses: actions/cache@v3
-# with:
-# path: |
-# ./mobile/build/
-# ./mobile/android/.gradle/
-# key: ${{ runner.os }}-flutter-${{ hashFiles('**/*.gradle*', 'pubspec.lock') }}
-# - name: Setup Android SDK
-# if: steps.android-sdk.outputs.cache-hit != 'true'
-# uses: android-actions/setup-android@v2
-# - name: AVD cache
-# uses: actions/cache@v3
-# id: avd-cache
-# with:
-# path: |
-# ~/.android/avd/*
-# ~/.android/adb*
-# key: avd-29
-# - name: create AVD and generate snapshot for caching
-# if: steps.avd-cache.outputs.cache-hit != 'true'
-# uses: reactivecircus/android-emulator-runner@v2.27.0
-# with:
-# working-directory: ./mobile
-# cores: 2
-# api-level: 29
-# arch: x86_64
-# profile: pixel
-# target: default
-# force-avd-creation: false
-# emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
-# disable-animations: false
-# script: echo "Generated AVD snapshot for caching."
-# - name: Setup Flutter SDK
-# uses: subosito/flutter-action@v2
-# with:
-# channel: 'stable'
-# flutter-version: '3.7.3'
-# cache: true
-# - name: Run integration tests
-# uses: Wandalen/wretry.action@master
-# with:
-# action: reactivecircus/android-emulator-runner@v2.27.0
-# with: |
-# working-directory: ./mobile
-# cores: 2
-# api-level: 29
-# arch: x86_64
-# profile: pixel
-# target: default
-# force-avd-creation: false
-# emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
-# disable-animations: true
-# script: |
-# flutter pub get
-# flutter test integration_test
-# attempt_limit: 3
diff --git a/.github/workflows/weblate-lock.yml b/.github/workflows/weblate-lock.yml
deleted file mode 100644
index cb11a11be4..0000000000
--- a/.github/workflows/weblate-lock.yml
+++ /dev/null
@@ -1,73 +0,0 @@
-name: Weblate checks
-
-on:
- pull_request:
- branches: [main]
- types:
- - opened
- - synchronize
- - ready_for_review
- - auto_merge_enabled
- - auto_merge_disabled
-
-permissions: {}
-
-env:
- BOT_NAME: immich-push-o-matic
-
-jobs:
- pre-job:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- outputs:
- should_run: ${{ steps.check.outputs.should_run }}
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Check what should run
- id: check
- uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
- with:
- github-token: ${{ steps.token.outputs.token }}
- filters: |
- i18n:
- - modified: 'i18n/!(en|package)**\.json'
- skip-force-logic: 'true'
-
- enforce-lock:
- name: Check Weblate Lock
- needs: [pre-job]
- runs-on: ubuntu-latest
- permissions: {}
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
- steps:
- - id: token
- uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
- with:
- app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
- private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-
- - name: Bot review status
- env:
- PR_NUMBER: ${{ github.event.pull_request.number || github.event.pull_request_review.pull_request.number }}
- GH_TOKEN: ${{ steps.token.outputs.token }}
- run: |
- # Then check for APPROVED by the bot, if absent fail
- gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json reviews | jq -e '.reviews | map(select(.author.login == env.BOT_NAME and .state == "APPROVED")) | length > 0' \
- || (echo "The push-o-matic bot has not approved this PR yet" && exit 1)
-
- success-check-lock:
- name: Weblate Lock Check Success
- needs: [enforce-lock]
- runs-on: ubuntu-latest
- permissions: {}
- if: always()
- steps:
- - uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- with:
- needs: ${{ toJSON(needs) }}
diff --git a/.gitignore b/.gitignore
index 3220701cc6..d3c48d0bd2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,5 @@ vite.config.js.timestamp-*
.pnpm-store
.devcontainer/library
.devcontainer/.env*
+
+docker/dev-data
\ No newline at end of file
diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index d417dc5ba8..0000000000
--- a/.gitmodules
+++ /dev/null
@@ -1,6 +0,0 @@
-[submodule "mobile/.isar"]
- path = mobile/.isar
- url = https://github.com/isar/isar
-[submodule "e2e/test-assets"]
- path = e2e/test-assets
- url = https://github.com/immich-app/test-assets
diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs
deleted file mode 100644
index 0e76dabe66..0000000000
--- a/.pnpmfile.cjs
+++ /dev/null
@@ -1,18 +0,0 @@
-module.exports = {
- hooks: {
- readPackage: (pkg) => {
- if (!pkg.name) {
- return pkg;
- }
- if (pkg.name === "exiftool-vendored") {
- if (pkg.optionalDependencies["exiftool-vendored.pl"]) {
- // make exiftool-vendored.pl a regular dependency
- pkg.dependencies["exiftool-vendored.pl"] =
- pkg.optionalDependencies["exiftool-vendored.pl"];
- delete pkg.optionalDependencies["exiftool-vendored.pl"];
- }
- }
- return pkg;
- },
- },
-};
diff --git a/Makefile b/Makefile
index 0fc0bb0b4c..b53da6a56b 100644
--- a/Makefile
+++ b/Makefile
@@ -10,10 +10,10 @@ dev:
docker compose -f $(DOCKER_COMPOSE_FILE) up -d
@echo "Waiting for services to be healthy..."
@sleep 3
- @echo "Starting web dev server (background)..."
- pnpm --filter web dev &
+# @echo "Starting web dev server (background)..."
+# pnpm --filter web dev &
@echo "Starting NestJS server with hot reload..."
- pnpm --filter immich start:dev
+ DB_HOSTNAME=localhost DB_PORT=5435 DB_DATABASE_NAME=app REDIS_HOSTNAME=localhost pnpm --filter immich start:dev
dev-down:
docker compose -f $(DOCKER_COMPOSE_FILE) down --remove-orphans
@@ -23,13 +23,13 @@ dev-down:
# OpenAPI SDK generation
open-api:
- ./open-api/bin/generate-open-api.sh
+ cd open-api && ./bin/generate-open-api.sh
open-api-dart:
- cd open-api && npx --yes @openapitools/openapi-generator-cli generate -g dart -i ../server/immich-openapi-specs.json -o ../mobile/openapi
+ cd open-api && npx --yes @openapitools/openapi-generator-cli generate -g dart -i ../server/server-openapi-specs.json -o ../mobile/openapi
open-api-typescript:
- cd open-api && npx --yes oazapfts ../server/immich-openapi-specs.json --optimistic > typescript-sdk/src/fetch-client.ts
+ cd open-api && npx --yes oazapfts ../server/server-openapi-specs.json --optimistic > typescript-sdk/src/fetch-client.ts
# Build targets
build-server:
diff --git a/README.md b/README.md
index 7e06d9de4b..8d496449a7 100644
--- a/README.md
+++ b/README.md
@@ -1,133 +1,55 @@
-
-
-
-
-
-
-
-
-
+# Core Monorepo
-
-
-
-High performance self-hosted photo and video management solution
-
-
-
-
-
+A full-stack monorepo template with **NestJS** (server), **SvelteKit** (web), and **Flutter** (mobile), powered by **pnpm workspaces**.
-
- Catalร
- Espaรฑol
- Franรงais
- Italiano
- ๆฅๆฌ่ช
- ํ๊ตญ์ด
- Deutsch
- Nederlands
- Tรผrkรงe
- ็ฎไฝไธญๆ
- ๆญฃ้ซไธญๆ
- ะฃะบัะฐัะฝััะบะฐ
- ะ ัััะบะธะน
- Portuguรชs Brasileiro
- Svenska
- ุงูุนุฑุจูุฉ
- Tiแบฟng Viแปt
- เธ เธฒเธฉเธฒเนเธเธข
-
+## Architecture
+```
+server/ NestJS REST API with PostgreSQL + Redis
+web/ SvelteKit frontend (static adapter)
+mobile/ Flutter mobile app
+e2e/ End-to-end tests (Vitest + Playwright)
+open-api/ OpenAPI spec generation & TypeScript SDK
+i18n/ Internationalization strings
+docker/ Docker Compose for local development
+```
-> [!WARNING]
-> โ ๏ธ Always follow [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan for your precious photos and videos!
->
-
+## Quick Start
-> [!NOTE]
-> You can find the main documentation, including installation guides, at https://immich.app/.
+```bash
+# Install dependencies
+pnpm install
-## Links
+# Start Postgres + Redis
+make dev
-- [Documentation](https://docs.immich.app/)
-- [About](https://docs.immich.app/overview/introduction)
-- [Installation](https://docs.immich.app/install/requirements)
-- [Roadmap](https://immich.app/roadmap)
-- [Demo](#demo)
-- [Features](#features)
-- [Translations](https://docs.immich.app/developer/translations)
-- [Contributing](https://docs.immich.app/overview/support-the-project)
+# Or individually:
+make dev-down # Stop services
+make build-server # Build NestJS server
+make build-web # Build SvelteKit app
+make open-api # Regenerate OpenAPI spec & SDK
+```
-## Demo
+## Development
-Access the demo [here](https://demo.immich.app). For the mobile app, you can use `https://demo.immich.app` for the `Server Endpoint URL`.
+| Command | Description |
+|---------|-------------|
+| `make dev` | Start Docker services + web + server |
+| `make dev-down` | Stop all services |
+| `make build-server` | Build the NestJS server |
+| `make build-web` | Build the SvelteKit web app |
+| `make open-api` | Regenerate OpenAPI spec & SDKs |
+| `make test-server` | Run server unit tests |
+| `make test-e2e` | Run end-to-end tests |
+| `make lint-server` | Lint the server |
+| `make lint-web` | Lint the web app |
+| `make clean` | Remove containers, volumes, node_modules |
-### Login credentials
+## Stack
-| Email | Password |
-| --------------- | -------- |
-| demo@immich.app | demo |
-
-## Features
-
-| Features | Mobile | Web |
-| :------------------------------------------- | ------ | --- |
-| Upload and view videos and photos | Yes | Yes |
-| Auto backup when the app is opened | Yes | N/A |
-| Prevent duplication of assets | Yes | Yes |
-| Selective album(s) for backup | Yes | N/A |
-| Download photos and videos to local device | Yes | Yes |
-| Multi-user support | Yes | Yes |
-| Album and Shared albums | Yes | Yes |
-| Scrubbable/draggable scrollbar | Yes | Yes |
-| Support raw formats | Yes | Yes |
-| Metadata view (EXIF, map) | Yes | Yes |
-| Search by metadata, objects, faces, and CLIP | Yes | Yes |
-| Administrative functions (user management) | No | Yes |
-| Background backup | Yes | N/A |
-| Virtual scroll | Yes | Yes |
-| OAuth support | Yes | Yes |
-| API Keys | N/A | Yes |
-| LivePhoto/MotionPhoto backup and playback | Yes | Yes |
-| Support 360 degree image display | No | Yes |
-| User-defined storage structure | Yes | Yes |
-| Public Sharing | Yes | Yes |
-| Archive and Favorites | Yes | Yes |
-| Global Map | Yes | Yes |
-| Partner Sharing | Yes | Yes |
-| Facial recognition and clustering | Yes | Yes |
-| Memories (x years ago) | Yes | Yes |
-| Offline support | Yes | No |
-| Read-only gallery | Yes | Yes |
-| Stacked Photos | Yes | Yes |
-| Tags | No | Yes |
-| Folder View | Yes | Yes |
-
-## Translations
-
-Read more about translations [here](https://docs.immich.app/developer/translations).
-
-
-
-
-
-## Repository activity
-
-
-
-## Star history
-
-
-
-
-
-
-
-
-
-## Contributors
-
-
-
-
+- **Server**: NestJS, Kysely (query builder), PostgreSQL 18, Redis
+- **Web**: SvelteKit 2, Svelte 5, TailwindCSS, @immich/ui
+- **Mobile**: Flutter / Dart
+- **API**: Auto-generated OpenAPI spec with TypeScript SDK via oazapfts
+- **Auth**: JWT (access + refresh tokens), API keys, session management
+- **i18n**: svelte-i18n with English base locale
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index fbba33c022..0ddf9c2fa9 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -10,9 +10,9 @@ services:
POSTGRES_DB: ${DB_DATABASE_NAME:-app}
POSTGRES_INITDB_ARGS: '--data-checksums'
volumes:
- - pgdata:/var/lib/postgresql/data
+ - ./dev-data/postgres:/var/lib/postgresql/data
ports:
- - '5432:5432'
+ - '5435:5432'
shm_size: 128mb
restart: always
healthcheck:
@@ -32,6 +32,3 @@ services:
timeout: 5s
retries: 5
restart: always
-
-volumes:
- pgdata:
diff --git a/e2e/package.json b/e2e/package.json
index 7271a65ffa..8bddc8db65 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -1,14 +1,12 @@
{
- "name": "immich-e2e",
- "version": "2.5.5",
+ "name": "e2e",
+ "version": "0.1.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"test": "vitest --run",
"test:watch": "vitest",
- "test:web": "npx playwright test",
- "start:web": "npx playwright test --ui",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
@@ -17,38 +15,30 @@
},
"keywords": [],
"author": "",
- "license": "GNU Affero General Public License version 3",
+ "license": "MIT",
"devDependencies": {
"@eslint/js": "^9.8.0",
"@faker-js/faker": "^10.1.0",
- "@immich/cli": "file:../cli",
- "@immich/e2e-auth-server": "file:../e2e-auth-server",
- "@immich/sdk": "file:../open-api/typescript-sdk",
- "@playwright/test": "^1.44.1",
+ "@server/sdk": "file:../open-api/typescript-sdk",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.10.9",
"@types/pg": "^8.15.1",
- "@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",
"dotenv": "^17.2.3",
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^62.0.0",
- "exiftool-vendored": "^34.3.0",
"globals": "^16.0.0",
"luxon": "^3.4.4",
"pg": "^8.11.3",
- "pngjs": "^7.0.0",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
- "sharp": "^0.34.5",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
- "utimes": "^5.2.1",
"vitest": "^3.0.0"
},
"volta": {
diff --git a/e2e/src/api/specs/activity.e2e-spec.ts b/e2e/src/api/specs/activity.e2e-spec.ts
deleted file mode 100644
index 9ce9b4b916..0000000000
--- a/e2e/src/api/specs/activity.e2e-spec.ts
+++ /dev/null
@@ -1,378 +0,0 @@
-import {
- ActivityCreateDto,
- AlbumResponseDto,
- AlbumUserRole,
- AssetMediaResponseDto,
- LoginResponseDto,
- ReactionType,
- createActivity as create,
- createAlbum,
- removeAssetFromAlbum,
-} from '@immich/sdk';
-import { createUserDto, uuidDto } from 'src/fixtures';
-import { errorDto } from 'src/responses';
-import { app, asBearerAuth, utils } from 'src/utils';
-import request from 'supertest';
-import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
-
-describe('/activities', () => {
- let admin: LoginResponseDto;
- let nonOwner: LoginResponseDto;
- let asset: AssetMediaResponseDto;
- let album: AlbumResponseDto;
-
- const createActivity = (dto: ActivityCreateDto, accessToken?: string) =>
- create({ activityCreateDto: dto }, { headers: asBearerAuth(accessToken || admin.accessToken) });
-
- beforeAll(async () => {
- await utils.resetDatabase();
-
- admin = await utils.adminSetup();
- nonOwner = await utils.userSetup(admin.accessToken, createUserDto.user1);
- asset = await utils.createAsset(admin.accessToken);
- album = await createAlbum(
- {
- createAlbumDto: {
- albumName: 'Album 1',
- assetIds: [asset.id],
- albumUsers: [{ userId: nonOwner.userId, role: AlbumUserRole.Editor }],
- },
- },
- { headers: asBearerAuth(admin.accessToken) },
- );
- });
-
- beforeEach(async () => {
- await utils.resetDatabase(['activity']);
- });
-
- describe('GET /activities', () => {
- it('should start off empty', async () => {
- const { status, body } = await request(app)
- .get('/activities')
- .query({ albumId: album.id })
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(body).toEqual([]);
- expect(status).toEqual(200);
- });
-
- it('should filter by album id', async () => {
- const album2 = await createAlbum(
- {
- createAlbumDto: {
- albumName: 'Album 2',
- assetIds: [asset.id],
- },
- },
- { headers: asBearerAuth(admin.accessToken) },
- );
-
- const [reaction] = await Promise.all([
- createActivity({ albumId: album.id, type: ReactionType.Like }),
- createActivity({ albumId: album2.id, type: ReactionType.Like }),
- ]);
-
- const { status, body } = await request(app)
- .get('/activities')
- .query({ albumId: album.id })
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toEqual(200);
- expect(body.length).toBe(1);
- expect(body[0]).toEqual(reaction);
- });
-
- it('should filter by type=comment', async () => {
- const [reaction] = await Promise.all([
- createActivity({
- albumId: album.id,
- type: ReactionType.Comment,
- comment: 'comment',
- }),
- createActivity({ albumId: album.id, type: ReactionType.Like }),
- ]);
-
- const { status, body } = await request(app)
- .get('/activities')
- .query({ albumId: album.id, type: 'comment' })
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toEqual(200);
- expect(body.length).toBe(1);
- expect(body[0]).toEqual(reaction);
- });
-
- it('should filter by type=like', async () => {
- const [reaction] = await Promise.all([
- createActivity({ albumId: album.id, type: ReactionType.Like }),
- createActivity({
- albumId: album.id,
- type: ReactionType.Comment,
- comment: 'comment',
- }),
- ]);
-
- const { status, body } = await request(app)
- .get('/activities')
- .query({ albumId: album.id, type: 'like' })
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toEqual(200);
- expect(body.length).toBe(1);
- expect(body[0]).toEqual(reaction);
- });
-
- it('should filter by userId', async () => {
- const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like });
-
- const response1 = await request(app)
- .get('/activities')
- .query({ albumId: album.id, userId: uuidDto.notFound })
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(response1.status).toEqual(200);
- expect(response1.body.length).toBe(0);
-
- const response2 = await request(app)
- .get('/activities')
- .query({ albumId: album.id, userId: admin.userId })
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(response2.status).toEqual(200);
- expect(response2.body.length).toBe(1);
- expect(response2.body[0]).toEqual(reaction);
- });
-
- it('should filter by assetId', async () => {
- const [reaction] = await Promise.all([
- createActivity({
- albumId: album.id,
- assetId: asset.id,
- type: ReactionType.Like,
- }),
- createActivity({ albumId: album.id, type: ReactionType.Like }),
- ]);
-
- const { status, body } = await request(app)
- .get('/activities')
- .query({ albumId: album.id, assetId: asset.id })
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toEqual(200);
- expect(body.length).toBe(1);
- expect(body[0]).toEqual(reaction);
- });
- });
-
- describe('POST /activities', () => {
- it('should add a comment to an album', async () => {
- const { status, body } = await request(app)
- .post('/activities')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({
- albumId: album.id,
- type: 'comment',
- comment: 'This is my first comment',
- });
- expect(status).toEqual(201);
- expect(body).toEqual({
- id: expect.any(String),
- assetId: null,
- createdAt: expect.any(String),
- type: 'comment',
- comment: 'This is my first comment',
- user: expect.objectContaining({ email: admin.userEmail }),
- });
- });
-
- it('should add a like to an album', async () => {
- const { status, body } = await request(app)
- .post('/activities')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ albumId: album.id, type: 'like' });
- expect(status).toEqual(201);
- expect(body).toEqual({
- id: expect.any(String),
- assetId: null,
- createdAt: expect.any(String),
- type: 'like',
- comment: null,
- user: expect.objectContaining({ email: admin.userEmail }),
- });
- });
-
- it('should return a 200 for a duplicate like on the album', async () => {
- const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like });
- const { status, body } = await request(app)
- .post('/activities')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ albumId: album.id, type: 'like' });
- expect(status).toEqual(200);
- expect(body).toEqual(reaction);
- });
-
- it('should not confuse an album like with an asset like', async () => {
- const reaction = await createActivity({
- albumId: album.id,
- assetId: asset.id,
- type: ReactionType.Like,
- });
- const { status, body } = await request(app)
- .post('/activities')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ albumId: album.id, type: 'like' });
- expect(status).toEqual(201);
- expect(body.id).not.toEqual(reaction.id);
- });
-
- it('should add a comment to an asset', async () => {
- const { status, body } = await request(app)
- .post('/activities')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({
- albumId: album.id,
- assetId: asset.id,
- type: 'comment',
- comment: 'This is my first comment',
- });
- expect(status).toEqual(201);
- expect(body).toEqual({
- id: expect.any(String),
- assetId: asset.id,
- createdAt: expect.any(String),
- type: 'comment',
- comment: 'This is my first comment',
- user: expect.objectContaining({ email: admin.userEmail }),
- });
- });
-
- it('should add a like to an asset', async () => {
- const { status, body } = await request(app)
- .post('/activities')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ albumId: album.id, assetId: asset.id, type: 'like' });
- expect(status).toEqual(201);
- expect(body).toEqual({
- id: expect.any(String),
- assetId: asset.id,
- createdAt: expect.any(String),
- type: 'like',
- comment: null,
- user: expect.objectContaining({ email: admin.userEmail }),
- });
- });
-
- it('should return a 200 for a duplicate like on an asset', async () => {
- const reaction = await createActivity({
- albumId: album.id,
- assetId: asset.id,
- type: ReactionType.Like,
- });
-
- const { status, body } = await request(app)
- .post('/activities')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ albumId: album.id, assetId: asset.id, type: 'like' });
- expect(status).toEqual(200);
- expect(body).toEqual(reaction);
- });
- });
-
- describe('DELETE /activities/:id', () => {
- it('should remove a comment from an album', async () => {
- const reaction = await createActivity({
- albumId: album.id,
- type: ReactionType.Comment,
- comment: 'This is a test comment',
- });
- const { status } = await request(app)
- .delete(`/activities/${reaction.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toEqual(204);
- });
-
- it('should remove a like from an album', async () => {
- const reaction = await createActivity({
- albumId: album.id,
- type: ReactionType.Like,
- });
- const { status } = await request(app)
- .delete(`/activities/${reaction.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toEqual(204);
- });
-
- it('should let the owner remove a comment by another user', async () => {
- const reaction = await createActivity({
- albumId: album.id,
- type: ReactionType.Comment,
- comment: 'This is a test comment',
- });
-
- const { status } = await request(app)
- .delete(`/activities/${reaction.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toEqual(204);
- });
-
- it('should not let a user remove a comment by another user', async () => {
- const reaction = await createActivity({
- albumId: album.id,
- type: ReactionType.Comment,
- comment: 'This is a test comment',
- });
-
- const { status, body } = await request(app)
- .delete(`/activities/${reaction.id}`)
- .set('Authorization', `Bearer ${nonOwner.accessToken}`);
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('Not found or no activity.delete access'));
- });
-
- it('should let a non-owner remove their own comment', async () => {
- const reaction = await createActivity(
- {
- albumId: album.id,
- type: ReactionType.Comment,
- comment: 'This is a test comment',
- },
- nonOwner.accessToken,
- );
-
- const { status } = await request(app)
- .delete(`/activities/${reaction.id}`)
- .set('Authorization', `Bearer ${nonOwner.accessToken}`);
-
- expect(status).toBe(204);
- });
-
- it('should return empty list when asset is removed', async () => {
- const album3 = await createAlbum(
- {
- createAlbumDto: {
- albumName: 'Album 3',
- assetIds: [asset.id],
- },
- },
- { headers: asBearerAuth(admin.accessToken) },
- );
-
- await createActivity({ albumId: album3.id, assetId: asset.id, type: ReactionType.Like });
-
- await removeAssetFromAlbum(
- {
- id: album3.id,
- bulkIdsDto: {
- ids: [asset.id],
- },
- },
- { headers: asBearerAuth(admin.accessToken) },
- );
-
- const { status, body } = await request(app)
- .get('/activities')
- .query({ albumId: album.id })
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toEqual(200);
- expect(body).toEqual([]);
- });
- });
-});
diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts
deleted file mode 100644
index c4f06edd93..0000000000
--- a/e2e/src/api/specs/album.e2e-spec.ts
+++ /dev/null
@@ -1,721 +0,0 @@
-import {
- addAssetsToAlbum,
- AlbumResponseDto,
- AlbumUserRole,
- AssetMediaResponseDto,
- AssetOrder,
- deleteUserAdmin,
- getAlbumInfo,
- LoginResponseDto,
- SharedLinkType,
-} from '@immich/sdk';
-import { createUserDto } from 'src/fixtures';
-import { errorDto } from 'src/responses';
-import { app, asBearerAuth, utils } from 'src/utils';
-import request from 'supertest';
-import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
-
-const user1SharedEditorUser = 'user1SharedEditorUser';
-const user1SharedViewerUser = 'user1SharedViewerUser';
-const user1SharedLink = 'user1SharedLink';
-const user1NotShared = 'user1NotShared';
-const user2SharedUser = 'user2SharedUser';
-const user2SharedLink = 'user2SharedLink';
-const user2NotShared = 'user2NotShared';
-const user4DeletedAsset = 'user4DeletedAsset';
-const user4Empty = 'user4Empty';
-
-describe('/albums', () => {
- let admin: LoginResponseDto;
- let user1: LoginResponseDto;
- let user1Asset1: AssetMediaResponseDto;
- let user1Asset2: AssetMediaResponseDto;
- let user4Asset1: AssetMediaResponseDto;
- let user1Albums: AlbumResponseDto[];
- let user2: LoginResponseDto;
- let user2Albums: AlbumResponseDto[];
- let deletedAssetAlbum: AlbumResponseDto;
- let user3: LoginResponseDto; // deleted
- let user4: LoginResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
-
- admin = await utils.adminSetup();
-
- [user1, user2, user3, user4] = await Promise.all([
- utils.userSetup(admin.accessToken, createUserDto.user1),
- utils.userSetup(admin.accessToken, createUserDto.user2),
- utils.userSetup(admin.accessToken, createUserDto.user3),
- utils.userSetup(admin.accessToken, createUserDto.user4),
- ]);
-
- [user1Asset1, user1Asset2, user4Asset1] = await Promise.all([
- utils.createAsset(user1.accessToken, { isFavorite: true }),
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- [user1Albums, user2Albums, deletedAssetAlbum] = await Promise.all([
- Promise.all([
- utils.createAlbum(user1.accessToken, {
- albumName: user1SharedEditorUser,
- albumUsers: [
- { userId: admin.userId, role: AlbumUserRole.Editor },
- { userId: user2.userId, role: AlbumUserRole.Editor },
- ],
- assetIds: [user1Asset1.id],
- }),
- utils.createAlbum(user1.accessToken, {
- albumName: user1SharedLink,
- assetIds: [user1Asset1.id],
- }),
- utils.createAlbum(user1.accessToken, {
- albumName: user1NotShared,
- assetIds: [user1Asset1.id, user1Asset2.id],
- }),
- utils.createAlbum(user1.accessToken, {
- albumName: user1SharedViewerUser,
- albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
- assetIds: [user1Asset1.id],
- }),
- ]),
- Promise.all([
- utils.createAlbum(user2.accessToken, {
- albumName: user2SharedUser,
- albumUsers: [
- { userId: user1.userId, role: AlbumUserRole.Editor },
- { userId: user3.userId, role: AlbumUserRole.Editor },
- ],
- }),
- utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }),
- utils.createAlbum(user2.accessToken, { albumName: user2NotShared }),
- ]),
- utils.createAlbum(user4.accessToken, { albumName: user4DeletedAsset }),
- utils.createAlbum(user4.accessToken, { albumName: user4Empty }),
- utils.createAlbum(user3.accessToken, {
- albumName: 'Deleted',
- albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }],
- }),
- ]);
-
- await Promise.all([
- addAssetsToAlbum(
- { id: user2Albums[0].id, bulkIdsDto: { ids: [user1Asset1.id, user1Asset2.id] } },
- { headers: asBearerAuth(user1.accessToken) },
- ),
- addAssetsToAlbum(
- { id: deletedAssetAlbum.id, bulkIdsDto: { ids: [user4Asset1.id] } },
- { headers: asBearerAuth(user4.accessToken) },
- ),
- // add shared link to user1SharedLink album
- utils.createSharedLink(user1.accessToken, {
- type: SharedLinkType.Album,
- albumId: user1Albums[1].id,
- }),
- // add shared link to user2SharedLink album
- utils.createSharedLink(user2.accessToken, {
- type: SharedLinkType.Album,
- albumId: user2Albums[1].id,
- }),
- ]);
-
- [user2Albums[0]] = await Promise.all([
- getAlbumInfo({ id: user2Albums[0].id }, { headers: asBearerAuth(user2.accessToken) }),
- deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }),
- utils.deleteAssets(user1.accessToken, [user4Asset1.id]),
- ]);
- });
-
- describe('GET /albums', () => {
- it("should not show other users' favorites", async () => {
- const { status, body } = await request(app)
- .get(`/albums/${user1Albums[0].id}?withoutAssets=false`)
- .set('Authorization', `Bearer ${user2.accessToken}`);
- expect(status).toEqual(200);
- expect(body).toEqual({
- ...user1Albums[0],
- assets: [expect.objectContaining({ isFavorite: false })],
- contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
- lastModifiedAssetTimestamp: expect.any(String),
- startDate: expect.any(String),
- endDate: expect.any(String),
- shared: true,
- albumUsers: expect.any(Array),
- });
- });
-
- it('should not return shared albums with a deleted owner', async () => {
- const { status, body } = await request(app)
- .get('/albums?shared=true')
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toHaveLength(4);
- expect(body).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- ownerId: user1.userId,
- albumName: user1SharedLink,
- shared: true,
- }),
- expect.objectContaining({
- ownerId: user1.userId,
- albumName: user1SharedEditorUser,
- shared: true,
- }),
- expect.objectContaining({
- ownerId: user1.userId,
- albumName: user1SharedViewerUser,
- shared: true,
- }),
- expect.objectContaining({
- ownerId: user2.userId,
- albumName: user2SharedUser,
- shared: true,
- }),
- ]),
- );
- });
-
- it('should return the album collection including owned and shared', async () => {
- const { status, body } = await request(app).get('/albums').set('Authorization', `Bearer ${user1.accessToken}`);
- expect(status).toBe(200);
- expect(body).toHaveLength(4);
- expect(body).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- ownerId: user1.userId,
- albumName: user1SharedEditorUser,
- shared: true,
- }),
- expect.objectContaining({
- ownerId: user1.userId,
- albumName: user1SharedViewerUser,
- shared: true,
- }),
- expect.objectContaining({
- ownerId: user1.userId,
- albumName: user1SharedLink,
- shared: true,
- }),
- expect.objectContaining({
- ownerId: user1.userId,
- albumName: user1NotShared,
- shared: false,
- }),
- ]),
- );
- });
-
- it('should return the album collection filtered by shared', async () => {
- const { status, body } = await request(app)
- .get('/albums?shared=true')
- .set('Authorization', `Bearer ${user1.accessToken}`);
- expect(status).toBe(200);
- expect(body).toHaveLength(4);
- expect(body).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- ownerId: user1.userId,
- albumName: user1SharedEditorUser,
- shared: true,
- }),
- expect.objectContaining({
- ownerId: user1.userId,
- albumName: user1SharedViewerUser,
- shared: true,
- }),
- expect.objectContaining({
- ownerId: user1.userId,
- albumName: user1SharedLink,
- shared: true,
- }),
- expect.objectContaining({
- ownerId: user2.userId,
- albumName: user2SharedUser,
- shared: true,
- }),
- ]),
- );
- });
-
- it('should return the album collection filtered by NOT shared', async () => {
- const { status, body } = await request(app)
- .get('/albums?shared=false')
- .set('Authorization', `Bearer ${user1.accessToken}`);
- expect(status).toBe(200);
- expect(body).toHaveLength(1);
- expect(body).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- ownerId: user1.userId,
- albumName: user1NotShared,
- shared: false,
- }),
- ]),
- );
- });
-
- it('should return the album collection filtered by assetId', async () => {
- const { status, body } = await request(app)
- .get(`/albums?assetId=${user1Asset2.id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
- expect(status).toBe(200);
- expect(body).toHaveLength(2);
- });
-
- it('should return the album collection filtered by assetId and ignores shared=true', async () => {
- const { status, body } = await request(app)
- .get(`/albums?shared=true&assetId=${user1Asset1.id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
- expect(status).toBe(200);
- expect(body).toHaveLength(5);
- });
-
- it('should return the album collection filtered by assetId and ignores shared=false', async () => {
- const { status, body } = await request(app)
- .get(`/albums?shared=false&assetId=${user1Asset1.id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
- expect(status).toBe(200);
- expect(body).toHaveLength(5);
- });
-
- it('should return empty albums and albums where all assets are deleted', async () => {
- const { status, body } = await request(app).get('/albums').set('Authorization', `Bearer ${user4.accessToken}`);
- expect(status).toBe(200);
- expect(body).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- ownerId: user4.userId,
- albumName: user4DeletedAsset,
- shared: false,
- }),
- expect.objectContaining({
- ownerId: user4.userId,
- albumName: user4Empty,
- shared: false,
- }),
- ]),
- );
- });
- });
-
- describe('GET /albums/:id', () => {
- it('should return album info for own album', async () => {
- const { status, body } = await request(app)
- .get(`/albums/${user1Albums[0].id}?withoutAssets=false`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual({
- ...user1Albums[0],
- assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
- contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
- lastModifiedAssetTimestamp: expect.any(String),
- startDate: expect.any(String),
- endDate: expect.any(String),
- albumUsers: expect.any(Array),
- shared: true,
- });
- });
-
- it('should return album info for shared album (editor)', async () => {
- const { status, body } = await request(app)
- .get(`/albums/${user2Albums[0].id}?withoutAssets=false`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toMatchObject({ id: user2Albums[0].id });
- });
-
- it('should return album info for shared album (viewer)', async () => {
- const { status, body } = await request(app)
- .get(`/albums/${user1Albums[3].id}?withoutAssets=false`)
- .set('Authorization', `Bearer ${user2.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toMatchObject({ id: user1Albums[3].id });
- });
-
- it('should return album info with assets when withoutAssets is undefined', async () => {
- const { status, body } = await request(app)
- .get(`/albums/${user1Albums[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual({
- ...user1Albums[0],
- assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
- contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
- lastModifiedAssetTimestamp: expect.any(String),
- startDate: expect.any(String),
- endDate: expect.any(String),
- albumUsers: expect.any(Array),
- shared: true,
- });
- });
-
- it('should return album info without assets when withoutAssets is true', async () => {
- const { status, body } = await request(app)
- .get(`/albums/${user1Albums[0].id}?withoutAssets=true`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual({
- ...user1Albums[0],
- assets: [],
- contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
- assetCount: 1,
- lastModifiedAssetTimestamp: expect.any(String),
- endDate: expect.any(String),
- startDate: expect.any(String),
- albumUsers: expect.any(Array),
- shared: true,
- });
- });
-
- it('should not count trashed assets', async () => {
- await utils.deleteAssets(user1.accessToken, [user1Asset2.id]);
-
- const { status, body } = await request(app)
- .get(`/albums/${user2Albums[0].id}?withoutAssets=true`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual({
- ...user2Albums[0],
- assets: [],
- contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
- assetCount: 1,
- lastModifiedAssetTimestamp: expect.any(String),
- endDate: expect.any(String),
- startDate: expect.any(String),
- albumUsers: expect.any(Array),
- shared: true,
- });
- });
- });
-
- describe('GET /albums/statistics', () => {
- it('should return total count of albums the user has access to', async () => {
- const { status, body } = await request(app)
- .get('/albums/statistics')
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual({ owned: 4, shared: 4, notShared: 1 });
- });
- });
-
- describe('POST /albums', () => {
- it('should create an album', async () => {
- const { status, body } = await request(app)
- .post('/albums')
- .send({ albumName: 'New album' })
- .set('Authorization', `Bearer ${user1.accessToken}`);
- expect(status).toBe(201);
- expect(body).toEqual({
- id: expect.any(String),
- createdAt: expect.any(String),
- updatedAt: expect.any(String),
- ownerId: user1.userId,
- albumName: 'New album',
- description: '',
- albumThumbnailAssetId: null,
- shared: false,
- albumUsers: [],
- hasSharedLink: false,
- assets: [],
- assetCount: 0,
- owner: expect.objectContaining({ email: user1.userEmail }),
- isActivityEnabled: true,
- order: AssetOrder.Desc,
- });
- });
-
- it('should not be able to share album with owner', async () => {
- const { status, body } = await request(app)
- .post('/albums')
- .send({ albumName: 'New album', albumUsers: [{ role: AlbumUserRole.Editor, userId: user1.userId }] })
- .set('Authorization', `Bearer ${user1.accessToken}`);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('Cannot share album with owner'));
- });
- });
-
- describe('PUT /albums/:id/assets', () => {
- it('should be able to add own asset to own album', async () => {
- const asset = await utils.createAsset(user1.accessToken);
- const { status, body } = await request(app)
- .put(`/albums/${user1Albums[0].id}/assets`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ ids: [asset.id] });
-
- expect(status).toBe(200);
- expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
- });
-
- it('should be able to add own asset to shared album', async () => {
- const asset = await utils.createAsset(user1.accessToken);
- const { status, body } = await request(app)
- .put(`/albums/${user2Albums[0].id}/assets`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ ids: [asset.id] });
-
- expect(status).toBe(200);
- expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
- });
-
- it('should not be able to add assets to album as a viewer', async () => {
- const asset = await utils.createAsset(user2.accessToken);
- const { status, body } = await request(app)
- .put(`/albums/${user1Albums[3].id}/assets`)
- .set('Authorization', `Bearer ${user2.accessToken}`)
- .send({ ids: [asset.id] });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('Not found or no albumAsset.create access'));
- });
-
- it('should add duplicate assets only once', async () => {
- const asset = await utils.createAsset(user1.accessToken);
- const { status, body } = await request(app)
- .put(`/albums/${user1Albums[0].id}/assets`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ ids: [asset.id, asset.id] });
-
- expect(status).toBe(200);
- expect(body).toEqual([
- expect.objectContaining({ id: asset.id, success: true }),
- expect.objectContaining({ id: asset.id, success: false, error: 'duplicate' }),
- ]);
- });
- });
-
- describe('PATCH /albums/:id', () => {
- it('should update an album', async () => {
- const album = await utils.createAlbum(user1.accessToken, {
- albumName: 'New album',
- });
- const { status, body } = await request(app)
- .patch(`/albums/${album.id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- albumName: 'New album name',
- description: 'An album description',
- });
- expect(status).toBe(200);
- expect(body).toEqual({
- ...album,
- updatedAt: expect.any(String),
- albumName: 'New album name',
- description: 'An album description',
- });
- });
-
- it('should not be able to update as a viewer', async () => {
- const { status, body } = await request(app)
- .patch(`/albums/${user1Albums[3].id}`)
- .set('Authorization', `Bearer ${user2.accessToken}`)
- .send({ albumName: 'New album name' });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
- });
-
- it('should not be able to update as an editor', async () => {
- const { status, body } = await request(app)
- .patch(`/albums/${user1Albums[0].id}`)
- .set('Authorization', `Bearer ${user2.accessToken}`)
- .send({ albumName: 'New album name' });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
- });
- });
-
- describe('DELETE /albums/:id/assets', () => {
- it('should require authorization', async () => {
- const { status, body } = await request(app)
- .delete(`/albums/${user1Albums[1].id}/assets`)
- .set('Authorization', `Bearer ${user2.accessToken}`)
- .send({ ids: [user1Asset1.id] });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.noPermission);
- });
-
- it('should be able to remove foreign asset from owned album', async () => {
- const { status, body } = await request(app)
- .delete(`/albums/${user2Albums[0].id}/assets`)
- .set('Authorization', `Bearer ${user2.accessToken}`)
- .send({ ids: [user1Asset1.id] });
-
- expect(status).toBe(200);
- expect(body).toEqual([
- expect.objectContaining({
- id: user1Asset1.id,
- success: true,
- }),
- ]);
- });
-
- it('should not be able to remove foreign asset from foreign album', async () => {
- const { status, body } = await request(app)
- .delete(`/albums/${user1Albums[0].id}/assets`)
- .set('Authorization', `Bearer ${user2.accessToken}`)
- .send({ ids: [user1Asset1.id] });
-
- expect(status).toBe(200);
- expect(body).toEqual([
- expect.objectContaining({
- id: user1Asset1.id,
- success: false,
- error: 'no_permission',
- }),
- ]);
- });
-
- it('should be able to remove own asset from own album', async () => {
- const { status, body } = await request(app)
- .delete(`/albums/${user1Albums[0].id}/assets`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ ids: [user1Asset1.id] });
-
- expect(status).toBe(200);
- expect(body).toEqual([expect.objectContaining({ id: user1Asset1.id, success: true })]);
- });
-
- it('should be able to remove own asset from shared album', async () => {
- const { status, body } = await request(app)
- .delete(`/albums/${user2Albums[0].id}/assets`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ ids: [user1Asset2.id] });
-
- expect(status).toBe(200);
- expect(body).toEqual([expect.objectContaining({ id: user1Asset2.id, success: true })]);
- });
-
- it('should not be able to remove assets from album as a viewer', async () => {
- const { status, body } = await request(app)
- .delete(`/albums/${user1Albums[3].id}/assets`)
- .set('Authorization', `Bearer ${user2.accessToken}`)
- .send({ ids: [user1Asset1.id] });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('Not found or no albumAsset.delete access'));
- });
-
- it('should remove duplicate assets only once', async () => {
- const { status, body } = await request(app)
- .delete(`/albums/${user1Albums[1].id}/assets`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ ids: [user1Asset1.id, user1Asset1.id] });
-
- expect(status).toBe(200);
- expect(body).toEqual([
- expect.objectContaining({ id: user1Asset1.id, success: true }),
- expect.objectContaining({ id: user1Asset1.id, success: false, error: 'not_found' }),
- ]);
- });
- });
-
- describe('PUT :id/users', () => {
- let album: AlbumResponseDto;
-
- beforeEach(async () => {
- album = await utils.createAlbum(user1.accessToken, {
- albumName: 'testAlbum',
- });
- });
-
- it('should be able to add user to own album', async () => {
- const { status, body } = await request(app)
- .put(`/albums/${album.id}/users`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }] });
-
- expect(status).toBe(200);
- expect(body).toEqual(
- expect.objectContaining({
- albumUsers: [
- expect.objectContaining({
- user: expect.objectContaining({ id: user2.userId }),
- }),
- ],
- }),
- );
- });
-
- it('should not be able to share album with owner', async () => {
- const { status, body } = await request(app)
- .put(`/albums/${album.id}/users`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }] });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('Cannot be shared with owner'));
- });
-
- it('should not be able to add existing user to shared album', async () => {
- await request(app)
- .put(`/albums/${album.id}/users`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }] });
-
- const { status, body } = await request(app)
- .put(`/albums/${album.id}/users`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }] });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('User already added'));
- });
- });
-
- describe('PUT :id/user/:userId', () => {
- it('should allow the album owner to change the role of a shared user', async () => {
- const album = await utils.createAlbum(user1.accessToken, {
- albumName: 'testAlbum',
- albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
- });
-
- expect(album.albumUsers[0].role).toEqual(AlbumUserRole.Viewer);
-
- const { status } = await request(app)
- .put(`/albums/${album.id}/user/${user2.userId}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ role: AlbumUserRole.Editor });
-
- expect(status).toBe(204);
-
- // Get album to verify the role change
- const { body } = await request(app)
- .get(`/albums/${album.id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
- expect(body).toEqual(
- expect.objectContaining({
- albumUsers: [expect.objectContaining({ role: AlbumUserRole.Editor })],
- }),
- );
- });
-
- it('should not allow a shared user to change the role of another shared user', async () => {
- const album = await utils.createAlbum(user1.accessToken, {
- albumName: 'testAlbum',
- albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
- });
-
- expect(album.albumUsers[0].role).toEqual(AlbumUserRole.Viewer);
-
- const { status, body } = await request(app)
- .put(`/albums/${album.id}/user/${user2.userId}`)
- .set('Authorization', `Bearer ${user2.accessToken}`)
- .send({ role: AlbumUserRole.Editor });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('Not found or no album.share access'));
- });
- });
-});
diff --git a/e2e/src/api/specs/api-key.e2e-spec.ts b/e2e/src/api/specs/api-key.e2e-spec.ts
index 28d134a664..5b13c8906d 100644
--- a/e2e/src/api/specs/api-key.e2e-spec.ts
+++ b/e2e/src/api/specs/api-key.e2e-spec.ts
@@ -1,4 +1,4 @@
-import { LoginResponseDto, Permission, createApiKey } from '@immich/sdk';
+import { LoginResponseDto, Permission, createApiKey } from '@server/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts
deleted file mode 100644
index d4eee16232..0000000000
--- a/e2e/src/api/specs/asset.e2e-spec.ts
+++ /dev/null
@@ -1,1242 +0,0 @@
-import {
- AssetMediaResponseDto,
- AssetMediaStatus,
- AssetResponseDto,
- AssetTypeEnum,
- AssetVisibility,
- getAssetInfo,
- getMyUser,
- LoginResponseDto,
- SharedLinkType,
- updateConfig,
-} from '@immich/sdk';
-import { exiftool } from 'exiftool-vendored';
-import { DateTime } from 'luxon';
-import { randomBytes } from 'node:crypto';
-import { readFile, writeFile } from 'node:fs/promises';
-import { basename, join } from 'node:path';
-import { Socket } from 'socket.io-client';
-import { createUserDto, uuidDto } from 'src/fixtures';
-import { makeRandomImage } from 'src/generators';
-import { errorDto } from 'src/responses';
-import { app, asBearerAuth, tempDir, TEN_TIMES, testAssetDir, utils } from 'src/utils';
-import request from 'supertest';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
-
-const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
-const ratingAssetFilepath = `${testAssetDir}/metadata/rating/mongolels.jpg`;
-const facesAssetDir = `${testAssetDir}/metadata/faces`;
-
-const readTags = async (bytes: Buffer, filename: string) => {
- const filepath = join(tempDir, filename);
- await writeFile(filepath, bytes);
- return exiftool.read(filepath);
-};
-
-const today = DateTime.fromObject({
- year: 2023,
- month: 11,
- day: 3,
-}) as DateTime;
-const yesterday = today.minus({ days: 1 });
-
-describe('/asset', () => {
- let admin: LoginResponseDto;
- let websocket: Socket;
-
- let user1: LoginResponseDto;
- let user2: LoginResponseDto;
- let timeBucketUser: LoginResponseDto;
- let quotaUser: LoginResponseDto;
- let statsUser: LoginResponseDto;
-
- let user1Assets: AssetMediaResponseDto[];
- let user2Assets: AssetMediaResponseDto[];
- let locationAsset: AssetMediaResponseDto;
- let ratingAsset: AssetMediaResponseDto;
-
- const setupTests = async () => {
- await utils.resetDatabase();
- admin = await utils.adminSetup({ onboarding: false });
-
- [websocket, user1, user2, statsUser, quotaUser, timeBucketUser] = await Promise.all([
- utils.connectWebsocket(admin.accessToken),
- utils.userSetup(admin.accessToken, createUserDto.create('1')),
- utils.userSetup(admin.accessToken, createUserDto.create('2')),
- utils.userSetup(admin.accessToken, createUserDto.create('stats')),
- utils.userSetup(admin.accessToken, createUserDto.userQuota),
- utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')),
- ]);
-
- await utils.createPartner(user1.accessToken, user2.userId);
-
- // asset location
- locationAsset = await utils.createAsset(admin.accessToken, {
- assetData: {
- filename: 'thompson-springs.jpg',
- bytes: await readFile(locationAssetFilepath),
- },
- });
-
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: locationAsset.id });
-
- // asset rating
- ratingAsset = await utils.createAsset(admin.accessToken, {
- assetData: {
- filename: 'mongolels.jpg',
- bytes: await readFile(ratingAssetFilepath),
- },
- });
-
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: ratingAsset.id });
-
- user1Assets = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken, {
- isFavorite: true,
- fileCreatedAt: yesterday.toISO(),
- fileModifiedAt: yesterday.toISO(),
- assetData: { filename: 'example.mp4' },
- }),
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- user2Assets = [await utils.createAsset(user2.accessToken)];
-
- await Promise.all([
- utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-01-01').toISOString() }),
- utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-10').toISOString() }),
- utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }),
- utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }),
- ]);
-
- for (const asset of [...user1Assets, ...user2Assets]) {
- expect(asset.status).toBe(AssetMediaStatus.Created);
- }
-
- await Promise.all([
- // stats
- utils.createAsset(statsUser.accessToken),
- utils.createAsset(statsUser.accessToken, { isFavorite: true }),
- utils.createAsset(statsUser.accessToken, { visibility: AssetVisibility.Archive }),
- utils.createAsset(statsUser.accessToken, {
- visibility: AssetVisibility.Archive,
- isFavorite: true,
- assetData: { filename: 'example.mp4' },
- }),
- ]);
-
- const person1 = await utils.createPerson(user1.accessToken, {
- name: 'Test Person',
- });
- await utils.createFace({
- assetId: user1Assets[0].id,
- personId: person1.id,
- });
- };
- beforeAll(setupTests, 30_000);
-
- afterAll(() => {
- utils.disconnectWebsocket(websocket);
- });
-
- describe('GET /assets/:id/original', () => {
- it('should download the file', async () => {
- const response = await request(app)
- .get(`/assets/${user1Assets[0].id}/original`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(response.status).toBe(200);
- expect(response.headers['content-type']).toEqual('image/png');
- });
- });
-
- describe('GET /assets/:id', () => {
- it('should require access', async () => {
- const { status, body } = await request(app)
- .get(`/assets/${user2Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.noPermission);
- });
-
- it('should get the asset info', async () => {
- const { status, body } = await request(app)
- .get(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
- expect(status).toBe(200);
- expect(body).toMatchObject({ id: user1Assets[0].id });
- });
-
- it('should get the asset rating', async () => {
- await utils.waitForWebsocketEvent({
- event: 'assetUpload',
- id: ratingAsset.id,
- });
-
- const { status, body } = await request(app)
- .get(`/assets/${ratingAsset.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(200);
- expect(body).toMatchObject({
- id: ratingAsset.id,
- exifInfo: expect.objectContaining({ rating: 3 }),
- });
- });
-
- describe('faces', () => {
- const metadataFaceTests = [
- {
- description: 'without orientation',
- filename: 'portrait.jpg',
- },
- {
- description: 'adjusting face regions to orientation',
- filename: 'portrait-orientation-6.jpg',
- },
- ];
- // should produce same resulting face region coordinates for any orientation
- const expectedFaces = [
- {
- name: 'Marie Curie',
- birthDate: null,
- isHidden: false,
- faces: [
- {
- imageHeight: 700,
- imageWidth: 840,
- boundingBoxX1: 261,
- boundingBoxX2: 356,
- boundingBoxY1: 146,
- boundingBoxY2: 284,
- sourceType: 'exif',
- },
- ],
- },
- {
- name: 'Pierre Curie',
- birthDate: null,
- isHidden: false,
- faces: [
- {
- imageHeight: 700,
- imageWidth: 840,
- boundingBoxX1: 536,
- boundingBoxX2: 618,
- boundingBoxY1: 83,
- boundingBoxY2: 252,
- sourceType: 'exif',
- },
- ],
- },
- ];
-
- it.each(metadataFaceTests)('should get the asset faces from $filename $description', async ({ filename }) => {
- const config = await utils.getSystemConfig(admin.accessToken);
- config.metadata.faces.import = true;
- await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });
-
- const facesAsset = await utils.createAsset(admin.accessToken, {
- assetData: {
- filename,
- bytes: await readFile(`${facesAssetDir}/${filename}`),
- },
- });
-
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: facesAsset.id });
-
- const { status, body } = await request(app)
- .get(`/assets/${facesAsset.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body.id).toEqual(facesAsset.id);
- expect(body.people).toMatchObject(expectedFaces);
- });
- });
-
- it('should work with a shared link', async () => {
- const sharedLink = await utils.createSharedLink(user1.accessToken, {
- type: SharedLinkType.Individual,
- assetIds: [user1Assets[0].id],
- });
-
- const { status, body } = await request(app).get(`/assets/${user1Assets[0].id}?key=${sharedLink.key}`);
- expect(status).toBe(200);
- expect(body).toMatchObject({ id: user1Assets[0].id });
- });
-
- it('should not send people data for shared links for un-authenticated users', async () => {
- const { status, body } = await request(app)
- .get(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toEqual(200);
- expect(body).toMatchObject({
- id: user1Assets[0].id,
- isFavorite: false,
- people: [
- {
- birthDate: null,
- id: expect.any(String),
- isHidden: false,
- name: 'Test Person',
- thumbnailPath: '/my/awesome/thumbnail.jpg',
- },
- ],
- });
-
- const sharedLink = await utils.createSharedLink(user1.accessToken, {
- type: SharedLinkType.Individual,
- assetIds: [user1Assets[0].id],
- });
-
- const data = await request(app).get(`/assets/${user1Assets[0].id}?key=${sharedLink.key}`);
- expect(data.status).toBe(200);
- expect(data.body).toMatchObject({ people: [] });
- });
-
- describe('partner assets', () => {
- it('should get the asset info', async () => {
- const { status, body } = await request(app)
- .get(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user2.accessToken}`);
- expect(status).toBe(200);
- expect(body).toMatchObject({ id: user1Assets[0].id });
- });
-
- it('disallows viewing archived assets', async () => {
- const asset = await utils.createAsset(user1.accessToken, { visibility: AssetVisibility.Archive });
-
- const { status } = await request(app)
- .get(`/assets/${asset.id}`)
- .set('Authorization', `Bearer ${user2.accessToken}`);
- expect(status).toBe(400);
- });
-
- it('disallows viewing trashed assets', async () => {
- const asset = await utils.createAsset(user1.accessToken);
- await utils.deleteAssets(user1.accessToken, [asset.id]);
-
- const { status } = await request(app)
- .get(`/assets/${asset.id}`)
- .set('Authorization', `Bearer ${user2.accessToken}`);
- expect(status).toBe(400);
- });
- });
- });
-
- describe('GET /assets/statistics', () => {
- it('should return stats of all assets', async () => {
- const { status, body } = await request(app)
- .get('/assets/statistics')
- .set('Authorization', `Bearer ${statsUser.accessToken}`);
-
- expect(body).toEqual({ images: 3, videos: 1, total: 4 });
- expect(status).toBe(200);
- });
-
- it('should return stats of all favored assets', async () => {
- const { status, body } = await request(app)
- .get('/assets/statistics')
- .set('Authorization', `Bearer ${statsUser.accessToken}`)
- .query({ isFavorite: true });
-
- expect(status).toBe(200);
- expect(body).toEqual({ images: 1, videos: 1, total: 2 });
- });
-
- it('should return stats of all archived assets', async () => {
- const { status, body } = await request(app)
- .get('/assets/statistics')
- .set('Authorization', `Bearer ${statsUser.accessToken}`)
- .query({ visibility: AssetVisibility.Archive });
-
- expect(status).toBe(200);
- expect(body).toEqual({ images: 1, videos: 1, total: 2 });
- });
-
- it('should return stats of all favored and archived assets', async () => {
- const { status, body } = await request(app)
- .get('/assets/statistics')
- .set('Authorization', `Bearer ${statsUser.accessToken}`)
- .query({ isFavorite: true, visibility: AssetVisibility.Archive });
-
- expect(status).toBe(200);
- expect(body).toEqual({ images: 0, videos: 1, total: 1 });
- });
-
- it('should return stats of all assets neither favored nor archived', async () => {
- const { status, body } = await request(app)
- .get('/assets/statistics')
- .set('Authorization', `Bearer ${statsUser.accessToken}`)
- .query({ isFavorite: false, visibility: AssetVisibility.Timeline });
-
- expect(status).toBe(200);
- expect(body).toEqual({ images: 1, videos: 0, total: 1 });
- });
- });
-
- describe('GET /assets/random', () => {
- beforeAll(async () => {
- await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- await utils.waitForQueueFinish(admin.accessToken, 'thumbnailGeneration');
- });
-
- it.each(TEN_TIMES)('should return 1 random assets', async () => {
- const { status, body } = await request(app)
- .get('/assets/random')
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
-
- const assets: AssetResponseDto[] = body;
- expect(assets.length).toBe(1);
- expect(assets[0].ownerId).toBe(user1.userId);
- });
-
- it.each(TEN_TIMES)('should return 2 random assets', async () => {
- const { status, body } = await request(app)
- .get('/assets/random?count=2')
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
-
- const assets: AssetResponseDto[] = body;
- expect(assets.length).toBe(2);
-
- for (const asset of assets) {
- expect(asset.ownerId).toBe(user1.userId);
- }
- });
-
- it.skip('should return 1 asset if there are 10 assets in the database but user 2 only has 1', async () => {
- const { status, body } = await request(app)
- .get('/assets/random')
- .set('Authorization', `Bearer ${user2.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual([expect.objectContaining({ id: user2Assets[0].id })]);
- });
- });
-
- describe('PUT /assets/:id', () => {
- it('should require access', async () => {
- const { status, body } = await request(app)
- .put(`/assets/${user2Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.noPermission);
- });
-
- it('should favorite an asset', async () => {
- const before = await utils.getAssetInfo(user1.accessToken, user1Assets[0].id);
- expect(before.isFavorite).toBe(false);
-
- const { status, body } = await request(app)
- .put(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ isFavorite: true });
- expect(body).toMatchObject({ id: user1Assets[0].id, isFavorite: true });
- expect(status).toEqual(200);
- });
-
- it('should archive an asset', async () => {
- const before = await utils.getAssetInfo(user1.accessToken, user1Assets[0].id);
- expect(before.isArchived).toBe(false);
-
- const { status, body } = await request(app)
- .put(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ visibility: AssetVisibility.Archive });
- expect(body).toMatchObject({ id: user1Assets[0].id, isArchived: true });
- expect(status).toEqual(200);
- });
-
- it('should update date time original', async () => {
- const { status, body } = await request(app)
- .put(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
-
- expect(body).toMatchObject({
- id: user1Assets[0].id,
- exifInfo: expect.objectContaining({
- dateTimeOriginal: '2023-11-20T01:11:00+00:00',
- timeZone: 'UTC-7',
- }),
- });
- expect(status).toEqual(200);
- });
-
- it('should not allow linking two photos', async () => {
- const { status, body } = await request(app)
- .put(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ livePhotoVideoId: user1Assets[1].id });
-
- expect(body).toEqual(errorDto.badRequest('Live photo video must be a video'));
- expect(status).toEqual(400);
- });
-
- it('should not allow linking a video owned by another user', async () => {
- const asset = await utils.createAsset(user2.accessToken, { assetData: { filename: 'example.mp4' } });
- const { status, body } = await request(app)
- .put(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ livePhotoVideoId: asset.id });
-
- expect(body).toEqual(errorDto.badRequest('Live photo video does not belong to the user'));
- expect(status).toEqual(400);
- });
-
- it('should link a motion photo', async () => {
- const asset = await utils.createAsset(user1.accessToken, { assetData: { filename: 'example.mp4' } });
- const { status, body } = await request(app)
- .put(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ livePhotoVideoId: asset.id });
-
- expect(status).toEqual(200);
- expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: asset.id });
- });
-
- it('should unlink a motion photo', async () => {
- const { status, body } = await request(app)
- .put(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ livePhotoVideoId: null });
-
- expect(status).toEqual(200);
- expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: null });
- });
-
- it.skip('should update date time original when sidecar file contains DateTimeOriginal', async () => {
- const sidecarData = `
-
-
-
- 0220 2024-07-11T10:32:52Z
- 2.3.0.0
-
-
-
-`;
-
- const { id } = await utils.createAsset(user1.accessToken, {
- sidecarData: {
- bytes: Buffer.from(sidecarData),
- filename: 'example.xmp',
- },
- });
- await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
-
- const assetInfo = await utils.getAssetInfo(user1.accessToken, id);
- expect(assetInfo.exifInfo?.dateTimeOriginal).toBe('2024-07-11T10:32:52+00:00');
-
- const { status, body } = await request(app)
- .put(`/assets/${id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
-
- expect(body).toMatchObject({
- id,
- exifInfo: expect.objectContaining({
- dateTimeOriginal: '2023-11-20T01:11:00+00:00',
- }),
- });
- expect(status).toEqual(200);
- });
-
- it('should update gps data', async () => {
- const { status, body } = await request(app)
- .put(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ latitude: 12, longitude: 12 });
-
- expect(body).toMatchObject({
- id: user1Assets[0].id,
- exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }),
- });
- expect(status).toEqual(200);
- });
-
- it.skip('should geocode country from gps data in the middle of nowhere', async () => {
- const { status } = await request(app)
- .put(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ latitude: 42, longitude: 69 });
- expect(status).toEqual(200);
-
- await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
-
- const asset = await getAssetInfo({ id: user1Assets[0].id }, { headers: asBearerAuth(user1.accessToken) });
- expect(asset).toMatchObject({
- id: user1Assets[0].id,
- exifInfo: expect.objectContaining({ city: null, country: 'Kazakhstan' }),
- });
- });
-
- it('should set the description', async () => {
- const { status, body } = await request(app)
- .put(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ description: 'Test asset description' });
- expect(body).toMatchObject({
- id: user1Assets[0].id,
- exifInfo: expect.objectContaining({
- description: 'Test asset description',
- }),
- });
- expect(status).toEqual(200);
- });
-
- it('should set the rating', async () => {
- const { status, body } = await request(app)
- .put(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ rating: 2 });
- expect(body).toMatchObject({
- id: user1Assets[0].id,
- exifInfo: expect.objectContaining({
- rating: 2,
- }),
- });
- expect(status).toEqual(200);
- });
-
- it('should set the negative rating', async () => {
- const { status, body } = await request(app)
- .put(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ rating: -1 });
- expect(body).toMatchObject({
- id: user1Assets[0].id,
- exifInfo: expect.objectContaining({
- rating: -1,
- }),
- });
- expect(status).toEqual(200);
- });
-
- it('should return tagged people', async () => {
- const { status, body } = await request(app)
- .put(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ isFavorite: true });
- expect(status).toEqual(200);
- expect(body).toMatchObject({
- id: user1Assets[0].id,
- isFavorite: true,
- people: [
- {
- birthDate: null,
- id: expect.any(String),
- isHidden: false,
- name: 'Test Person',
- thumbnailPath: '/my/awesome/thumbnail.jpg',
- },
- ],
- });
- });
- });
-
- describe('DELETE /assets', () => {
- it('should throw an error when the id is not found', async () => {
- const { status, body } = await request(app)
- .delete(`/assets`)
- .send({ ids: [uuidDto.notFound] })
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('Not found or no asset.delete access'));
- });
-
- it('should move an asset to trash', async () => {
- const { id: assetId } = await utils.createAsset(admin.accessToken);
-
- const before = await utils.getAssetInfo(admin.accessToken, assetId);
- expect(before.isTrashed).toBe(false);
-
- const { status } = await request(app)
- .delete('/assets')
- .send({ ids: [assetId] })
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(204);
-
- const after = await utils.getAssetInfo(admin.accessToken, assetId);
- expect(after.isTrashed).toBe(true);
- });
-
- it('should permanently delete an asset from trash', async () => {
- const { id: assetId } = await utils.createAsset(admin.accessToken);
-
- {
- const { status } = await request(app)
- .delete('/assets')
- .send({ ids: [assetId] })
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(204);
- }
-
- const trashed = await utils.getAssetInfo(admin.accessToken, assetId);
- expect(trashed.isTrashed).toBe(true);
-
- {
- const { status } = await request(app)
- .delete('/assets')
- .send({ ids: [assetId], force: true })
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(204);
- }
-
- await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId });
-
- {
- const { status } = await request(app)
- .get(`/assets/${assetId}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(400);
- }
- });
-
- it('should clean up live photos', async () => {
- const { id: motionId } = await utils.createAsset(admin.accessToken, {
- assetData: { filename: 'test.mp4', bytes: makeRandomImage() },
- });
- const { id: photoId } = await utils.createAsset(admin.accessToken, { livePhotoVideoId: motionId });
-
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: photoId });
- await utils.waitForWebsocketEvent({ event: 'assetHidden', id: motionId });
-
- const asset = await utils.getAssetInfo(admin.accessToken, photoId);
- expect(asset.livePhotoVideoId).toBe(motionId);
-
- const { status } = await request(app)
- .delete('/assets')
- .send({ ids: [photoId], force: true })
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(204);
-
- await utils.waitForWebsocketEvent({ event: 'assetDelete', id: photoId });
- await utils.waitForWebsocketEvent({ event: 'assetDelete', id: motionId });
- });
-
- it('should not delete a shared motion asset', async () => {
- const { id: motionId } = await utils.createAsset(admin.accessToken, {
- assetData: { filename: 'test.mp4', bytes: makeRandomImage() },
- });
- const { id: asset1 } = await utils.createAsset(admin.accessToken, { livePhotoVideoId: motionId });
- const { id: asset2 } = await utils.createAsset(admin.accessToken, { livePhotoVideoId: motionId });
-
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset1 });
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset2 });
- await utils.waitForWebsocketEvent({ event: 'assetHidden', id: motionId });
-
- const asset = await utils.getAssetInfo(admin.accessToken, asset1);
- expect(asset.livePhotoVideoId).toBe(motionId);
-
- const { status } = await request(app)
- .delete('/assets')
- .send({ ids: [asset1], force: true })
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(204);
-
- await utils.waitForWebsocketEvent({ event: 'assetDelete', id: asset1 });
- await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask');
-
- await expect(utils.getAssetInfo(admin.accessToken, motionId)).resolves.toMatchObject({ id: motionId });
- await expect(utils.getAssetInfo(admin.accessToken, asset2)).resolves.toMatchObject({
- id: asset2,
- livePhotoVideoId: motionId,
- });
- });
- });
-
- describe('GET /assets/:id/thumbnail', () => {
- it('should not include gps data for webp thumbnails', async () => {
- await utils.waitForWebsocketEvent({
- event: 'assetUpload',
- id: locationAsset.id,
- });
-
- const { status, body, type } = await request(app)
- .get(`/assets/${locationAsset.id}/thumbnail?format=WEBP`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toBeDefined();
- expect(type).toBe('image/webp');
-
- const exifData = await readTags(body, 'thumbnail.webp');
- expect(exifData).not.toHaveProperty('GPSLongitude');
- expect(exifData).not.toHaveProperty('GPSLatitude');
- });
-
- it('should not include gps data for jpeg previews', async () => {
- const { status, body, type } = await request(app)
- .get(`/assets/${locationAsset.id}/thumbnail?size=preview`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toBeDefined();
- expect(type).toBe('image/jpeg');
-
- const exifData = await readTags(body, 'thumbnail.jpg');
- expect(exifData).not.toHaveProperty('GPSLongitude');
- expect(exifData).not.toHaveProperty('GPSLatitude');
- });
- });
-
- describe('GET /assets/:id/original', () => {
- it('should download the original', async () => {
- const { status, body, type } = await request(app)
- .get(`/assets/${locationAsset.id}/original`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toBeDefined();
- expect(type).toBe('image/jpeg');
-
- const asset = await utils.getAssetInfo(admin.accessToken, locationAsset.id);
-
- const original = await readFile(locationAssetFilepath);
- const originalChecksum = utils.sha1(original);
- const downloadChecksum = utils.sha1(body);
-
- expect(originalChecksum).toBe(downloadChecksum);
- expect(downloadChecksum).toBe(asset.checksum);
- });
- });
-
- describe('PUT /assets', () => {
- it('should update date time original relatively', async () => {
- const { status, body } = await request(app)
- .put(`/assets/`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ ids: [user1Assets[0].id], dateTimeRelative: -1441 });
-
- expect(body).toEqual({});
- expect(status).toEqual(204);
-
- const result = await request(app)
- .get(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send();
-
- expect(result.body).toMatchObject({
- id: user1Assets[0].id,
- exifInfo: expect.objectContaining({
- dateTimeOriginal: '2023-11-19T01:10:00+00:00',
- }),
- });
- });
- });
-
- describe('POST /assets', () => {
- beforeAll(setupTests, 30_000);
-
- const tests = [
- {
- input: 'formats/avif/8bit-sRGB.avif',
- expected: {
- type: AssetTypeEnum.Image,
- originalFileName: '8bit-sRGB.avif',
- exifInfo: {
- description: '',
- exifImageHeight: 1080,
- exifImageWidth: 1617,
- fileSizeInByte: 862_424,
- },
- },
- },
- {
- input: 'formats/jpg/el_torcal_rocks.jpg',
- expected: {
- type: AssetTypeEnum.Image,
- originalFileName: 'el_torcal_rocks.jpg',
- exifInfo: {
- dateTimeOriginal: '2012-08-05T11:39:59+00:00',
- exifImageWidth: 512,
- exifImageHeight: 341,
- focalLength: 75,
- iso: 200,
- fNumber: 11,
- exposureTime: '1/160',
- fileSizeInByte: 53_493,
- make: 'SONY',
- model: 'DSLR-A550',
- description: 'SONY DSC',
- },
- },
- },
- {
- input: 'formats/jxl/8bit-sRGB.jxl',
- expected: {
- type: AssetTypeEnum.Image,
- originalFileName: '8bit-sRGB.jxl',
- exifInfo: {
- description: '',
- exifImageHeight: 1080,
- exifImageWidth: 1440,
- fileSizeInByte: 1_780_777,
- },
- },
- },
- {
- input: 'formats/heic/IMG_2682.heic',
- expected: {
- type: AssetTypeEnum.Image,
- originalFileName: 'IMG_2682.heic',
- fileCreatedAt: '2019-03-21T16:04:22.348Z',
- exifInfo: {
- dateTimeOriginal: '2019-03-21T16:04:22.348+00:00',
- exifImageWidth: 4032,
- exifImageHeight: 3024,
- latitude: 41.2203,
- longitude: -96.071_625,
- make: 'Apple',
- model: 'iPhone 7',
- lensModel: 'iPhone 7 back camera 3.99mm f/1.8',
- fileSizeInByte: 880_703,
- exposureTime: '1/887',
- iso: 20,
- focalLength: 3.99,
- fNumber: 1.8,
- timeZone: 'America/Chicago',
- },
- },
- },
- {
- input: 'formats/png/density_plot.png',
- expected: {
- type: AssetTypeEnum.Image,
- originalFileName: 'density_plot.png',
- exifInfo: {
- exifImageWidth: 800,
- exifImageHeight: 800,
- fileSizeInByte: 25_408,
- },
- },
- },
- {
- input: 'formats/raw/Nikon/D80/glarus.nef',
- expected: {
- type: AssetTypeEnum.Image,
- originalFileName: 'glarus.nef',
- fileCreatedAt: '2010-07-20T17:27:12.000Z',
- exifInfo: {
- make: 'NIKON CORPORATION',
- model: 'NIKON D80',
- exposureTime: '1/200',
- fNumber: 10,
- focalLength: 18,
- iso: 100,
- fileSizeInByte: 9_057_784,
- dateTimeOriginal: '2010-07-20T17:27:12+00:00',
- orientation: '1',
- },
- },
- },
- {
- input: 'formats/raw/Nikon/D700/philadelphia.nef',
- expected: {
- type: AssetTypeEnum.Image,
- originalFileName: 'philadelphia.nef',
- fileCreatedAt: '2016-09-22T21:10:29.060Z',
- exifInfo: {
- make: 'NIKON CORPORATION',
- model: 'NIKON D700',
- exposureTime: '1/400',
- fNumber: 11,
- focalLength: 85,
- iso: 200,
- fileSizeInByte: 15_856_335,
- dateTimeOriginal: '2016-09-22T21:10:29.06+00:00',
- orientation: '1',
- timeZone: 'UTC-4',
- },
- },
- },
- {
- input: 'formats/raw/Panasonic/DMC-GH4/4_3.rw2',
- expected: {
- type: AssetTypeEnum.Image,
- originalFileName: '4_3.rw2',
- fileCreatedAt: '2018-05-10T08:42:37.842Z',
- exifInfo: {
- make: 'Panasonic',
- model: 'DMC-GH4',
- exifImageHeight: 3456,
- exifImageWidth: 4608,
- exposureTime: '1/100',
- fNumber: 3.2,
- focalLength: 35,
- iso: 400,
- fileSizeInByte: 19_587_072,
- dateTimeOriginal: '2018-05-10T08:42:37.842+00:00',
- orientation: '1',
- },
- },
- },
- {
- input: 'formats/raw/Sony/ILCE-6300/12bit-compressed-(3_2).arw',
- expected: {
- type: AssetTypeEnum.Image,
- originalFileName: '12bit-compressed-(3_2).arw',
- fileCreatedAt: '2016-09-27T10:51:44.000Z',
- exifInfo: {
- make: 'SONY',
- model: 'ILCE-6300',
- exifImageHeight: 4024,
- exifImageWidth: 6048,
- exposureTime: '1/320',
- fNumber: 8,
- focalLength: 97,
- iso: 100,
- lensModel: 'Sony E PZ 18-105mm F4 G OSS',
- fileSizeInByte: 25_001_984,
- dateTimeOriginal: '2016-09-27T10:51:44+00:00',
- orientation: '1',
- },
- },
- },
- {
- input: 'formats/raw/Sony/ILCE-7M2/14bit-uncompressed-(3_2).arw',
- expected: {
- type: AssetTypeEnum.Image,
- originalFileName: '14bit-uncompressed-(3_2).arw',
- fileCreatedAt: '2016-01-08T14:08:01.000Z',
- exifInfo: {
- make: 'SONY',
- model: 'ILCE-7M2',
- exifImageHeight: 4024,
- exifImageWidth: 6048,
- exposureTime: '1.3',
- fNumber: 22,
- focalLength: 25,
- iso: 100,
- lensModel: 'Zeiss Batis 25mm F2',
- fileSizeInByte: 49_512_448,
- dateTimeOriginal: '2016-01-08T14:08:01+00:00',
- orientation: '1',
- },
- },
- },
- {
- input: 'formats/raw/Canon/PowerShot_G12.CR2',
- expected: {
- type: AssetTypeEnum.Image,
- originalFileName: 'PowerShot_G12.CR2',
- fileCreatedAt: '2015-12-27T09:55:40.000Z',
- exifInfo: {
- make: 'Canon',
- model: 'Canon PowerShot G12',
- exifImageHeight: 2736,
- exifImageWidth: 3648,
- exposureTime: '1/1000',
- fNumber: 4,
- focalLength: 18.098,
- iso: 80,
- lensModel: null,
- fileSizeInByte: 11_113_617,
- dateTimeOriginal: '2015-12-27T09:55:40+00:00',
- latitude: null,
- longitude: null,
- orientation: '1',
- },
- },
- },
- {
- input: 'formats/raw/Fujifilm/X100V_compressed.RAF',
- expected: {
- type: AssetTypeEnum.Image,
- originalFileName: 'X100V_compressed.RAF',
- fileCreatedAt: '2024-10-12T21:01:01.000Z',
- exifInfo: {
- make: 'FUJIFILM',
- model: 'X100V',
- exifImageHeight: 4160,
- exifImageWidth: 6240,
- exposureTime: '1/4000',
- fNumber: 16,
- focalLength: 23,
- iso: 160,
- lensModel: null,
- fileSizeInByte: 13_551_312,
- dateTimeOriginal: '2024-10-12T21:01:01+00:00',
- latitude: null,
- longitude: null,
- orientation: '6',
- },
- },
- },
- {
- input: 'formats/raw/Ricoh/GR3/Ricoh_GR3-450.DNG',
- expected: {
- type: AssetTypeEnum.Image,
- originalFileName: 'Ricoh_GR3-450.DNG',
- fileCreatedAt: '2024-06-08T13:48:39.000Z',
- exifInfo: {
- dateTimeOriginal: '2024-06-08T13:48:39+00:00',
- exifImageHeight: 4064,
- exifImageWidth: 6112,
- exposureTime: '1/400',
- fNumber: 5,
- fileSizeInByte: 31_175_472,
- focalLength: 18.3,
- iso: 100,
- latitude: 36.613_24,
- lensModel: '18.3mm F2.8',
- longitude: -121.897_85,
- make: 'RICOH IMAGING COMPANY, LTD.',
- model: 'RICOH GR III',
- orientation: '1',
- },
- },
- },
- ];
-
- it.each(tests)(`should upload and generate a thumbnail for different file types`, async ({ input, expected }) => {
- const filepath = join(testAssetDir, input);
- const response = await utils.createAsset(admin.accessToken, {
- assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
- });
-
- expect(response.status).toBe(AssetMediaStatus.Created);
- const id = response.id;
- // longer timeout as the thumbnail generation from full-size raw files can take a while
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id });
-
- const asset = await utils.getAssetInfo(admin.accessToken, id);
- expect(asset.exifInfo).toBeDefined();
- expect(asset.exifInfo).toMatchObject(expected.exifInfo);
- expect(asset).toMatchObject(expected);
- });
-
- it('should handle a duplicate', async () => {
- const filepath = 'formats/jpeg/el_torcal_rocks.jpeg';
- const { status } = await utils.createAsset(admin.accessToken, {
- assetData: {
- bytes: await readFile(join(testAssetDir, filepath)),
- filename: basename(filepath),
- },
- });
-
- expect(status).toBe(AssetMediaStatus.Duplicate);
- });
-
- it('should update the used quota', async () => {
- const { body, status } = await request(app)
- .post('/assets')
- .set('Authorization', `Bearer ${quotaUser.accessToken}`)
- .field('deviceAssetId', 'example-image')
- .field('deviceId', 'e2e')
- .field('fileCreatedAt', new Date().toISOString())
- .field('fileModifiedAt', new Date().toISOString())
- .attach('assetData', makeRandomImage(), 'example.jpg');
-
- expect(body).toEqual({ id: expect.any(String), status: AssetMediaStatus.Created });
- expect(status).toBe(201);
-
- const user = await getMyUser({ headers: asBearerAuth(quotaUser.accessToken) });
-
- expect(user).toEqual(expect.objectContaining({ quotaUsageInBytes: 70 }));
- });
-
- it('should not upload an asset if it would exceed the quota', async () => {
- const { body, status } = await request(app)
- .post('/assets')
- .set('Authorization', `Bearer ${quotaUser.accessToken}`)
- .field('deviceAssetId', 'example-image')
- .field('deviceId', 'e2e')
- .field('fileCreatedAt', new Date().toISOString())
- .field('fileModifiedAt', new Date().toISOString())
- .attach('assetData', randomBytes(2014), 'example.jpg');
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('Quota has been exceeded!'));
- });
-
- // These hashes were created by copying the image files to a Samsung phone,
- // exporting the video from Samsung's stock Gallery app, and hashing them locally.
- // This ensures that immich+exiftool are extracting the videos the same way Samsung does.
- // DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives
- // into the test here.
- it.each([
- {
- filepath: 'formats/motionphoto/samsung-one-ui-5.jpg',
- checksum: 'fr14niqCq6N20HB8rJYEvpsUVtI=',
- },
- {
- filepath: 'formats/motionphoto/samsung-one-ui-6.jpg',
- checksum: 'lT9Uviw/FFJYCjfIxAGPTjzAmmw=',
- },
- {
- filepath: 'formats/motionphoto/samsung-one-ui-6.heic',
- checksum: '/ejgzywvgvzvVhUYVfvkLzFBAF0=',
- },
- {
- filepath: 'formats/motionphoto/pixel-6-pro.jpg',
- checksum: 'bFhLGbdK058PSk4FTfrSnoKWykc=',
- },
- {
- filepath: 'formats/motionphoto/pixel-8a.jpg',
- checksum: '7YdY+WF0h+CXHbiXpi0HiCMTTjs=',
- },
- ])(`should extract motionphoto video from $filepath`, async ({ filepath, checksum }) => {
- const response = await utils.createAsset(admin.accessToken, {
- assetData: {
- bytes: await readFile(join(testAssetDir, filepath)),
- filename: basename(filepath),
- },
- });
-
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: response.id });
-
- expect(response.status).toBe(AssetMediaStatus.Created);
-
- const asset = await utils.getAssetInfo(admin.accessToken, response.id);
- expect(asset.livePhotoVideoId).toBeDefined();
-
- const video = await utils.getAssetInfo(admin.accessToken, asset.livePhotoVideoId as string);
- expect(video.checksum).toStrictEqual(checksum);
- });
- });
-
- describe('POST /assets/exist', () => {
- it('ignores invalid deviceAssetIds', async () => {
- const response = await utils.checkExistingAssets(user1.accessToken, {
- deviceId: 'test-assets-exist',
- deviceAssetIds: ['invalid', 'INVALID'],
- });
-
- expect(response.existingIds).toHaveLength(0);
- });
-
- it('returns the IDs of existing assets', async () => {
- await utils.createAsset(user1.accessToken, {
- deviceId: 'test-assets-exist',
- deviceAssetId: 'test-asset-0',
- });
-
- const response = await utils.checkExistingAssets(user1.accessToken, {
- deviceId: 'test-assets-exist',
- deviceAssetIds: ['test-asset-0'],
- });
-
- expect(response.existingIds).toEqual(['test-asset-0']);
- });
- });
-});
diff --git a/e2e/src/api/specs/database-backups.e2e-spec.ts b/e2e/src/api/specs/database-backups.e2e-spec.ts
deleted file mode 100644
index 2b0f6ae61a..0000000000
--- a/e2e/src/api/specs/database-backups.e2e-spec.ts
+++ /dev/null
@@ -1,350 +0,0 @@
-import { LoginResponseDto, ManualJobName } from '@immich/sdk';
-import { errorDto } from 'src/responses';
-import { app, utils } from 'src/utils';
-import request from 'supertest';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
-
-describe('/admin/database-backups', () => {
- let cookie: string | undefined;
- let admin: LoginResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
- admin = await utils.adminSetup();
- await utils.resetBackups(admin.accessToken);
- });
-
- describe('GET /', async () => {
- it('should succeed and be empty', async () => {
- const { status, body } = await request(app)
- .get('/admin/database-backups')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(200);
- expect(body).toEqual({
- backups: [],
- });
- });
-
- it('should contain a created backup', async () => {
- await utils.createJob(admin.accessToken, {
- name: ManualJobName.BackupDatabase,
- });
-
- await utils.waitForQueueFinish(admin.accessToken, 'backupDatabase');
-
- await expect
- .poll(
- async () => {
- const { status, body } = await request(app)
- .get('/admin/database-backups')
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- return body;
- },
- {
- interval: 500,
- timeout: 10_000,
- },
- )
- .toEqual(
- expect.objectContaining({
- backups: [
- expect.objectContaining({
- filename: expect.stringMatching(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/),
- filesize: expect.any(Number),
- }),
- ],
- }),
- );
- });
- });
-
- describe('DELETE /', async () => {
- it('should delete backup', async () => {
- const filename = await utils.createBackup(admin.accessToken);
-
- const { status } = await request(app)
- .delete(`/admin/database-backups`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ backups: [filename] });
-
- expect(status).toBe(200);
-
- const { status: listStatus, body } = await request(app)
- .get('/admin/database-backups')
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(listStatus).toBe(200);
- expect(body).toEqual(
- expect.objectContaining({
- backups: [],
- }),
- );
- });
- });
-
- // => action: restore database flow
-
- describe.sequential('POST /start-restore', () => {
- afterAll(async () => {
- await request(app).post('/admin/maintenance').set('cookie', cookie!).send({ action: 'end' });
- await utils.poll(
- () => request(app).get('/server/config'),
- ({ status, body }) => status === 200 && !body.maintenanceMode,
- );
-
- admin = await utils.adminSetup();
- });
-
- it.sequential('should not work when the server is configured', async () => {
- const { status, body } = await request(app).post('/admin/database-backups/start-restore').send();
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('The server already has an admin'));
- });
-
- it.sequential('should enter maintenance mode in "database restore mode"', async () => {
- await utils.resetDatabase(); // reset database before running this test
-
- const { status, headers } = await request(app).post('/admin/database-backups/start-restore').send();
-
- expect(status).toBe(201);
-
- cookie = headers['set-cookie'][0].split(';')[0];
-
- await expect
- .poll(
- async () => {
- const { status, body } = await request(app).get('/server/config');
- expect(status).toBe(200);
- return body.maintenanceMode;
- },
- {
- interval: 500,
- timeout: 10_000,
- },
- )
- .toBeTruthy();
-
- const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
- expect(status2).toBe(200);
- expect(body).toEqual({
- active: true,
- action: 'select_database_restore',
- });
- });
- });
-
- // => action: restore database
-
- describe.sequential('POST /backups/restore', () => {
- beforeAll(async () => {
- await utils.disconnectDatabase();
- });
-
- afterAll(async () => {
- await utils.connectDatabase();
- });
-
- it.sequential('should restore a backup', { timeout: 60_000 }, async () => {
- let filename = await utils.createBackup(admin.accessToken);
-
- // work-around until test is running on released version
- await utils.move(
- `/data/backups/${filename}`,
- '/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
- );
- filename = 'immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz';
-
- const { status } = await request(app)
- .post('/admin/maintenance')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({
- action: 'restore_database',
- restoreBackupFilename: filename,
- });
-
- expect(status).toBe(201);
-
- await expect
- .poll(
- async () => {
- const { status, body } = await request(app).get('/server/config');
- expect(status).toBe(200);
- return body.maintenanceMode;
- },
- {
- interval: 500,
- timeout: 10_000,
- },
- )
- .toBeTruthy();
-
- const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
- expect(status2).toBe(200);
- expect(body).toEqual(
- expect.objectContaining({
- active: true,
- action: 'restore_database',
- }),
- );
-
- await expect
- .poll(
- async () => {
- const { status, body } = await request(app).get('/server/config');
- expect(status).toBe(200);
- return body.maintenanceMode;
- },
- {
- interval: 500,
- timeout: 60_000,
- },
- )
- .toBeFalsy();
- });
-
- it.sequential('fail to restore a corrupted backup', { timeout: 60_000 }, async () => {
- await utils.prepareTestBackup('corrupted');
-
- const { status, headers } = await request(app)
- .post('/admin/maintenance')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({
- action: 'restore_database',
- restoreBackupFilename: 'development-corrupted.sql.gz',
- });
-
- expect(status).toBe(201);
- cookie = headers['set-cookie'][0].split(';')[0];
-
- await expect
- .poll(
- async () => {
- const { status, body } = await request(app).get('/server/config');
- expect(status).toBe(200);
- return body.maintenanceMode;
- },
- {
- interval: 500,
- timeout: 10_000,
- },
- )
- .toBeTruthy();
-
- await expect
- .poll(
- async () => {
- const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
- expect(status).toBe(200);
- return body;
- },
- {
- interval: 500,
- timeout: 10_000,
- },
- )
- .toEqual(
- expect.objectContaining({
- active: true,
- action: 'restore_database',
- error: 'Something went wrong, see logs!',
- }),
- );
-
- const { status: status2, body: body2 } = await request(app)
- .get('/admin/maintenance/status')
- .set('cookie', cookie!)
- .send({ token: 'token' });
- expect(status2).toBe(200);
- expect(body2).toEqual(
- expect.objectContaining({
- active: true,
- action: 'restore_database',
- error: expect.stringContaining('IM CORRUPTED'),
- }),
- );
-
- await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
- action: 'end',
- });
-
- await utils.poll(
- () => request(app).get('/server/config'),
- ({ status, body }) => status === 200 && !body.maintenanceMode,
- );
- });
-
- it.sequential('rollback to restore point if backup is missing admin', { timeout: 60_000 }, async () => {
- await utils.prepareTestBackup('empty');
-
- const { status, headers } = await request(app)
- .post('/admin/maintenance')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({
- action: 'restore_database',
- restoreBackupFilename: 'development-empty.sql.gz',
- });
-
- expect(status).toBe(201);
- cookie = headers['set-cookie'][0].split(';')[0];
-
- await expect
- .poll(
- async () => {
- const { status, body } = await request(app).get('/server/config');
- expect(status).toBe(200);
- return body.maintenanceMode;
- },
- {
- interval: 500,
- timeout: 10_000,
- },
- )
- .toBeTruthy();
-
- await expect
- .poll(
- async () => {
- const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
- expect(status).toBe(200);
- return body;
- },
- {
- interval: 500,
- timeout: 30_000,
- },
- )
- .toEqual(
- expect.objectContaining({
- active: true,
- action: 'restore_database',
- error: 'Something went wrong, see logs!',
- }),
- );
-
- const { status: status2, body: body2 } = await request(app)
- .get('/admin/maintenance/status')
- .set('cookie', cookie!)
- .send({ token: 'token' });
- expect(status2).toBe(200);
- expect(body2).toEqual(
- expect.objectContaining({
- active: true,
- action: 'restore_database',
- error: expect.stringContaining('Server health check failed, no admin exists.'),
- }),
- );
-
- await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
- action: 'end',
- });
-
- await utils.poll(
- () => request(app).get('/server/config'),
- ({ status, body }) => status === 200 && !body.maintenanceMode,
- );
- });
- });
-});
diff --git a/e2e/src/api/specs/download.e2e-spec.ts b/e2e/src/api/specs/download.e2e-spec.ts
deleted file mode 100644
index 4dcb6934af..0000000000
--- a/e2e/src/api/specs/download.e2e-spec.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
-import { readFile, writeFile } from 'node:fs/promises';
-import { app, tempDir, utils } from 'src/utils';
-import request from 'supertest';
-import { beforeAll, describe, expect, it } from 'vitest';
-
-describe('/download', () => {
- let admin: LoginResponseDto;
- let asset1: AssetMediaResponseDto;
- let asset2: AssetMediaResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
- admin = await utils.adminSetup();
- [asset1, asset2] = await Promise.all([utils.createAsset(admin.accessToken), utils.createAsset(admin.accessToken)]);
- });
-
- describe('POST /download/info', () => {
- it('should download info', async () => {
- const { status, body } = await request(app)
- .post('/download/info')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ assetIds: [asset1.id] });
-
- expect(status).toBe(201);
- expect(body).toEqual(
- expect.objectContaining({
- archives: [expect.objectContaining({ assetIds: [asset1.id] })],
- }),
- );
- });
- });
-
- describe('POST /download/archive', () => {
- it('should download an archive', async () => {
- const { status, body } = await request(app)
- .post('/download/archive')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ assetIds: [asset1.id, asset2.id] });
-
- expect(status).toBe(200);
- expect(body instanceof Buffer).toBe(true);
-
- await writeFile(`${tempDir}/archive.zip`, body);
- await utils.unzip(`${tempDir}/archive.zip`, `${tempDir}/archive`);
- const files = [
- { filename: 'example.png', id: asset1.id },
- { filename: 'example+1.png', id: asset2.id },
- ];
- for (const { id, filename } of files) {
- const bytes = await readFile(`${tempDir}/archive/${filename}`);
- const asset = await utils.getAssetInfo(admin.accessToken, id);
- expect(utils.sha1(bytes)).toBe(asset.checksum);
- }
- });
- });
-});
diff --git a/e2e/src/api/specs/jobs.e2e-spec.ts b/e2e/src/api/specs/jobs.e2e-spec.ts
deleted file mode 100644
index be7984404b..0000000000
--- a/e2e/src/api/specs/jobs.e2e-spec.ts
+++ /dev/null
@@ -1,225 +0,0 @@
-import { LoginResponseDto, QueueCommand, QueueName, updateConfig } from '@immich/sdk';
-import { cpSync, rmSync } from 'node:fs';
-import { readFile } from 'node:fs/promises';
-import { basename } from 'node:path';
-import { errorDto } from 'src/responses';
-import { app, asBearerAuth, testAssetDir, utils } from 'src/utils';
-import request from 'supertest';
-import { afterEach, beforeAll, describe, expect, it } from 'vitest';
-
-describe('/jobs', () => {
- let admin: LoginResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
- admin = await utils.adminSetup({ onboarding: false });
- });
-
- describe('PUT /jobs', () => {
- afterEach(async () => {
- await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
- command: QueueCommand.Resume,
- force: false,
- });
-
- await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
- command: QueueCommand.Resume,
- force: false,
- });
-
- await utils.queueCommand(admin.accessToken, QueueName.FaceDetection, {
- command: QueueCommand.Resume,
- force: false,
- });
-
- await utils.queueCommand(admin.accessToken, QueueName.SmartSearch, {
- command: QueueCommand.Resume,
- force: false,
- });
-
- await utils.queueCommand(admin.accessToken, QueueName.DuplicateDetection, {
- command: QueueCommand.Resume,
- force: false,
- });
-
- const config = await utils.getSystemConfig(admin.accessToken);
- config.machineLearning.duplicateDetection.enabled = false;
- config.machineLearning.enabled = false;
- config.metadata.faces.import = false;
- config.machineLearning.clip.enabled = false;
- await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });
- });
-
- it('should require authentication', async () => {
- const { status, body } = await request(app).put('/jobs/metadataExtraction');
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should queue metadata extraction for missing assets', async () => {
- const path = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`;
-
- await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
- command: QueueCommand.Pause,
- force: false,
- });
-
- const { id } = await utils.createAsset(admin.accessToken, {
- assetData: { bytes: await readFile(path), filename: basename(path) },
- });
-
- await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
-
- {
- const asset = await utils.getAssetInfo(admin.accessToken, id);
-
- expect(asset.exifInfo).toBeDefined();
- expect(asset.exifInfo?.make).toBeNull();
- }
-
- await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
- command: QueueCommand.Empty,
- force: false,
- });
-
- await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
-
- await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
- command: QueueCommand.Resume,
- force: false,
- });
-
- await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
- command: QueueCommand.Start,
- force: false,
- });
-
- await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
-
- {
- const asset = await utils.getAssetInfo(admin.accessToken, id);
-
- expect(asset.exifInfo).toBeDefined();
- expect(asset.exifInfo?.make).toBe('NIKON CORPORATION');
- }
- });
-
- it('should not re-extract metadata for existing assets', async () => {
- const path = `${testAssetDir}/temp/metadata/asset.jpg`;
-
- cpSync(`${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`, path);
-
- const { id } = await utils.createAsset(admin.accessToken, {
- assetData: { bytes: await readFile(path), filename: basename(path) },
- });
-
- await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
-
- {
- const asset = await utils.getAssetInfo(admin.accessToken, id);
-
- expect(asset.exifInfo).toBeDefined();
- expect(asset.exifInfo?.model).toBe('NIKON D700');
- }
-
- cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, path);
-
- await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
- command: QueueCommand.Start,
- force: false,
- });
-
- await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
-
- {
- const asset = await utils.getAssetInfo(admin.accessToken, id);
-
- expect(asset.exifInfo).toBeDefined();
- expect(asset.exifInfo?.model).toBe('NIKON D700');
- }
-
- rmSync(path);
- });
-
- it('should queue thumbnail extraction for assets missing thumbs', async () => {
- const path = `${testAssetDir}/albums/nature/tanners_ridge.jpg`;
-
- await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
- command: QueueCommand.Pause,
- force: false,
- });
-
- const { id } = await utils.createAsset(admin.accessToken, {
- assetData: { bytes: await readFile(path), filename: basename(path) },
- });
-
- await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
- await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
-
- const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
- expect(assetBefore.thumbhash).toBeNull();
-
- await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
- command: QueueCommand.Empty,
- force: false,
- });
-
- await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
- await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
-
- await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
- command: QueueCommand.Resume,
- force: false,
- });
-
- await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
- command: QueueCommand.Start,
- force: false,
- });
-
- await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
- await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
-
- const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
- expect(assetAfter.thumbhash).not.toBeNull();
- });
-
- it('should not reload existing thumbnail when running thumb job for missing assets', async () => {
- const path = `${testAssetDir}/temp/thumbs/asset1.jpg`;
-
- cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, path);
-
- const { id } = await utils.createAsset(admin.accessToken, {
- assetData: { bytes: await readFile(path), filename: basename(path) },
- });
-
- await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
- await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
-
- const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
-
- cpSync(`${testAssetDir}/albums/nature/notocactus_minimus.jpg`, path);
-
- await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
- command: QueueCommand.Resume,
- force: false,
- });
-
- // This runs the missing thumbnail job
- await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
- command: QueueCommand.Start,
- force: false,
- });
-
- await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
- await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
-
- const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
-
- // Asset 1 thumbnail should be untouched since its thumb should not have been reloaded, even though the file was changed
- expect(assetAfter.thumbhash).toEqual(assetBefore.thumbhash);
-
- rmSync(path);
- });
- });
-});
diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts
deleted file mode 100644
index 4d67a84647..0000000000
--- a/e2e/src/api/specs/library.e2e-spec.ts
+++ /dev/null
@@ -1,1448 +0,0 @@
-import { LibraryResponseDto, LoginResponseDto, getAllLibraries } from '@immich/sdk';
-import { cpSync, existsSync, rmSync, unlinkSync } from 'node:fs';
-import { Socket } from 'socket.io-client';
-import { userDto, uuidDto } from 'src/fixtures';
-import { errorDto } from 'src/responses';
-import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils';
-import request from 'supertest';
-import { utimes } from 'utimes';
-import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
-
-describe('/libraries', () => {
- let admin: LoginResponseDto;
- let user: LoginResponseDto;
- let library: LibraryResponseDto;
- let websocket: Socket;
-
- beforeAll(async () => {
- await utils.resetDatabase();
- admin = await utils.adminSetup();
- await utils.resetAdminConfig(admin.accessToken);
- user = await utils.userSetup(admin.accessToken, userDto.user1);
- library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId });
- websocket = await utils.connectWebsocket(admin.accessToken);
- utils.createImageFile(`${testAssetDir}/temp/directoryA/assetA.png`);
- utils.createImageFile(`${testAssetDir}/temp/directoryB/assetB.png`);
- });
-
- afterAll(() => {
- utils.disconnectWebsocket(websocket);
- utils.resetTempFolder();
- });
-
- beforeEach(() => {
- utils.resetEvents();
- });
-
- describe('GET /libraries', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).get('/libraries');
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
- });
-
- describe('POST /libraries', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).post('/libraries').send({});
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require admin authentication', async () => {
- const { status, body } = await request(app)
- .post('/libraries')
- .set('Authorization', `Bearer ${user.accessToken}`)
- .send({ ownerId: admin.userId });
-
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.forbidden);
- });
-
- it('should create an external library with defaults', async () => {
- const { status, body } = await request(app)
- .post('/libraries')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ ownerId: admin.userId });
-
- expect(status).toBe(201);
- expect(body).toEqual(
- expect.objectContaining({
- ownerId: admin.userId,
- name: 'New External Library',
- refreshedAt: null,
- assetCount: 0,
- importPaths: [],
- exclusionPatterns: expect.any(Array),
- }),
- );
- });
-
- it('should create an external library with options', async () => {
- const { status, body } = await request(app)
- .post('/libraries')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({
- ownerId: admin.userId,
- name: 'My Awesome Library',
- importPaths: ['/path/to/import'],
- exclusionPatterns: ['**/Raw/**'],
- });
-
- expect(status).toBe(201);
- expect(body).toEqual(
- expect.objectContaining({
- name: 'My Awesome Library',
- importPaths: ['/path/to/import'],
- }),
- );
- });
-
- it('should not create an external library with duplicate import paths', async () => {
- const { status, body } = await request(app)
- .post('/libraries')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({
- ownerId: admin.userId,
- name: 'My Awesome Library',
- importPaths: ['/path', '/path'],
- exclusionPatterns: ['**/Raw/**'],
- });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
- });
-
- it('should not create an external library with duplicate exclusion patterns', async () => {
- const { status, body } = await request(app)
- .post('/libraries')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({
- ownerId: admin.userId,
- name: 'My Awesome Library',
- importPaths: ['/path/to/import'],
- exclusionPatterns: ['**/Raw/**', '**/Raw/**'],
- });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
- });
- });
-
- describe('PUT /libraries/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).put(`/libraries/${uuidDto.notFound}`).send({});
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should change the library name', async () => {
- const { status, body } = await request(app)
- .put(`/libraries/${library.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ name: 'New Library Name' });
-
- expect(status).toBe(200);
- expect(body).toEqual(
- expect.objectContaining({
- name: 'New Library Name',
- }),
- );
- });
-
- it('should not set an empty name', async () => {
- const { status, body } = await request(app)
- .put(`/libraries/${library.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ name: '' });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest(['name should not be empty']));
- });
-
- it('should change the import paths', async () => {
- const { status, body } = await request(app)
- .put(`/libraries/${library.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ importPaths: [testAssetDirInternal] });
-
- expect(status).toBe(200);
- expect(body).toEqual(
- expect.objectContaining({
- importPaths: [testAssetDirInternal],
- }),
- );
- });
-
- it('should reject an empty import path', async () => {
- const { status, body } = await request(app)
- .put(`/libraries/${library.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ importPaths: [''] });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest(['each value in importPaths should not be empty']));
- });
-
- it('should reject duplicate import paths', async () => {
- const { status, body } = await request(app)
- .put(`/libraries/${library.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ importPaths: ['/path', '/path'] });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
- });
-
- it('should change the exclusion pattern', async () => {
- const { status, body } = await request(app)
- .put(`/libraries/${library.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ exclusionPatterns: ['**/Raw/**'] });
-
- expect(status).toBe(200);
- expect(body).toEqual(
- expect.objectContaining({
- exclusionPatterns: ['**/Raw/**'],
- }),
- );
- });
-
- it('should reject duplicate exclusion patterns', async () => {
- const { status, body } = await request(app)
- .put(`/libraries/${library.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
- });
-
- it('should reject an empty exclusion pattern', async () => {
- const { status, body } = await request(app)
- .put(`/libraries/${library.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ exclusionPatterns: [''] });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest(['each value in exclusionPatterns should not be empty']));
- });
- });
-
- describe('GET /libraries/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).get(`/libraries/${uuidDto.notFound}`);
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require admin access', async () => {
- const { status, body } = await request(app)
- .get(`/libraries/${uuidDto.notFound}`)
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.forbidden);
- });
-
- it('should get library by id', async () => {
- const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId });
-
- const { status, body } = await request(app)
- .get(`/libraries/${library.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual(
- expect.objectContaining({
- ownerId: admin.userId,
- name: 'New External Library',
- refreshedAt: null,
- assetCount: 0,
- importPaths: [],
- exclusionPatterns: expect.any(Array),
- }),
- );
- });
- });
-
- describe('GET /libraries/:id/statistics', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).get(`/libraries/${uuidDto.notFound}/statistics`);
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
- });
-
- describe('POST /libraries/:id/scan', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/scan`).send({});
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should import new asset when scanning external library', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/directoryA`],
- });
-
- const { status } = await request(app)
- .post(`/libraries/${library.id}/scan`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send();
- expect(status).toBe(204);
-
- await utils.waitForQueueFinish(admin.accessToken, 'library');
- await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
- await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
-
- const { assets } = await utils.searchAssets(admin.accessToken, {
- originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`,
- libraryId: library.id,
- });
- expect(assets.count).toBe(1);
- });
-
- it('should process metadata and thumbnails for external asset', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/directoryA`],
- });
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, {
- originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`,
- libraryId: library.id,
- });
- expect(assets.count).toBe(1);
- const asset = assets.items[0];
- expect(asset.exifInfo).not.toBe(null);
- expect(asset.exifInfo?.dateTimeOriginal).not.toBe(null);
- expect(asset.thumbhash).not.toBe(null);
- });
-
- it('should scan external library with exclusion pattern', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp`],
- exclusionPatterns: ['**/directoryA/**'],
- });
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.count).toBe(1);
-
- expect(assets.items).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ originalPath: expect.stringContaining('directoryB/assetB.png') }),
- ]),
- );
- });
-
- it('should scan external library with multiple exclusion patterns', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp`],
- exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'],
- });
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.count).toBe(0);
-
- expect(assets.items).toEqual([]);
- });
-
- it('should remove assets covered by a new exclusion pattern', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp`],
- });
-
- await utils.scan(admin.accessToken, library.id);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.count).toBe(2);
-
- expect(assets.items).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ originalPath: expect.stringContaining('directoryA/assetA.png') }),
- expect.objectContaining({ originalPath: expect.stringContaining('directoryB/assetB.png') }),
- ]),
- );
- }
-
- await utils.updateLibrary(admin.accessToken, library.id, {
- exclusionPatterns: ['**/directoryA/**'],
- });
-
- await utils.scan(admin.accessToken, library.id);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.count).toBe(1);
-
- expect(assets.items).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ originalPath: expect.stringContaining('directoryB/assetB.png') }),
- ]),
- );
- }
-
- await utils.updateLibrary(admin.accessToken, library.id, {
- exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'],
- });
-
- await utils.scan(admin.accessToken, library.id);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.count).toBe(0);
-
- expect(assets.items).toEqual([]);
- }
- });
-
- it('should scan multiple import paths', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`],
- });
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.count).toBe(2);
- expect(assets.items.find((asset) => asset.originalPath.includes('directoryA'))).toBeDefined();
- expect(assets.items.find((asset) => asset.originalPath.includes('directoryB'))).toBeDefined();
- });
-
- it('should scan multiple import paths with commas', async () => {
- // https://github.com/immich-app/immich/issues/10699
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/folder, a`, `${testAssetDirInternal}/temp/folder, b`],
- });
-
- utils.createImageFile(`${testAssetDir}/temp/folder, a/assetA.png`);
- utils.createImageFile(`${testAssetDir}/temp/folder, b/assetB.png`);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.count).toBe(2);
- expect(assets.items.find((asset) => asset.originalPath.includes('folder, a'))).toBeDefined();
- expect(assets.items.find((asset) => asset.originalPath.includes('folder, b'))).toBeDefined();
-
- utils.removeImageFile(`${testAssetDir}/temp/folder, a/assetA.png`);
- utils.removeImageFile(`${testAssetDir}/temp/folder, b/assetB.png`);
- });
-
- it('should scan multiple import paths with braces', async () => {
- // https://github.com/immich-app/immich/issues/10699
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/folder{ a`, `${testAssetDirInternal}/temp/folder} b`],
- });
-
- utils.createImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`);
- utils.createImageFile(`${testAssetDir}/temp/folder} b/assetB.png`);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.count).toBe(2);
- expect(assets.items.find((asset) => asset.originalPath.includes('folder{ a'))).toBeDefined();
- expect(assets.items.find((asset) => asset.originalPath.includes('folder} b'))).toBeDefined();
-
- utils.removeImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`);
- utils.removeImageFile(`${testAssetDir}/temp/folder} b/assetB.png`);
- });
-
- const annoyingChars = [
- "'",
- '"',
- '`',
- '*',
- '{',
- '}',
- ',',
- '(',
- ')',
- '[',
- ']',
- '?',
- '!',
- '@',
- '#',
- '$',
- '%',
- '^',
- '&',
- '=',
- '+',
- '~',
- '|',
- '<',
- '>',
- ';',
- ':',
- '/', // We never got backslashes to work
- ];
-
- it.each(annoyingChars)('should scan multiple import paths with %s', async (char) => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/folder${char}1`, `${testAssetDirInternal}/temp/folder${char}2`],
- });
-
- utils.createImageFile(`${testAssetDir}/temp/folder${char}1/asset1.png`);
- utils.createImageFile(`${testAssetDir}/temp/folder${char}2/asset2.png`);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.items).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ originalPath: expect.stringContaining(`folder${char}1/asset1.png`) }),
- expect.objectContaining({ originalPath: expect.stringContaining(`folder${char}2/asset2.png`) }),
- ]),
- );
-
- utils.removeImageFile(`${testAssetDir}/temp/folder${char}1/asset1.png`);
- utils.removeImageFile(`${testAssetDir}/temp/folder${char}2/asset2.png`);
- });
-
- it('should respect exclusion patterns when using multiple import paths', async () => {
- // https://github.com/immich-app/immich/issues/17121
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/exclusion/`, `${testAssetDirInternal}/temp/exclusion2/`],
- });
-
- const excludedFolder = `Raw`;
-
- utils.createImageFile(`${testAssetDir}/temp/exclusion/asset1.png`);
- utils.createImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`);
-
- await utils.scan(admin.accessToken, library.id);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.items).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }),
- expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }),
- ]),
- );
- }
-
- await utils.scan(admin.accessToken, library.id);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.items).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }),
- expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }),
- ]),
- );
- }
-
- await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: [`**/${excludedFolder}/**`] });
- await utils.scan(admin.accessToken, library.id);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.items).toEqual([
- expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }),
- ]);
- }
-
- await utils.scan(admin.accessToken, library.id);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.items).toEqual([
- expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }),
- ]);
- }
-
- utils.removeImageFile(`${testAssetDir}/temp/exclusion/asset1.png`);
- utils.removeImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`);
- });
-
- const annoyingExclusionPatterns = ['@', '#', '$', '%', '^', '&', '='];
-
- it.each(annoyingExclusionPatterns)('should support exclusion patterns with %s', async (char) => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/exclusion/`],
- });
-
- const excludedFolder = `${char}folder`;
-
- utils.createImageFile(`${testAssetDir}/temp/exclusion/asset1.png`);
- utils.createImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`);
-
- await utils.scan(admin.accessToken, library.id);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.items).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }),
- expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }),
- ]),
- );
- }
-
- await utils.scan(admin.accessToken, library.id);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.items).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }),
- expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }),
- ]),
- );
- }
-
- await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: [`**/${excludedFolder}/**`] });
- await utils.scan(admin.accessToken, library.id);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.items).toEqual([
- expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }),
- ]);
- }
-
- await utils.scan(admin.accessToken, library.id);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.items).toEqual([
- expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }),
- ]);
- }
-
- utils.removeImageFile(`${testAssetDir}/temp/exclusion/asset1.png`);
- utils.removeImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`);
- });
-
- it('should reimport a modified file', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/reimport`],
- });
-
- utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
- await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000);
-
- await utils.scan(admin.accessToken, library.id);
-
- cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`);
- await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_001);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, {
- libraryId: library.id,
- });
-
- expect(assets.count).toEqual(1);
-
- const asset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
-
- expect(asset).toEqual(
- expect.objectContaining({
- originalFileName: 'asset.jpg',
- exifInfo: expect.objectContaining({
- model: 'NIKON D750',
- }),
- }),
- );
-
- utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
- });
-
- it('should not reimport a file with unchanged timestamp', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/reimport`],
- });
-
- utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
- await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000);
-
- await utils.scan(admin.accessToken, library.id);
-
- cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`);
- await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, {
- libraryId: library.id,
- });
-
- expect(assets.count).toEqual(1);
-
- const asset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
-
- expect(asset).toEqual(
- expect.objectContaining({
- originalFileName: 'asset.jpg',
- exifInfo: expect.not.objectContaining({
- model: 'NIKON D750',
- }),
- }),
- );
-
- utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
- });
-
- it('should not reimport a modified file more than once', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/reimport`],
- });
-
- utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
- await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000);
-
- await utils.scan(admin.accessToken, library.id);
-
- cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`);
- await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_001);
-
- await utils.scan(admin.accessToken, library.id);
-
- cpSync(`${testAssetDir}/albums/nature/el_torcal_rocks.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`);
- await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_001);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, {
- libraryId: library.id,
- });
-
- expect(assets.count).toEqual(1);
-
- const asset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
-
- expect(asset).toEqual(
- expect.objectContaining({
- originalFileName: 'asset.jpg',
- exifInfo: expect.objectContaining({
- model: 'NIKON D750',
- }),
- }),
- );
-
- utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
- });
-
- it('should set an asset offline if its file is missing', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/offline`],
- });
-
- utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
- expect(assets.count).toBe(1);
-
- utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
-
- await utils.scan(admin.accessToken, library.id);
-
- const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
- expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
- expect(trashedAsset.isOffline).toEqual(true);
-
- const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
- expect(newAssets.items).toEqual([]);
- });
-
- it('should set an asset offline if its file is not in any import path', async () => {
- utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
-
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/offline`],
- });
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
- expect(assets.count).toBe(1);
-
- utils.createDirectory(`${testAssetDir}/temp/another-path/`);
-
- await utils.updateLibrary(admin.accessToken, library.id, {
- importPaths: [`${testAssetDirInternal}/temp/another-path/`],
- });
-
- await utils.scan(admin.accessToken, library.id);
-
- const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
- expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
- expect(trashedAsset.isOffline).toBe(true);
-
- const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(newAssets.items).toEqual([]);
-
- utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
- utils.removeDirectory(`${testAssetDir}/temp/another-path/`);
- });
-
- it('should set an asset offline if its file is covered by an exclusion pattern', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp`],
- });
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, {
- libraryId: library.id,
- originalFileName: 'assetB.png',
- });
- expect(assets.count).toBe(1);
-
- await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/directoryB/**'] });
-
- await utils.scan(admin.accessToken, library.id);
-
- const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
- expect(trashedAsset.isTrashed).toBe(true);
- expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/directoryB/assetB.png`);
- expect(trashedAsset.isOffline).toBe(true);
-
- const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(newAssets.items).toEqual([
- expect.objectContaining({
- originalFileName: 'assetA.png',
- }),
- ]);
- });
-
- it('should not set an asset offline if its file exists, is in an import path, and not covered by an exclusion pattern', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp`],
- });
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets: assetsBefore } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
- expect(assetsBefore.count).toBeGreaterThan(1);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets).toEqual(assetsBefore);
- });
-
- describe('xmp metadata', async () => {
- it('should import metadata from file.xmp', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/xmp`],
- });
-
- cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
- cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(newAssets.items).toEqual([
- expect.objectContaining({
- originalFileName: 'glarus.nef',
- fileCreatedAt: '2000-09-27T12:35:33.000Z',
- }),
- ]);
-
- rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
- });
-
- it('should import metadata from file.ext.xmp', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/xmp`],
- });
-
- cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
- cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(newAssets.items).toEqual([
- expect.objectContaining({
- originalFileName: 'glarus.nef',
- fileCreatedAt: '2000-09-27T12:35:33.000Z',
- }),
- ]);
-
- rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
- });
-
- it('should import metadata in file.ext.xmp before file.xmp if both exist', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/xmp`],
- });
-
- cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
- cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
- cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(newAssets.items).toEqual([
- expect.objectContaining({
- originalFileName: 'glarus.nef',
- fileCreatedAt: '2000-09-27T12:35:33.000Z',
- }),
- ]);
-
- rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
- });
-
- it('should switch from using file.xmp to file.ext.xmp when asset refreshes', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/xmp`],
- });
-
- cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
- cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
- await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
-
- await utils.scan(admin.accessToken, library.id);
-
- cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
- unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`);
- await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(newAssets.items).toEqual([
- expect.objectContaining({
- originalFileName: 'glarus.nef',
- fileCreatedAt: '2010-09-27T12:35:33.000Z',
- }),
- ]);
-
- rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
- });
-
- it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/xmp`],
- });
-
- cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
- await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
-
- await utils.scan(admin.accessToken, library.id);
-
- cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
- await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(newAssets.items).toEqual([
- expect.objectContaining({
- originalFileName: 'glarus.nef',
- fileCreatedAt: '2000-09-27T12:35:33.000Z',
- }),
- ]);
-
- rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
- });
-
- it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/xmp`],
- });
-
- cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
- await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
-
- await utils.scan(admin.accessToken, library.id);
-
- cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
- await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(newAssets.items).toEqual([
- expect.objectContaining({
- originalFileName: 'glarus.nef',
- fileCreatedAt: '2000-09-27T12:35:33.000Z',
- }),
- ]);
-
- rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
- });
-
- it('should switch from using file.ext.xmp to file.xmp when asset refreshes', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/xmp`],
- });
-
- cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
- cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
- await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
-
- await utils.scan(admin.accessToken, library.id);
-
- cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
- unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`);
- await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(newAssets.items).toEqual([
- expect.objectContaining({
- originalFileName: 'glarus.nef',
- fileCreatedAt: '2010-09-27T12:35:33.000Z',
- }),
- ]);
-
- rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
- });
-
- it('should switch from using file.ext.xmp to file metadata', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/xmp`],
- });
-
- cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
- cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
- await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
-
- await utils.scan(admin.accessToken, library.id);
-
- unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`);
- await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(newAssets.items).toEqual([
- expect.objectContaining({
- originalFileName: 'glarus.nef',
- fileCreatedAt: '2010-07-20T17:27:12.000Z',
- }),
- ]);
-
- rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
- });
-
- it('should switch from using file.xmp to file metadata', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/xmp`],
- });
-
- cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
- cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
- await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
-
- await utils.scan(admin.accessToken, library.id);
-
- unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`);
- await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(newAssets.items).toEqual([
- expect.objectContaining({
- originalFileName: 'glarus.nef',
- fileCreatedAt: '2010-07-20T17:27:12.000Z',
- }),
- ]);
-
- rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
- });
- });
-
- it('should set an offline asset to online if its file exists, is in an import path, and not covered by an exclusion pattern', async () => {
- utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
-
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/offline`],
- });
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.count).toBe(1);
-
- utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`);
-
- await utils.scan(admin.accessToken, library.id);
-
- const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
- expect(offlineAsset.isTrashed).toBe(true);
- expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
- expect(offlineAsset.isOffline).toBe(true);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
- expect(assets.count).toBe(1);
- }
-
- utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`);
-
- await utils.scan(admin.accessToken, library.id);
-
- const backOnlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
-
- expect(backOnlineAsset.isTrashed).toBe(false);
- expect(backOnlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
- expect(backOnlineAsset.isOffline).toBe(false);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
- expect(assets.count).toBe(1);
- }
- });
-
- it('should set a trashed offline asset to online but keep it in trash', async () => {
- utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
-
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/offline`],
- });
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- expect(assets.count).toBe(1);
-
- await utils.deleteAssets(admin.accessToken, [assets.items[0].id]);
-
- {
- const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
-
- expect(trashedAsset.isTrashed).toBe(true);
- }
-
- utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`);
-
- await utils.scan(admin.accessToken, library.id);
-
- const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
- expect(offlineAsset.isTrashed).toBe(true);
- expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
- expect(offlineAsset.isOffline).toBe(true);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
- expect(assets.count).toBe(1);
- }
-
- utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`);
-
- await utils.scan(admin.accessToken, library.id);
-
- const backOnlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
-
- expect(backOnlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
- expect(backOnlineAsset.isOffline).toBe(false);
- expect(backOnlineAsset.isTrashed).toBe(true);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
- expect(assets.count).toBe(1);
- }
- });
-
- it('should not set an offline asset to online if its file exists, is not covered by an exclusion pattern, but is outside of all import paths', async () => {
- utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
-
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/offline`],
- });
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
-
- utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`);
-
- await utils.scan(admin.accessToken, library.id);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
- expect(assets.count).toBe(1);
- }
-
- const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
-
- expect(offlineAsset.isTrashed).toBe(true);
- expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
- expect(offlineAsset.isOffline).toBe(true);
-
- utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`);
-
- utils.createDirectory(`${testAssetDir}/temp/another-path/`);
-
- await utils.updateLibrary(admin.accessToken, library.id, {
- importPaths: [`${testAssetDirInternal}/temp/another-path`],
- });
-
- await utils.scan(admin.accessToken, library.id);
-
- const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
-
- expect(stillOfflineAsset.isTrashed).toBe(true);
- expect(stillOfflineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
- expect(stillOfflineAsset.isOffline).toBe(true);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
- expect(assets.count).toBe(1);
- }
-
- utils.removeDirectory(`${testAssetDir}/temp/another-path/`);
- });
-
- it('should not set an offline asset to online if its file exists, is in an import path, but is covered by an exclusion pattern', async () => {
- utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
-
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/offline`],
- });
-
- await utils.scan(admin.accessToken, library.id);
-
- {
- const { assets: assetsBefore } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
- expect(assetsBefore.count).toBe(1);
- }
-
- utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
- expect(assets.count).toBe(1);
-
- const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
-
- expect(offlineAsset.isTrashed).toBe(true);
- expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
- expect(offlineAsset.isOffline).toBe(true);
-
- utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`);
-
- await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
-
- await utils.scan(admin.accessToken, library.id);
-
- const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
-
- expect(stillOfflineAsset.isTrashed).toBe(true);
- expect(stillOfflineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
- expect(stillOfflineAsset.isOffline).toBe(true);
-
- {
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
- expect(assets.count).toBe(1);
- }
- });
- });
-
- describe('POST /libraries/:id/validate', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/validate`).send({});
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should pass with no import paths', async () => {
- const response = await utils.validateLibrary(admin.accessToken, library.id, { importPaths: [] });
- expect(response.importPaths).toEqual([]);
- });
-
- it('should fail if path does not exist', async () => {
- const pathToTest = `${testAssetDirInternal}/does/not/exist`;
-
- const response = await utils.validateLibrary(admin.accessToken, library.id, {
- importPaths: [pathToTest],
- });
-
- expect(response.importPaths?.length).toEqual(1);
- const pathResponse = response?.importPaths?.at(0);
-
- expect(pathResponse).toEqual({
- importPath: pathToTest,
- isValid: false,
- message: `Path does not exist (ENOENT)`,
- });
- });
-
- it("should fail if path isn't absolute", async () => {
- const pathToTest = `relative/path`;
-
- const cwd = process.cwd();
- // Create directory in cwd
- utils.createDirectory(`${cwd}/${pathToTest}`);
-
- const response = await utils.validateLibrary(admin.accessToken, library.id, {
- importPaths: [pathToTest],
- });
-
- utils.removeDirectory(`${cwd}/${pathToTest}`);
-
- expect(response.importPaths?.length).toEqual(1);
- const pathResponse = response?.importPaths?.at(0);
-
- expect(pathResponse).toEqual({
- importPath: pathToTest,
- isValid: false,
- message: expect.stringMatching('Import path must be absolute, try /usr/src/app/relative/path'),
- });
- });
-
- it('should fail if path is a file', async () => {
- const pathToTest = `${testAssetDirInternal}/albums/nature/el_torcal_rocks.jpg`;
-
- const response = await utils.validateLibrary(admin.accessToken, library.id, {
- importPaths: [pathToTest],
- });
-
- expect(response.importPaths?.length).toEqual(1);
- const pathResponse = response?.importPaths?.at(0);
-
- expect(pathResponse).toEqual({
- importPath: pathToTest,
- isValid: false,
- message: `Not a directory`,
- });
- });
- });
-
- describe('DELETE /libraries/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).delete(`/libraries/${uuidDto.notFound}`);
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should delete an external library', async () => {
- const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId });
-
- const { status, body } = await request(app)
- .delete(`/libraries/${library.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(204);
- expect(body).toEqual({});
-
- const libraries = await getAllLibraries({ headers: asBearerAuth(admin.accessToken) });
- expect(libraries).not.toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- id: library.id,
- }),
- ]),
- );
- });
-
- it('should delete an external library with assets', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp`],
- });
-
- await utils.scan(admin.accessToken, library.id);
-
- const { status, body } = await request(app)
- .delete(`/libraries/${library.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(204);
- expect(body).toEqual({});
-
- const libraries = await getAllLibraries({ headers: asBearerAuth(admin.accessToken) });
- expect(libraries).not.toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- id: library.id,
- }),
- ]),
- );
-
- // ensure no files get deleted
- expect(existsSync(`${testAssetDir}/temp/directoryA/assetA.png`)).toBe(true);
- expect(existsSync(`${testAssetDir}/temp/directoryB/assetB.png`)).toBe(true);
- });
- });
-});
diff --git a/e2e/src/api/specs/maintenance.e2e-spec.ts b/e2e/src/api/specs/maintenance.e2e-spec.ts
deleted file mode 100644
index 8e4e154328..0000000000
--- a/e2e/src/api/specs/maintenance.e2e-spec.ts
+++ /dev/null
@@ -1,199 +0,0 @@
-import { LoginResponseDto } from '@immich/sdk';
-import { createUserDto } from 'src/fixtures';
-import { errorDto } from 'src/responses';
-import { app, utils } from 'src/utils';
-import request from 'supertest';
-import { beforeAll, describe, expect, it } from 'vitest';
-
-describe('/admin/maintenance', () => {
- let cookie: string | undefined;
- let admin: LoginResponseDto;
- let nonAdmin: LoginResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
- admin = await utils.adminSetup();
- nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
- await utils.resetBackups(admin.accessToken);
- });
-
- // => outside of maintenance mode
-
- describe('GET ~/server/config', async () => {
- it('should indicate we are out of maintenance mode', async () => {
- const { status, body } = await request(app).get('/server/config');
- expect(status).toBe(200);
- expect(body.maintenanceMode).toBeFalsy();
- });
- });
-
- describe('GET /status', async () => {
- it('to always indicate we are not in maintenance mode', async () => {
- const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
- expect(status).toBe(200);
- expect(body).toEqual({
- active: false,
- action: 'end',
- });
- });
- });
-
- describe('POST /login', async () => {
- it('should not work out of maintenance mode', async () => {
- const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('Not in maintenance mode'));
- });
- });
-
- // => enter maintenance mode
-
- describe.sequential('POST /', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).post('/admin/maintenance').send({
- active: false,
- action: 'end',
- });
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should only work for admins', async () => {
- const { status, body } = await request(app)
- .post('/admin/maintenance')
- .set('Authorization', `Bearer ${nonAdmin.accessToken}`)
- .send({ action: 'end' });
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.forbidden);
- });
-
- it('should be a no-op if try to exit maintenance mode', async () => {
- const { status } = await request(app)
- .post('/admin/maintenance')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ action: 'end' });
- expect(status).toBe(201);
- });
-
- it('should enter maintenance mode', async () => {
- const { status, headers } = await request(app)
- .post('/admin/maintenance')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({
- action: 'start',
- });
-
- expect(status).toBe(201);
-
- cookie = headers['set-cookie'][0].split(';')[0];
- expect(cookie).toEqual(
- expect.stringMatching(/^immich_maintenance_token=[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*$/),
- );
-
- await expect
- .poll(
- async () => {
- const { status, body } = await request(app).get('/server/config');
- expect(status).toBe(200);
- return body.maintenanceMode;
- },
- {
- interval: 500,
- timeout: 10_000,
- },
- )
- .toBeTruthy();
- });
- });
-
- // => in maintenance mode
-
- describe.sequential('in maintenance mode', () => {
- describe('GET ~/server/config', async () => {
- it('should indicate we are in maintenance mode', async () => {
- const { status, body } = await request(app).get('/server/config');
- expect(status).toBe(200);
- expect(body.maintenanceMode).toBeTruthy();
- });
- });
-
- describe('GET /status', async () => {
- it('to indicate we are in maintenance mode', async () => {
- const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
- expect(status).toBe(200);
- expect(body).toEqual({
- active: true,
- action: 'start',
- });
- });
- });
-
- describe('POST /login', async () => {
- it('should fail without cookie or token in body', async () => {
- const { status, body } = await request(app).post('/admin/maintenance/login').send({});
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorizedWithMessage('Missing JWT Token'));
- });
-
- it('should succeed with cookie', async () => {
- const { status, body } = await request(app).post('/admin/maintenance/login').set('cookie', cookie!).send({});
- expect(status).toBe(201);
- expect(body).toEqual(
- expect.objectContaining({
- username: 'Immich Admin',
- }),
- );
- });
-
- it('should succeed with token', async () => {
- const { status, body } = await request(app)
- .post('/admin/maintenance/login')
- .send({
- token: cookie!.split('=')[1].trim(),
- });
- expect(status).toBe(201);
- expect(body).toEqual(
- expect.objectContaining({
- username: 'Immich Admin',
- }),
- );
- });
- });
-
- describe('POST /', async () => {
- it('should be a no-op if try to enter maintenance mode', async () => {
- const { status } = await request(app)
- .post('/admin/maintenance')
- .set('cookie', cookie!)
- .send({ action: 'start' });
- expect(status).toBe(201);
- });
- });
- });
-
- // => exit maintenance mode
-
- describe.sequential('POST /', () => {
- it('should exit maintenance mode', async () => {
- const { status } = await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
- action: 'end',
- });
-
- expect(status).toBe(201);
-
- await expect
- .poll(
- async () => {
- const { status, body } = await request(app).get('/server/config');
- expect(status).toBe(200);
- return body.maintenanceMode;
- },
- {
- interval: 500,
- timeout: 10_000,
- },
- )
- .toBeFalsy();
- });
- });
-});
diff --git a/e2e/src/api/specs/map.e2e-spec.ts b/e2e/src/api/specs/map.e2e-spec.ts
deleted file mode 100644
index 977638aa24..0000000000
--- a/e2e/src/api/specs/map.e2e-spec.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-import { AssetVisibility, LoginResponseDto } from '@immich/sdk';
-import { readFile } from 'node:fs/promises';
-import { basename, join } from 'node:path';
-import { Socket } from 'socket.io-client';
-import { errorDto } from 'src/responses';
-import { app, testAssetDir, utils } from 'src/utils';
-import request from 'supertest';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
-
-describe('/map', () => {
- let websocket: Socket;
- let admin: LoginResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
- admin = await utils.adminSetup({ onboarding: false });
-
- websocket = await utils.connectWebsocket(admin.accessToken);
-
- const files = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg'];
- utils.resetEvents();
- const uploadFile = async (input: string) => {
- const filepath = join(testAssetDir, input);
- const { id } = await utils.createAsset(admin.accessToken, {
- assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
- });
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id });
- };
- await Promise.all(files.map((f) => uploadFile(f)));
- });
-
- afterAll(() => {
- utils.disconnectWebsocket(websocket);
- });
-
- describe('GET /map/markers', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).get('/map/markers');
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- // TODO archive one of these assets
- it('should get map markers for all non-archived assets', async () => {
- const { status, body } = await request(app)
- .get('/map/markers')
- .query({ visibility: AssetVisibility.Timeline })
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toHaveLength(2);
- expect(body).toEqual([
- {
- city: 'Palisade',
- country: 'United States of America',
- id: expect.any(String),
- lat: expect.closeTo(39.115),
- lon: expect.closeTo(-108.400_968),
- state: 'Colorado',
- },
- {
- city: 'Ralston',
- country: 'United States of America',
- id: expect.any(String),
- lat: expect.closeTo(41.2203),
- lon: expect.closeTo(-96.071_625),
- state: 'Nebraska',
- },
- ]);
- });
-
- // TODO archive one of these assets
- it('should get all map markers', async () => {
- const { status, body } = await request(app)
- .get('/map/markers')
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual([
- {
- city: 'Palisade',
- country: 'United States of America',
- id: expect.any(String),
- lat: expect.closeTo(39.115),
- lon: expect.closeTo(-108.400_968),
- state: 'Colorado',
- },
- {
- city: 'Ralston',
- country: 'United States of America',
- id: expect.any(String),
- lat: expect.closeTo(41.2203),
- lon: expect.closeTo(-96.071_625),
- state: 'Nebraska',
- },
- ]);
- });
- });
-
- describe('GET /map/reverse-geocode', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).get('/map/reverse-geocode');
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should throw an error if a lat is not provided', async () => {
- const { status, body } = await request(app)
- .get('/map/reverse-geocode?lon=123')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
- });
-
- it('should throw an error if a lat is not a number', async () => {
- const { status, body } = await request(app)
- .get('/map/reverse-geocode?lat=abc&lon=123.456')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
- });
-
- it('should throw an error if a lat is out of range', async () => {
- const { status, body } = await request(app)
- .get('/map/reverse-geocode?lat=91&lon=123.456')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
- });
-
- it('should throw an error if a lon is not provided', async () => {
- const { status, body } = await request(app)
- .get('/map/reverse-geocode?lat=75')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest(['lon must be a number between -180 and 180']));
- });
-
- const reverseGeocodeTestCases = [
- {
- name: 'Vaucluse',
- lat: -33.858_977_058_663_13,
- lon: 151.278_490_730_270_48,
- results: [{ city: 'Vaucluse', state: 'New South Wales', country: 'Australia' }],
- },
- {
- name: 'Ravenhall',
- lat: -37.765_732_399_174_75,
- lon: 144.752_453_164_883_3,
- results: [{ city: 'Ravenhall', state: 'Victoria', country: 'Australia' }],
- },
- {
- name: 'Scarborough',
- lat: -31.894_346_156_789_997,
- lon: 115.757_617_103_904_64,
- results: [{ city: 'Scarborough', state: 'Western Australia', country: 'Australia' }],
- },
- ];
-
- it.each(reverseGeocodeTestCases)(`should resolve to $name`, async ({ lat, lon, results }) => {
- const { status, body } = await request(app)
- .get(`/map/reverse-geocode?lat=${lat}&lon=${lon}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(200);
- expect(Array.isArray(body)).toBe(true);
- expect(body.length).toBe(results.length);
- expect(body).toEqual(results);
- });
- });
-});
diff --git a/e2e/src/api/specs/memory.e2e-spec.ts b/e2e/src/api/specs/memory.e2e-spec.ts
deleted file mode 100644
index e5e2351738..0000000000
--- a/e2e/src/api/specs/memory.e2e-spec.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-import {
- AssetMediaResponseDto,
- LoginResponseDto,
- MemoryResponseDto,
- MemoryType,
- createMemory,
- getMemory,
-} from '@immich/sdk';
-import { createUserDto } from 'src/fixtures';
-import { errorDto } from 'src/responses';
-import { app, asBearerAuth, utils } from 'src/utils';
-import request from 'supertest';
-import { beforeAll, describe, expect, it } from 'vitest';
-
-describe('/memories', () => {
- let admin: LoginResponseDto;
- let user: LoginResponseDto;
- let adminAsset: AssetMediaResponseDto;
- let userAsset1: AssetMediaResponseDto;
- let userMemory: MemoryResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
-
- admin = await utils.adminSetup();
- user = await utils.userSetup(admin.accessToken, createUserDto.user1);
- [adminAsset, userAsset1] = await Promise.all([
- utils.createAsset(admin.accessToken),
- utils.createAsset(user.accessToken),
- ]);
- userMemory = await createMemory(
- {
- memoryCreateDto: {
- type: MemoryType.OnThisDay,
- memoryAt: new Date(2021).toISOString(),
- data: { year: 2021 },
- assetIds: [],
- },
- },
- { headers: asBearerAuth(user.accessToken) },
- );
- });
-
- describe('GET /memories/:id', () => {
- it('should require access', async () => {
- const { status, body } = await request(app)
- .get(`/memories/${userMemory.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.noPermission);
- });
-
- it('should get the memory', async () => {
- const { status, body } = await request(app)
- .get(`/memories/${userMemory.id}`)
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(200);
- expect(body).toMatchObject({ id: userMemory.id });
- });
- });
-
- describe('PUT /memories/:id', () => {
- it('should require access', async () => {
- const { status, body } = await request(app)
- .put(`/memories/${userMemory.id}`)
- .send({ isSaved: true })
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.noPermission);
- });
-
- it('should update the memory', async () => {
- const before = await getMemory({ id: userMemory.id }, { headers: asBearerAuth(user.accessToken) });
- expect(before.isSaved).toBe(false);
-
- const { status, body } = await request(app)
- .put(`/memories/${userMemory.id}`)
- .send({ isSaved: true })
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(200);
- expect(body).toMatchObject({
- id: userMemory.id,
- isSaved: true,
- });
- });
- });
-
- describe('PUT /memories/:id/assets', () => {
- it('should require access', async () => {
- const { status, body } = await request(app)
- .put(`/memories/${userMemory.id}/assets`)
- .send({ ids: [userAsset1.id] })
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.noPermission);
- });
-
- it('should require asset access', async () => {
- const { status, body } = await request(app)
- .put(`/memories/${userMemory.id}/assets`)
- .send({ ids: [adminAsset.id] })
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(200);
- expect(body).toHaveLength(1);
- expect(body[0]).toEqual({
- id: adminAsset.id,
- success: false,
- error: 'no_permission',
- });
- });
-
- it('should add assets to the memory', async () => {
- const { status, body } = await request(app)
- .put(`/memories/${userMemory.id}/assets`)
- .send({ ids: [userAsset1.id] })
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(200);
- expect(body).toHaveLength(1);
- expect(body[0]).toEqual({ id: userAsset1.id, success: true });
- });
- });
-
- describe('DELETE /memories/:id/assets', () => {
- it('should require access', async () => {
- const { status, body } = await request(app)
- .delete(`/memories/${userMemory.id}/assets`)
- .send({ ids: [userAsset1.id] })
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.noPermission);
- });
-
- it('should only remove assets in the memory', async () => {
- const { status, body } = await request(app)
- .delete(`/memories/${userMemory.id}/assets`)
- .send({ ids: [adminAsset.id] })
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(200);
- expect(body).toHaveLength(1);
- expect(body[0]).toEqual({
- id: adminAsset.id,
- success: false,
- error: 'not_found',
- });
- });
-
- it('should remove assets from the memory', async () => {
- const { status, body } = await request(app)
- .delete(`/memories/${userMemory.id}/assets`)
- .send({ ids: [userAsset1.id] })
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(200);
- expect(body).toHaveLength(1);
- expect(body[0]).toEqual({ id: userAsset1.id, success: true });
- });
- });
-
- describe('DELETE /memories/:id', () => {
- it('should require access', async () => {
- const { status, body } = await request(app)
- .delete(`/memories/${userMemory.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.noPermission);
- });
-
- it('should delete the memory', async () => {
- const { status } = await request(app)
- .delete(`/memories/${userMemory.id}`)
- .send({ isSaved: true })
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(204);
- });
- });
-});
diff --git a/e2e/src/api/specs/oauth.e2e-spec.ts b/e2e/src/api/specs/oauth.e2e-spec.ts
deleted file mode 100644
index cbd68c003a..0000000000
--- a/e2e/src/api/specs/oauth.e2e-spec.ts
+++ /dev/null
@@ -1,383 +0,0 @@
-import { OAuthClient, OAuthUser } from '@immich/e2e-auth-server';
-import {
- LoginResponseDto,
- SystemConfigOAuthDto,
- getConfigDefaults,
- getMyUser,
- startOAuth,
- updateConfig,
-} from '@immich/sdk';
-import { createHash, randomBytes } from 'node:crypto';
-import { errorDto } from 'src/responses';
-import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
-import request from 'supertest';
-import { beforeAll, describe, expect, it } from 'vitest';
-
-const authServer = {
- internal: 'http://e2e-auth-server:2286',
- external: 'http://127.0.0.1:2286',
-};
-
-const mobileOverrideRedirectUri = 'https://photos.immich.app/oauth/mobile-redirect';
-
-const redirect = async (url: string, cookies?: string[]) => {
- const { headers } = await request(url)
- .get('')
- .set('Cookie', cookies || []);
- return { cookies: (headers['set-cookie'] as unknown as string[]) || [], location: headers.location };
-};
-
-// Function to generate a code challenge from the verifier
-const generateCodeChallenge = async (codeVerifier: string): Promise => {
- const hashed = createHash('sha256').update(codeVerifier).digest();
- return hashed.toString('base64url');
-};
-
-const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) => {
- const state = randomBytes(16).toString('base64url');
- const codeVerifier = randomBytes(64).toString('base64url');
- const codeChallenge = await generateCodeChallenge(codeVerifier);
-
- const { url } = await startOAuth({
- oAuthConfigDto: { redirectUri: redirectUri ?? `${baseUrl}/auth/login`, state, codeChallenge },
- });
-
- // login
- const response1 = await redirect(url.replace(authServer.internal, authServer.external));
- const response2 = await request(authServer.external + response1.location)
- .post('')
- .set('Cookie', response1.cookies)
- .type('form')
- .send({ prompt: 'login', login: sub, password: 'password' });
-
- // approve
- const response3 = await redirect(response2.header.location, response1.cookies);
- const response4 = await request(authServer.external + response3.location)
- .post('')
- .type('form')
- .set('Cookie', response3.cookies)
- .send({ prompt: 'consent' });
-
- const response5 = await redirect(response4.header.location, response3.cookies.slice(1));
- const redirectUrl = response5.location;
-
- expect(redirectUrl).toBeDefined();
- const params = new URL(redirectUrl).searchParams;
- expect(params.get('code')).toBeDefined();
- expect(params.get('state')).toBe(state);
-
- return { url: redirectUrl, state, codeVerifier };
-};
-
-const setupOAuth = async (token: string, dto: Partial) => {
- const options = { headers: asBearerAuth(token) };
- const defaults = await getConfigDefaults(options);
- const merged = {
- ...defaults.oauth,
- buttonText: 'Login with Immich',
- issuerUrl: `${authServer.internal}/.well-known/openid-configuration`,
- ...dto,
- };
- await updateConfig({ systemConfigDto: { ...defaults, oauth: merged } }, options);
-};
-
-describe(`/oauth`, () => {
- let admin: LoginResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
- admin = await utils.adminSetup();
-
- await setupOAuth(admin.accessToken, {
- enabled: true,
- clientId: OAuthClient.DEFAULT,
- clientSecret: OAuthClient.DEFAULT,
- buttonText: 'Login with Immich',
- storageLabelClaim: 'immich_username',
- });
- });
-
- describe('POST /oauth/authorize', () => {
- it(`should throw an error if a redirect uri is not provided`, async () => {
- const { status, body } = await request(app).post('/oauth/authorize').send({});
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest(['redirectUri must be a string', 'redirectUri should not be empty']));
- });
-
- it('should return a redirect uri', async () => {
- const { status, body } = await request(app)
- .post('/oauth/authorize')
- .send({ redirectUri: 'http://127.0.0.1:2285/auth/login' });
- expect(status).toBe(201);
- expect(body).toEqual({ url: expect.stringContaining(`${authServer.internal}/auth?`) });
-
- const params = new URL(body.url).searchParams;
- expect(params.get('client_id')).toBe('client-default');
- expect(params.get('response_type')).toBe('code');
- expect(params.get('redirect_uri')).toBe('http://127.0.0.1:2285/auth/login');
- expect(params.get('state')).toBeDefined();
- });
- });
-
- describe('POST /oauth/callback', () => {
- it(`should throw an error if a url is not provided`, async () => {
- const { status, body } = await request(app).post('/oauth/callback').send({});
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest(['url must be a string', 'url should not be empty']));
- });
-
- it(`should throw an error if the url is empty`, async () => {
- const { status, body } = await request(app).post('/oauth/callback').send({ url: '' });
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest(['url should not be empty']));
- });
-
- it(`should throw an error if the state is not provided`, async () => {
- const { url } = await loginWithOAuth('oauth-auto-register');
- const { status, body } = await request(app).post('/oauth/callback').send({ url });
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('OAuth state is missing'));
- });
-
- it(`should throw an error if the state mismatches`, async () => {
- const callbackParams = await loginWithOAuth('oauth-auto-register');
- const { state } = await loginWithOAuth('oauth-auto-register');
- const { status } = await request(app)
- .post('/oauth/callback')
- .send({ ...callbackParams, state });
- expect(status).toBeGreaterThanOrEqual(400);
- });
-
- it(`should throw an error if the codeVerifier is not provided`, async () => {
- const { url, state } = await loginWithOAuth('oauth-auto-register');
- const { status, body } = await request(app).post('/oauth/callback').send({ url, state });
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('OAuth code verifier is missing'));
- });
-
- it(`should throw an error if the codeVerifier doesn't match the challenge`, async () => {
- const callbackParams = await loginWithOAuth('oauth-auto-register');
- const { codeVerifier } = await loginWithOAuth('oauth-auto-register');
- const { status, body } = await request(app)
- .post('/oauth/callback')
- .send({ ...callbackParams, codeVerifier });
- console.log(body);
- expect(status).toBeGreaterThanOrEqual(400);
- });
-
- it('should auto register the user by default', async () => {
- const callbackParams = await loginWithOAuth('oauth-auto-register');
- const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
- expect(status).toBe(201);
- expect(body).toMatchObject({
- accessToken: expect.any(String),
- isAdmin: false,
- name: 'OAuth User',
- userEmail: 'oauth-auto-register@immich.app',
- userId: expect.any(String),
- });
- });
-
- it('should allow passing state and codeVerifier via cookies', async () => {
- const { url, state, codeVerifier } = await loginWithOAuth('oauth-auto-register');
- const { status, body } = await request(app)
- .post('/oauth/callback')
- .set('Cookie', [`immich_oauth_state=${state}`, `immich_oauth_code_verifier=${codeVerifier}`])
- .send({ url });
- expect(status).toBe(201);
- expect(body).toMatchObject({
- accessToken: expect.any(String),
- userId: expect.any(String),
- userEmail: 'oauth-auto-register@immich.app',
- });
- });
-
- it('should handle a user without an email', async () => {
- const callbackParams = await loginWithOAuth(OAuthUser.NO_EMAIL);
- const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('OAuth profile does not have an email address'));
- });
-
- it('should set the quota from a claim', async () => {
- const callbackParams = await loginWithOAuth(OAuthUser.WITH_QUOTA);
- const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
- expect(status).toBe(201);
- expect(body).toMatchObject({
- accessToken: expect.any(String),
- userId: expect.any(String),
- userEmail: 'oauth-with-quota@immich.app',
- });
-
- const user = await getMyUser({ headers: asBearerAuth(body.accessToken) });
- expect(user.quotaSizeInBytes).toBe(25 * 2 ** 30); // 25 GiB;
- });
-
- it('should set the storage label from a claim', async () => {
- const callbackParams = await loginWithOAuth(OAuthUser.WITH_USERNAME);
- const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
- expect(status).toBe(201);
- expect(body).toMatchObject({
- accessToken: expect.any(String),
- userId: expect.any(String),
- userEmail: 'oauth-with-username@immich.app',
- });
-
- const user = await getMyUser({ headers: asBearerAuth(body.accessToken) });
- expect(user.storageLabel).toBe('user-username');
- });
-
- it('should set the admin status from a role claim', async () => {
- const callbackParams = await loginWithOAuth(OAuthUser.WITH_ROLE);
- const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
- expect(status).toBe(201);
- expect(body).toMatchObject({
- accessToken: expect.any(String),
- userId: expect.any(String),
- userEmail: 'oauth-with-role@immich.app',
- isAdmin: true,
- });
-
- const user = await getMyUser({ headers: asBearerAuth(body.accessToken) });
- expect(user.isAdmin).toBe(true);
- });
-
- it('should work with RS256 signed tokens', async () => {
- await setupOAuth(admin.accessToken, {
- enabled: true,
- clientId: OAuthClient.RS256_TOKENS,
- clientSecret: OAuthClient.RS256_TOKENS,
- autoRegister: true,
- buttonText: 'Login with Immich',
- signingAlgorithm: 'RS256',
- });
- const callbackParams = await loginWithOAuth('oauth-RS256-token');
- const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
- expect(status).toBe(201);
- expect(body).toMatchObject({
- accessToken: expect.any(String),
- isAdmin: false,
- name: 'OAuth User',
- userEmail: 'oauth-RS256-token@immich.app',
- userId: expect.any(String),
- });
- });
-
- it('should work with RS256 signed user profiles', async () => {
- await setupOAuth(admin.accessToken, {
- enabled: true,
- clientId: OAuthClient.RS256_PROFILE,
- clientSecret: OAuthClient.RS256_PROFILE,
- buttonText: 'Login with Immich',
- profileSigningAlgorithm: 'RS256',
- });
- const callbackParams = await loginWithOAuth('oauth-signed-profile');
- const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
- expect(status).toBe(201);
- expect(body).toMatchObject({
- userId: expect.any(String),
- userEmail: 'oauth-signed-profile@immich.app',
- });
- });
-
- it('should throw an error for an invalid token algorithm', async () => {
- await setupOAuth(admin.accessToken, {
- enabled: true,
- clientId: OAuthClient.DEFAULT,
- clientSecret: OAuthClient.DEFAULT,
- buttonText: 'Login with Immich',
- signingAlgorithm: 'something-that-does-not-work',
- });
- const callbackParams = await loginWithOAuth('oauth-signed-bad');
- const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
- expect(status).toBe(500);
- expect(body).toMatchObject({
- error: 'Internal Server Error',
- message: 'Failed to finish oauth',
- statusCode: 500,
- });
- });
-
- describe('autoRegister: false', () => {
- beforeAll(async () => {
- await setupOAuth(admin.accessToken, {
- enabled: true,
- clientId: OAuthClient.DEFAULT,
- clientSecret: OAuthClient.DEFAULT,
- autoRegister: false,
- buttonText: 'Login with Immich',
- });
- });
-
- it('should not auto register the user', async () => {
- const callbackParams = await loginWithOAuth('oauth-no-auto-register');
- const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('User does not exist and auto registering is disabled.'));
- });
-
- it('should link to an existing user by email', async () => {
- const { userId } = await utils.userSetup(admin.accessToken, {
- name: 'OAuth User 3',
- email: 'oauth-user3@immich.app',
- password: 'password',
- });
- const callbackParams = await loginWithOAuth('oauth-user3');
- const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
- expect(status).toBe(201);
- expect(body).toMatchObject({
- userId,
- userEmail: 'oauth-user3@immich.app',
- });
- });
- });
- });
-
- describe('mobile redirect override', () => {
- beforeAll(async () => {
- await setupOAuth(admin.accessToken, {
- enabled: true,
- clientId: OAuthClient.DEFAULT,
- clientSecret: OAuthClient.DEFAULT,
- buttonText: 'Login with Immich',
- storageLabelClaim: 'immich_username',
- mobileOverrideEnabled: true,
- mobileRedirectUri: mobileOverrideRedirectUri,
- });
- });
-
- it('should return the mobile redirect uri', async () => {
- const { status, body } = await request(app)
- .post('/oauth/authorize')
- .send({ redirectUri: 'app.immich:///oauth-callback' });
- expect(status).toBe(201);
- expect(body).toEqual({ url: expect.stringContaining(`${authServer.internal}/auth?`) });
-
- const params = new URL(body.url).searchParams;
- expect(params.get('client_id')).toBe('client-default');
- expect(params.get('response_type')).toBe('code');
- expect(params.get('redirect_uri')).toBe(mobileOverrideRedirectUri);
- expect(params.get('state')).toBeDefined();
- });
-
- it('should auto register the user by default', async () => {
- const callbackParams = await loginWithOAuth('oauth-mobile-override', 'app.immich:///oauth-callback');
- expect(callbackParams.url).toEqual(expect.stringContaining(mobileOverrideRedirectUri));
-
- // simulate redirecting back to mobile app
- const url = callbackParams.url.replace(mobileOverrideRedirectUri, 'app.immich:///oauth-callback');
-
- const { status, body } = await request(app)
- .post('/oauth/callback')
- .send({ ...callbackParams, url });
- expect(status).toBe(201);
- expect(body).toMatchObject({
- accessToken: expect.any(String),
- isAdmin: false,
- name: 'OAuth User',
- userEmail: 'oauth-mobile-override@immich.app',
- userId: expect.any(String),
- });
- });
- });
-});
diff --git a/e2e/src/api/specs/partner.e2e-spec.ts b/e2e/src/api/specs/partner.e2e-spec.ts
deleted file mode 100644
index 9047a97055..0000000000
--- a/e2e/src/api/specs/partner.e2e-spec.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { LoginResponseDto, createPartner } from '@immich/sdk';
-import { createUserDto } from 'src/fixtures';
-import { errorDto } from 'src/responses';
-import { app, asBearerAuth, utils } from 'src/utils';
-import request from 'supertest';
-import { beforeAll, describe, expect, it } from 'vitest';
-
-describe('/partners', () => {
- let admin: LoginResponseDto;
- let user1: LoginResponseDto;
- let user2: LoginResponseDto;
- let user3: LoginResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
-
- admin = await utils.adminSetup();
-
- [user1, user2, user3] = await Promise.all([
- utils.userSetup(admin.accessToken, createUserDto.user1),
- utils.userSetup(admin.accessToken, createUserDto.user2),
- utils.userSetup(admin.accessToken, createUserDto.user3),
- ]);
-
- await Promise.all([
- createPartner({ partnerCreateDto: { sharedWithId: user2.userId } }, { headers: asBearerAuth(user1.accessToken) }),
- createPartner({ partnerCreateDto: { sharedWithId: user1.userId } }, { headers: asBearerAuth(user2.accessToken) }),
- ]);
- });
-
- describe('GET /partners', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).get('/partners');
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should get all partners shared by user', async () => {
- const { status, body } = await request(app)
- .get('/partners')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .query({ direction: 'shared-by' });
-
- expect(status).toBe(200);
- expect(body).toEqual([expect.objectContaining({ id: user2.userId })]);
- });
-
- it('should get all partners that share with user', async () => {
- const { status, body } = await request(app)
- .get('/partners')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .query({ direction: 'shared-with' });
-
- expect(status).toBe(200);
- expect(body).toEqual([expect.objectContaining({ id: user2.userId })]);
- });
- });
-
- describe('POST /partners/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).post(`/partners/${user3.userId}`);
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should share with new partner', async () => {
- const { status, body } = await request(app)
- .post(`/partners/${user3.userId}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(201);
- expect(body).toEqual(expect.objectContaining({ id: user3.userId }));
- });
-
- it('should not share with new partner if already sharing with this partner', async () => {
- const { status, body } = await request(app)
- .post(`/partners/${user2.userId}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(400);
- expect(body).toEqual(expect.objectContaining({ message: 'Partner already exists' }));
- });
- });
-
- describe('PUT /partners/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).put(`/partners/${user2.userId}`);
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should update partner', async () => {
- const { status, body } = await request(app)
- .put(`/partners/${user2.userId}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ inTimeline: false });
-
- expect(status).toBe(200);
- expect(body).toEqual(expect.objectContaining({ id: user2.userId, inTimeline: false }));
- });
- });
-
- describe('DELETE /partners/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).delete(`/partners/${user3.userId}`);
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should delete partner', async () => {
- const { status } = await request(app)
- .delete(`/partners/${user3.userId}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(204);
- });
-
- it('should throw a bad request if partner not found', async () => {
- const { status, body } = await request(app)
- .delete(`/partners/${user3.userId}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(400);
- expect(body).toEqual(expect.objectContaining({ message: 'Partner not found' }));
- });
- });
-});
diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts
deleted file mode 100644
index 20290cd941..0000000000
--- a/e2e/src/api/specs/person.e2e-spec.ts
+++ /dev/null
@@ -1,346 +0,0 @@
-import { getPerson, LoginResponseDto, PersonResponseDto } from '@immich/sdk';
-import { uuidDto } from 'src/fixtures';
-import { errorDto } from 'src/responses';
-import { app, asBearerAuth, utils } from 'src/utils';
-import request from 'supertest';
-import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
-
-describe('/people', () => {
- let admin: LoginResponseDto;
- let visiblePerson: PersonResponseDto;
- let hiddenPerson: PersonResponseDto;
- let multipleAssetsPerson: PersonResponseDto;
-
- let nameAlicePerson: PersonResponseDto;
- let nameBobPerson: PersonResponseDto;
- let nameCharliePerson: PersonResponseDto;
- let nameNullPerson4Assets: PersonResponseDto;
- let nameNullPerson3Assets: PersonResponseDto;
- let nameNullPerson1Asset: PersonResponseDto;
- let nameBillPersonFavourite: PersonResponseDto;
- let nameFreddyPersonFavourite: PersonResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
- admin = await utils.adminSetup();
-
- [
- visiblePerson,
- hiddenPerson,
- multipleAssetsPerson,
- nameCharliePerson,
- nameBobPerson,
- nameAlicePerson,
- nameNullPerson4Assets,
- nameNullPerson3Assets,
- nameNullPerson1Asset,
- nameBillPersonFavourite,
- nameFreddyPersonFavourite,
- ] = await Promise.all([
- utils.createPerson(admin.accessToken, {
- name: 'visible_person',
- }),
- utils.createPerson(admin.accessToken, {
- name: 'hidden_person',
- isHidden: true,
- }),
- utils.createPerson(admin.accessToken, {
- name: 'multiple_assets_person',
- }),
- // --- Setup for the specific sorting test ---
- utils.createPerson(admin.accessToken, {
- name: 'Charlie',
- }),
- utils.createPerson(admin.accessToken, {
- name: 'Bob',
- }),
- utils.createPerson(admin.accessToken, {
- name: 'Alice',
- }),
- utils.createPerson(admin.accessToken, {
- name: '',
- }),
- utils.createPerson(admin.accessToken, {
- name: '',
- }),
- utils.createPerson(admin.accessToken, {
- name: '',
- }),
- utils.createPerson(admin.accessToken, {
- name: 'Bill',
- isFavorite: true,
- }),
- utils.createPerson(admin.accessToken, {
- name: 'Freddy',
- isFavorite: true,
- }),
- ]);
-
- const asset1 = await utils.createAsset(admin.accessToken);
- const asset2 = await utils.createAsset(admin.accessToken);
- const asset3 = await utils.createAsset(admin.accessToken);
- const asset4 = await utils.createAsset(admin.accessToken);
-
- await Promise.all([
- utils.createFace({ assetId: asset1.id, personId: visiblePerson.id }),
- utils.createFace({ assetId: asset1.id, personId: hiddenPerson.id }),
- utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
- utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
- utils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }),
- utils.createFace({ assetId: asset3.id, personId: multipleAssetsPerson.id }), // 4 assets
- // Named persons
- utils.createFace({ assetId: asset1.id, personId: nameCharliePerson.id }), // 1 asset
- utils.createFace({ assetId: asset1.id, personId: nameBobPerson.id }),
- utils.createFace({ assetId: asset2.id, personId: nameBobPerson.id }), // 2 assets
- utils.createFace({ assetId: asset1.id, personId: nameAlicePerson.id }), // 1 asset
- // Null-named person 4 assets
- utils.createFace({ assetId: asset1.id, personId: nameNullPerson4Assets.id }),
- utils.createFace({ assetId: asset2.id, personId: nameNullPerson4Assets.id }),
- utils.createFace({ assetId: asset3.id, personId: nameNullPerson4Assets.id }),
- utils.createFace({ assetId: asset4.id, personId: nameNullPerson4Assets.id }), // 4 assets
- // Null-named person 3 assets
- utils.createFace({ assetId: asset1.id, personId: nameNullPerson3Assets.id }),
- utils.createFace({ assetId: asset2.id, personId: nameNullPerson3Assets.id }),
- utils.createFace({ assetId: asset3.id, personId: nameNullPerson3Assets.id }), // 3 assets
- // Null-named person 1 asset
- utils.createFace({ assetId: asset3.id, personId: nameNullPerson1Asset.id }),
- // Favourite People
- utils.createFace({ assetId: asset1.id, personId: nameFreddyPersonFavourite.id }),
- utils.createFace({ assetId: asset2.id, personId: nameFreddyPersonFavourite.id }),
- utils.createFace({ assetId: asset1.id, personId: nameBillPersonFavourite.id }),
- ]);
- });
-
- describe('GET /people', () => {
- beforeEach(async () => {});
- it('should return all people (including hidden)', async () => {
- const { status, body } = await request(app)
- .get('/people')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .query({ withHidden: true });
-
- expect(status).toBe(200);
- expect(body).toEqual({
- hasNextPage: false,
- total: 11,
- hidden: 1,
- people: [
- expect.objectContaining({ name: 'Freddy' }),
- expect.objectContaining({ name: 'Bill' }),
- expect.objectContaining({ name: 'multiple_assets_person' }),
- expect.objectContaining({ name: 'Bob' }),
- expect.objectContaining({ name: 'Alice' }),
- expect.objectContaining({ name: 'Charlie' }),
- expect.objectContaining({ name: 'visible_person' }),
- expect.objectContaining({ id: nameNullPerson4Assets.id, name: '' }),
- expect.objectContaining({ id: nameNullPerson3Assets.id, name: '' }),
- expect.objectContaining({ name: 'hidden_person' }), // Should really be before the null names
- ],
- });
- });
-
- it('should sort visible people by asset count (desc), then by name (asc, nulls last)', async () => {
- const { status, body } = await request(app).get('/people').set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body.hasNextPage).toBe(false);
- expect(body.total).toBe(11); // All persons
- expect(body.hidden).toBe(1); // 'hidden_person'
-
- const people = body.people as PersonResponseDto[];
-
- expect(people.map((p) => p.id)).toEqual([
- nameFreddyPersonFavourite.id, // name: 'Freddy', count: 2
- nameBillPersonFavourite.id, // name: 'Bill', count: 1
- multipleAssetsPerson.id, // name: 'multiple_assets_person', count: 3
- nameBobPerson.id, // name: 'Bob', count: 2
- nameAlicePerson.id, // name: 'Alice', count: 1
- nameCharliePerson.id, // name: 'Charlie', count: 1
- visiblePerson.id, // name: 'visible_person', count: 1
- nameNullPerson4Assets.id, // name: '', count: 4
- nameNullPerson3Assets.id, // name: '', count: 3
- ]);
-
- expect(people.some((p) => p.id === hiddenPerson.id)).toBe(false);
- });
-
- it('should return only visible people', async () => {
- const { status, body } = await request(app).get('/people').set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual({
- hasNextPage: false,
- total: 11,
- hidden: 1,
- people: [
- expect.objectContaining({ name: 'Freddy' }),
- expect.objectContaining({ name: 'Bill' }),
- expect.objectContaining({ name: 'multiple_assets_person' }),
- expect.objectContaining({ name: 'Bob' }),
- expect.objectContaining({ name: 'Alice' }),
- expect.objectContaining({ name: 'Charlie' }),
- expect.objectContaining({ name: 'visible_person' }),
- expect.objectContaining({ id: nameNullPerson4Assets.id, name: '' }),
- expect.objectContaining({ id: nameNullPerson3Assets.id, name: '' }),
- ],
- });
- });
-
- it('should support pagination', async () => {
- const { status, body } = await request(app)
- .get('/people')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .query({ withHidden: true, page: 5, size: 1 });
-
- expect(status).toBe(200);
- expect(body).toEqual({
- hasNextPage: true,
- total: 11,
- hidden: 1,
- people: [expect.objectContaining({ name: 'Alice' })],
- });
- });
- });
-
- describe('GET /people/:id', () => {
- it('should throw error if person with id does not exist', async () => {
- const { status, body } = await request(app)
- .get(`/people/${uuidDto.notFound}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest());
- });
-
- it('should return person information', async () => {
- const { status, body } = await request(app)
- .get(`/people/${visiblePerson.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual(expect.objectContaining({ id: visiblePerson.id }));
- });
- });
-
- describe('GET /people/:id/statistics', () => {
- it('should throw error if person with id does not exist', async () => {
- const { status, body } = await request(app)
- .get(`/people/${uuidDto.notFound}/statistics`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest());
- });
-
- it('should return the correct number of assets', async () => {
- const { status, body } = await request(app)
- .get(`/people/${multipleAssetsPerson.id}/statistics`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual(expect.objectContaining({ assets: 3 }));
- });
- });
-
- describe('POST /people', () => {
- it('should create a person', async () => {
- const { status, body } = await request(app)
- .post(`/people`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({
- name: 'New Person',
- birthDate: '1990-01-01',
- color: '#333',
- });
- expect(status).toBe(201);
- expect(body).toMatchObject({
- id: expect.any(String),
- name: 'New Person',
- birthDate: '1990-01-01',
- });
- });
-
- it('should create a favorite person', async () => {
- const { status, body } = await request(app)
- .post(`/people`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({
- name: 'New Favorite Person',
- isFavorite: true,
- });
- expect(status).toBe(201);
- expect(body).toMatchObject({
- id: expect.any(String),
- name: 'New Favorite Person',
- isFavorite: true,
- });
- });
- });
-
- describe('PUT /people/:id', () => {
- it('should update a date of birth', async () => {
- const { status, body } = await request(app)
- .put(`/people/${visiblePerson.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ birthDate: '1990-01-01' });
- expect(status).toBe(200);
- expect(body).toMatchObject({ birthDate: '1990-01-01' });
- });
-
- it('should clear a date of birth', async () => {
- const { status, body } = await request(app)
- .put(`/people/${visiblePerson.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ birthDate: null });
- expect(status).toBe(200);
- expect(body).toMatchObject({ birthDate: null });
- });
-
- it('should set a color', async () => {
- const { status, body } = await request(app)
- .put(`/people/${visiblePerson.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ color: '#555' });
- expect(status).toBe(200);
- expect(body).toMatchObject({ color: '#555' });
- });
-
- it('should clear a color', async () => {
- const { status, body } = await request(app)
- .put(`/people/${visiblePerson.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ color: null });
- expect(status).toBe(200);
- expect(body.color).toBeUndefined();
- });
-
- it('should mark a person as favorite', async () => {
- const person = await utils.createPerson(admin.accessToken, {
- name: 'visible_person',
- });
-
- expect(person.isFavorite).toBe(false);
-
- const { status, body } = await request(app)
- .put(`/people/${person.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ isFavorite: true });
- expect(status).toBe(200);
- expect(body).toMatchObject({ isFavorite: true });
-
- const person2 = await getPerson({ id: person.id }, { headers: asBearerAuth(admin.accessToken) });
- expect(person2).toMatchObject({ id: person.id, isFavorite: true });
- });
- });
-
- describe('POST /people/:id/merge', () => {
- it('should not supporting merging a person into themselves', async () => {
- const { status, body } = await request(app)
- .post(`/people/${visiblePerson.id}/merge`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ ids: [visiblePerson.id] });
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('Cannot merge a person into themselves'));
- });
- });
-});
diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts
deleted file mode 100644
index 2f6ea75f77..0000000000
--- a/e2e/src/api/specs/search.e2e-spec.ts
+++ /dev/null
@@ -1,703 +0,0 @@
-import {
- AssetMediaResponseDto,
- AssetResponseDto,
- AssetVisibility,
- deleteAssets,
- LoginResponseDto,
- updateAsset,
-} from '@immich/sdk';
-import { DateTime } from 'luxon';
-import { readFile } from 'node:fs/promises';
-import { join } from 'node:path';
-import { Socket } from 'socket.io-client';
-import { app, asBearerAuth, TEN_TIMES, testAssetDir, utils } from 'src/utils';
-import request from 'supertest';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
-const today = DateTime.now();
-
-describe('/search', () => {
- let admin: LoginResponseDto;
- let websocket: Socket;
-
- let assetFalcon: AssetMediaResponseDto;
- let assetDenali: AssetMediaResponseDto;
- let assetCyclamen: AssetMediaResponseDto;
- let assetNotocactus: AssetMediaResponseDto;
- let assetSilver: AssetMediaResponseDto;
- let assetDensity: AssetMediaResponseDto;
- // let assetPhiladelphia: AssetMediaResponseDto;
- // let assetOrychophragmus: AssetMediaResponseDto;
- // let assetRidge: AssetMediaResponseDto;
- // let assetPolemonium: AssetMediaResponseDto;
- // let assetWood: AssetMediaResponseDto;
- // let assetGlarus: AssetMediaResponseDto;
- let assetHeic: AssetMediaResponseDto;
- let assetRocks: AssetMediaResponseDto;
- let assetOneJpg6: AssetMediaResponseDto;
- let assetOneHeic6: AssetMediaResponseDto;
- let assetOneJpg5: AssetMediaResponseDto;
- let assetSprings: AssetMediaResponseDto;
- let assetLast: AssetMediaResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
- admin = await utils.adminSetup();
- websocket = await utils.connectWebsocket(admin.accessToken);
-
- const files = [
- { filename: '/albums/nature/prairie_falcon.jpg' },
- { filename: '/formats/webp/denali.webp' },
- { filename: '/albums/nature/cyclamen_persicum.jpg', dto: { isFavorite: true } },
- { filename: '/albums/nature/notocactus_minimus.jpg' },
- { filename: '/albums/nature/silver_fir.jpg' },
- { filename: '/formats/heic/IMG_2682.heic' },
- { filename: '/formats/jpg/el_torcal_rocks.jpg' },
- { filename: '/formats/motionphoto/samsung-one-ui-6.jpg' },
- { filename: '/formats/motionphoto/samsung-one-ui-6.heic' },
- { filename: '/formats/motionphoto/samsung-one-ui-5.jpg' },
-
- { filename: '/metadata/gps-position/thompson-springs.jpg', dto: { visibility: AssetVisibility.Archive } },
-
- // used for search suggestions
- { filename: '/formats/png/density_plot.png' },
- { filename: '/formats/raw/Nikon/D80/glarus.nef' },
- { filename: '/formats/raw/Nikon/D700/philadelphia.nef' },
- { filename: '/albums/nature/orychophragmus_violaceus.jpg' },
- { filename: '/albums/nature/tanners_ridge.jpg' },
- { filename: '/albums/nature/polemonium_reptans.jpg' },
-
- // last asset
- { filename: '/albums/nature/wood_anemones.jpg' },
- ];
- const assets: AssetMediaResponseDto[] = [];
- for (const { filename, dto } of files) {
- const bytes = await readFile(join(testAssetDir, filename));
- assets.push(
- await utils.createAsset(admin.accessToken, {
- deviceAssetId: `test-${filename}`,
- assetData: { bytes, filename },
- ...dto,
- }),
- );
- }
-
- for (const asset of assets) {
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
- }
-
- // note: the coordinates here are not the actual coordinates of the images and are random for most of them
- const coordinates = [
- { latitude: 48.853_41, longitude: 2.3488 }, // paris
- { latitude: 35.6895, longitude: 139.691_71 }, // tokyo
- { latitude: 52.524_37, longitude: 13.410_53 }, // berlin
- { latitude: 1.314_663_1, longitude: 103.845_409_3 }, // singapore
- { latitude: 41.013_84, longitude: 28.949_66 }, // istanbul
- { latitude: 5.556_02, longitude: -0.1969 }, // accra
- { latitude: 37.544_270_6, longitude: -4.727_752_8 }, // andalusia
- { latitude: 23.133_02, longitude: -82.383_04 }, // havana
- { latitude: 41.694_11, longitude: 44.833_68 }, // tbilisi
- { latitude: 31.222_22, longitude: 121.458_06 }, // shanghai
- { latitude: 38.9711, longitude: -109.7137 }, // thompson springs
- { latitude: 40.714_27, longitude: -74.005_97 }, // new york
- { latitude: 47.040_57, longitude: 9.068_04 }, // glarus
- { latitude: 32.771_52, longitude: -89.116_73 }, // philadelphia
- { latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh
- { latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge
- { latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg
- { latitude: 0, longitude: 0 }, // null island
- ];
-
- const updates = coordinates.map((dto, i) =>
- updateAsset({ id: assets[i].id, updateAssetDto: dto }, { headers: asBearerAuth(admin.accessToken) }),
- );
-
- await Promise.all(updates);
- for (const [i] of coordinates.entries()) {
- await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: assets[i].id });
- }
-
- [
- assetFalcon,
- assetDenali,
- assetCyclamen,
- assetNotocactus,
- assetSilver,
- assetHeic,
- assetRocks,
- assetOneJpg6,
- assetOneHeic6,
- assetOneJpg5,
- assetSprings,
- assetDensity,
- // assetGlarus,
- // assetPhiladelphia,
- // assetOrychophragmus,
- // assetRidge,
- // assetPolemonium,
- // assetWood,
- ] = assets;
-
- assetLast = assets.at(-1) as AssetMediaResponseDto;
-
- await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) });
- }, 30_000);
-
- afterAll(async () => {
- utils.disconnectWebsocket(websocket);
- });
-
- describe('POST /search/metadata', () => {
- const searchTests = [
- {
- should: 'should get my assets',
- deferred: () => ({ dto: { size: 1 }, assets: [assetLast] }),
- },
- {
- should: 'should sort my assets in reverse',
- deferred: () => ({ dto: { order: 'asc', size: 2 }, assets: [assetCyclamen, assetNotocactus] }),
- },
- {
- should: 'should support pagination',
- deferred: () => ({ dto: { order: 'asc', size: 1, page: 2 }, assets: [assetNotocactus] }),
- },
- {
- should: 'should search by checksum (base64)',
- deferred: () => ({ dto: { checksum: '9IXBDMjj9OrQb+1YMHprZJgZ/UQ=' }, assets: [assetCyclamen] }),
- },
- {
- should: 'should search by checksum (hex)',
- deferred: () => ({ dto: { checksum: 'f485c10cc8e3f4ead06fed58307a6b649819fd44' }, assets: [assetCyclamen] }),
- },
- { should: 'should search by id', deferred: () => ({ dto: { id: assetCyclamen.id }, assets: [assetCyclamen] }) },
- {
- should: 'should search by isFavorite (true)',
- deferred: () => ({ dto: { isFavorite: true }, assets: [assetCyclamen] }),
- },
- {
- should: 'should search by isFavorite (false)',
- deferred: () => ({ dto: { size: 1, isFavorite: false }, assets: [assetLast] }),
- },
- {
- should: 'should search by visibility (AssetVisibility.Archive)',
- deferred: () => ({ dto: { visibility: AssetVisibility.Archive }, assets: [assetSprings] }),
- },
- {
- should: 'should search by visibility (AssetVisibility.Timeline)',
- deferred: () => ({ dto: { size: 1, visibility: AssetVisibility.Timeline }, assets: [assetLast] }),
- },
- {
- should: 'should search by type (image)',
- deferred: () => ({ dto: { size: 1, type: 'IMAGE' }, assets: [assetLast] }),
- },
- {
- should: 'should search by type (video)',
- deferred: () => ({
- dto: { type: 'VIDEO', visibility: AssetVisibility.Hidden },
- assets: [
- // the three live motion photos
- { id: expect.any(String) },
- { id: expect.any(String) },
- { id: expect.any(String) },
- ],
- }),
- },
- {
- should: 'should search by trashedBefore',
- deferred: () => ({ dto: { trashedBefore: today.plus({ hour: 1 }).toJSDate() }, assets: [assetSilver] }),
- },
- {
- should: 'should search by trashedBefore (no results)',
- deferred: () => ({ dto: { trashedBefore: today.minus({ days: 1 }).toJSDate() }, assets: [] }),
- },
- {
- should: 'should search by trashedAfter',
- deferred: () => ({ dto: { trashedAfter: today.minus({ hour: 1 }).toJSDate() }, assets: [assetSilver] }),
- },
- {
- should: 'should search by trashedAfter (no results)',
- deferred: () => ({ dto: { trashedAfter: today.plus({ hour: 1 }).toJSDate() }, assets: [] }),
- },
- {
- should: 'should search by takenBefore',
- deferred: () => ({ dto: { size: 1, takenBefore: today.plus({ hour: 1 }).toJSDate() }, assets: [assetLast] }),
- },
- {
- should: 'should search by takenBefore (no results)',
- deferred: () => ({ dto: { takenBefore: DateTime.fromObject({ year: 1234 }).toJSDate() }, assets: [] }),
- },
- {
- should: 'should search by takenAfter',
- deferred: () => ({
- dto: { size: 1, takenAfter: DateTime.fromObject({ year: 1234 }).toJSDate() },
- assets: [assetLast],
- }),
- },
- {
- should: 'should search by takenAfter (no results)',
- deferred: () => ({ dto: { takenAfter: today.plus({ hour: 1 }).toJSDate() }, assets: [] }),
- },
- {
- should: 'should search by originalFilename',
- deferred: () => ({
- dto: { originalFileName: 'rocks' },
- assets: [assetRocks],
- }),
- },
- {
- should: 'should search by originalFilename with spaces',
- deferred: () => ({
- dto: { originalFileName: 'samsung-one', type: 'IMAGE' },
- assets: [assetOneJpg5, assetOneJpg6, assetOneHeic6],
- }),
- },
- {
- should: 'should search by city',
- deferred: () => ({
- dto: {
- city: 'Accra',
- includeNull: true,
- },
- assets: [assetHeic],
- }),
- },
- {
- should: "should search city ('')",
- deferred: () => ({
- dto: {
- city: '',
- visibility: AssetVisibility.Timeline,
- includeNull: true,
- },
- assets: [assetLast],
- }),
- },
- {
- should: 'should search city (null)',
- deferred: () => ({
- dto: {
- city: null,
- visibility: AssetVisibility.Timeline,
- includeNull: true,
- },
- assets: [assetLast],
- }),
- },
- {
- should: 'should search by state',
- deferred: () => ({
- dto: {
- state: 'New York',
- includeNull: true,
- },
- assets: [assetDensity],
- }),
- },
- {
- should: "should search state ('')",
- deferred: () => ({
- dto: {
- state: '',
- visibility: AssetVisibility.Timeline,
- withExif: true,
- includeNull: true,
- },
- assets: [assetLast, assetNotocactus],
- }),
- },
- {
- should: 'should search state (null)',
- deferred: () => ({
- dto: {
- state: null,
- visibility: AssetVisibility.Timeline,
- includeNull: true,
- },
- assets: [assetLast, assetNotocactus],
- }),
- },
- {
- should: 'should search by country',
- deferred: () => ({
- dto: {
- country: 'France',
- includeNull: true,
- },
- assets: [assetFalcon],
- }),
- },
- {
- should: "should search country ('')",
- deferred: () => ({
- dto: {
- country: '',
- visibility: AssetVisibility.Timeline,
- includeNull: true,
- },
- assets: [assetLast],
- }),
- },
- {
- should: 'should search country (null)',
- deferred: () => ({
- dto: {
- country: null,
- visibility: AssetVisibility.Timeline,
- includeNull: true,
- },
- assets: [assetLast],
- }),
- },
- {
- should: 'should search by make',
- deferred: () => ({
- dto: {
- make: 'Canon',
- includeNull: true,
- },
- assets: [assetFalcon, assetDenali],
- }),
- },
- {
- should: 'should search by model',
- deferred: () => ({
- dto: {
- model: 'Canon EOS 7D',
- includeNull: true,
- },
- assets: [assetDenali],
- }),
- },
- {
- should: 'should allow searching the upload library (libraryId: null)',
- deferred: () => ({
- dto: { libraryId: null, size: 1 },
- assets: [assetLast],
- }),
- },
- ];
-
- for (const { should, deferred } of searchTests) {
- it(should, async () => {
- const { assets, dto } = deferred();
- const { status, body } = await request(app)
- .post('/search/metadata')
- .send(dto)
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(200);
- expect(body.assets).toBeDefined();
- expect(Array.isArray(body.assets.items)).toBe(true);
- for (const [i, asset] of assets.entries()) {
- expect(body.assets.items[i]).toEqual(expect.objectContaining({ id: asset.id }));
- }
- expect(body.assets.items).toHaveLength(assets.length);
- });
- }
- });
-
- describe('POST /search/random', () => {
- beforeAll(async () => {
- await Promise.all([
- utils.createAsset(admin.accessToken),
- utils.createAsset(admin.accessToken),
- utils.createAsset(admin.accessToken),
- utils.createAsset(admin.accessToken),
- utils.createAsset(admin.accessToken),
- utils.createAsset(admin.accessToken),
- ]);
-
- await utils.waitForQueueFinish(admin.accessToken, 'thumbnailGeneration');
- });
-
- it.each(TEN_TIMES)('should return 1 random assets', async () => {
- const { status, body } = await request(app)
- .post('/search/random')
- .send({ size: 1 })
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
-
- const assets: AssetResponseDto[] = body;
- expect(assets.length).toBe(1);
- expect(assets[0].ownerId).toBe(admin.userId);
- });
-
- it.each(TEN_TIMES)('should return 2 random assets', async () => {
- const { status, body } = await request(app)
- .post('/search/random')
- .send({ size: 2 })
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
-
- const assets: AssetResponseDto[] = body;
- expect(assets.length).toBe(2);
- expect(assets[0].ownerId).toBe(admin.userId);
- expect(assets[1].ownerId).toBe(admin.userId);
- });
- });
-
- describe('GET /search/explore', () => {
- it('should get explore data', async () => {
- const { status, body } = await request(app)
- .get('/search/explore')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(200);
- expect(body).toEqual([{ fieldName: 'exifInfo.city', items: [] }]);
- });
- });
-
- describe('GET /search/places', () => {
- it('should get relevant places', async () => {
- const name = 'Paris';
-
- const { status, body } = await request(app)
- .get(`/search/places?name=${name}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(Array.isArray(body)).toBe(true);
- if (Array.isArray(body)) {
- expect(body.length).toBeGreaterThan(10);
- expect(body[0].name).toEqual(name);
- expect(body[0].admin2name).toEqual(name);
- }
- });
- });
-
- describe('GET /search/cities', () => {
- it('should get all cities', async () => {
- const { status, body } = await request(app)
- .get('/search/cities')
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(Array.isArray(body)).toBe(true);
- if (Array.isArray(body)) {
- expect(body.length).toBeGreaterThan(10);
- const assetsWithCity = body.filter((asset) => !!asset.exifInfo?.city);
- expect(assetsWithCity.length).toEqual(body.length);
- const cities = new Set(assetsWithCity.map((asset) => asset.exifInfo.city));
- expect(cities.size).toEqual(body.length);
- }
- });
- });
-
- describe('GET /search/suggestions', () => {
- it('should get suggestions for country (including null)', async () => {
- const { status, body } = await request(app)
- .get('/search/suggestions?type=country&includeNull=true')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(body).toEqual([
- 'Cuba',
- 'France',
- 'Georgia',
- 'Germany',
- 'Ghana',
- 'Japan',
- 'Morocco',
- "People's Republic of China",
- 'Russian Federation',
- 'Singapore',
- 'Spain',
- 'Switzerland',
- 'United States of America',
- null,
- ]);
- expect(status).toBe(200);
- });
-
- it('should get suggestions for country', async () => {
- const { status, body } = await request(app)
- .get('/search/suggestions?type=country')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(body).toEqual([
- 'Cuba',
- 'France',
- 'Georgia',
- 'Germany',
- 'Ghana',
- 'Japan',
- 'Morocco',
- "People's Republic of China",
- 'Russian Federation',
- 'Singapore',
- 'Spain',
- 'Switzerland',
- 'United States of America',
- ]);
- expect(status).toBe(200);
- });
-
- it('should get suggestions for state (including null)', async () => {
- const { status, body } = await request(app)
- .get('/search/suggestions?type=state&includeNull=true')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(body).toEqual([
- 'Andalusia',
- 'Glarus',
- 'Greater Accra',
- 'Havana',
- 'รle-de-France',
- 'Marrakesh-Safi',
- 'Mississippi',
- 'New York',
- 'Shanghai',
- 'State of Berlin',
- 'St.-Petersburg',
- 'Tbilisi',
- 'Tokyo',
- 'Virginia',
- null,
- ]);
- expect(status).toBe(200);
- });
-
- it('should get suggestions for state', async () => {
- const { status, body } = await request(app)
- .get('/search/suggestions?type=state')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(body).toEqual([
- 'Andalusia',
- 'Glarus',
- 'Greater Accra',
- 'Havana',
- 'รle-de-France',
- 'Marrakesh-Safi',
- 'Mississippi',
- 'New York',
- 'Shanghai',
- 'State of Berlin',
- 'St.-Petersburg',
- 'Tbilisi',
- 'Tokyo',
- 'Virginia',
- ]);
- expect(status).toBe(200);
- });
-
- it('should get suggestions for city (including null)', async () => {
- const { status, body } = await request(app)
- .get('/search/suggestions?type=city&includeNull=true')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(body).toEqual([
- 'Accra',
- 'Berlin',
- 'Glarus',
- 'Havana',
- 'Marrakesh',
- 'Montalbรกn de Cรณrdoba',
- 'New York City',
- 'Novena',
- 'Paris',
- 'Philadelphia',
- 'Saint Petersburg',
- 'Shanghai',
- 'Stanley',
- 'Tbilisi',
- 'Tokyo',
- null,
- ]);
- expect(status).toBe(200);
- });
-
- it('should get suggestions for city', async () => {
- const { status, body } = await request(app)
- .get('/search/suggestions?type=city')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(body).toEqual([
- 'Accra',
- 'Berlin',
- 'Glarus',
- 'Havana',
- 'Marrakesh',
- 'Montalbรกn de Cรณrdoba',
- 'New York City',
- 'Novena',
- 'Paris',
- 'Philadelphia',
- 'Saint Petersburg',
- 'Shanghai',
- 'Stanley',
- 'Tbilisi',
- 'Tokyo',
- ]);
- expect(status).toBe(200);
- });
-
- it('should get suggestions for camera make (including null)', async () => {
- const { status, body } = await request(app)
- .get('/search/suggestions?type=camera-make&includeNull=true')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(body).toEqual([
- 'Apple',
- 'Canon',
- 'FUJIFILM',
- 'NIKON CORPORATION',
- 'PENTAX Corporation',
- 'samsung',
- 'SONY',
- null,
- ]);
- expect(status).toBe(200);
- });
-
- it('should get suggestions for camera make', async () => {
- const { status, body } = await request(app)
- .get('/search/suggestions?type=camera-make')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(body).toEqual([
- 'Apple',
- 'Canon',
- 'FUJIFILM',
- 'NIKON CORPORATION',
- 'PENTAX Corporation',
- 'samsung',
- 'SONY',
- ]);
- expect(status).toBe(200);
- });
-
- it('should get suggestions for camera model (including null)', async () => {
- const { status, body } = await request(app)
- .get('/search/suggestions?type=camera-model&includeNull=true')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(body).toEqual([
- 'Canon EOS 7D',
- 'Canon EOS R5',
- 'DSLR-A550',
- 'FinePix S3Pro',
- 'iPhone 7',
- 'NIKON D700',
- 'NIKON D750',
- 'NIKON D80',
- 'PENTAX K10D',
- 'SM-F711N',
- 'SM-S906U',
- 'SM-T970',
- null,
- ]);
- expect(status).toBe(200);
- });
-
- it('should get suggestions for camera model', async () => {
- const { status, body } = await request(app)
- .get('/search/suggestions?type=camera-model')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(body).toEqual([
- 'Canon EOS 7D',
- 'Canon EOS R5',
- 'DSLR-A550',
- 'FinePix S3Pro',
- 'iPhone 7',
- 'NIKON D700',
- 'NIKON D750',
- 'NIKON D80',
- 'PENTAX K10D',
- 'SM-F711N',
- 'SM-S906U',
- 'SM-T970',
- ]);
- expect(status).toBe(200);
- });
- });
-});
diff --git a/e2e/src/api/specs/server.e2e-spec.ts b/e2e/src/api/specs/server.e2e-spec.ts
index 3dd6f15e71..4ec892c42a 100644
--- a/e2e/src/api/specs/server.e2e-spec.ts
+++ b/e2e/src/api/specs/server.e2e-spec.ts
@@ -1,4 +1,4 @@
-import { LoginResponseDto } from '@immich/sdk';
+import { LoginResponseDto } from '@server/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
diff --git a/e2e/src/api/specs/session.e2e-spec.ts b/e2e/src/api/specs/session.e2e-spec.ts
index 0b632f78ba..e6441a7071 100644
--- a/e2e/src/api/specs/session.e2e-spec.ts
+++ b/e2e/src/api/specs/session.e2e-spec.ts
@@ -1,4 +1,4 @@
-import { LoginResponseDto, getSessions, login, signUpAdmin } from '@immich/sdk';
+import { LoginResponseDto, getSessions, login, signUpAdmin } from '@server/sdk';
import { loginDto, signupDto, uuidDto } from 'src/fixtures';
import { deviceDto, errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts
deleted file mode 100644
index 8c15a14da5..0000000000
--- a/e2e/src/api/specs/shared-link.e2e-spec.ts
+++ /dev/null
@@ -1,477 +0,0 @@
-import {
- AlbumResponseDto,
- AssetMediaResponseDto,
- LoginResponseDto,
- SharedLinkResponseDto,
- SharedLinkType,
- createAlbum,
- deleteUserAdmin,
-} from '@immich/sdk';
-import { createUserDto, uuidDto } from 'src/fixtures';
-import { errorDto } from 'src/responses';
-import { app, asBearerAuth, baseUrl, shareUrl, utils } from 'src/utils';
-import request from 'supertest';
-import { beforeAll, describe, expect, it } from 'vitest';
-
-describe('/shared-links', () => {
- let admin: LoginResponseDto;
- let asset1: AssetMediaResponseDto;
- let asset2: AssetMediaResponseDto;
- let user1: LoginResponseDto;
- let user2: LoginResponseDto;
- let album: AlbumResponseDto;
- let deletedAlbum: AlbumResponseDto;
- let linkWithDeletedAlbum: SharedLinkResponseDto;
- let linkWithPassword: SharedLinkResponseDto;
- let linkWithAlbum: SharedLinkResponseDto;
- let linkWithAssets: SharedLinkResponseDto;
- let linkWithMetadata: SharedLinkResponseDto;
- let linkWithoutMetadata: SharedLinkResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
-
- admin = await utils.adminSetup();
-
- [user1, user2] = await Promise.all([
- utils.userSetup(admin.accessToken, createUserDto.user1),
- utils.userSetup(admin.accessToken, createUserDto.user2),
- ]);
-
- [asset1, asset2] = await Promise.all([utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken)]);
-
- [album, deletedAlbum] = await Promise.all([
- createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }),
- createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }),
- ]);
-
- [linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
- await Promise.all([
- utils.createSharedLink(user2.accessToken, {
- type: SharedLinkType.Album,
- albumId: deletedAlbum.id,
- }),
- utils.createSharedLink(user1.accessToken, {
- type: SharedLinkType.Album,
- albumId: album.id,
- }),
- utils.createSharedLink(user1.accessToken, {
- type: SharedLinkType.Individual,
- assetIds: [asset1.id],
- }),
- utils.createSharedLink(user1.accessToken, {
- type: SharedLinkType.Album,
- albumId: album.id,
- password: 'foo',
- }),
- utils.createSharedLink(user1.accessToken, {
- type: SharedLinkType.Individual,
- assetIds: [asset1.id],
- showMetadata: true,
- slug: 'metadata-slug',
- }),
- utils.createSharedLink(user1.accessToken, {
- type: SharedLinkType.Individual,
- assetIds: [asset1.id],
- showMetadata: false,
- }),
- ]);
-
- await deleteUserAdmin({ id: user2.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) });
- });
-
- describe('GET /share/:key', () => {
- it('should have correct asset count in meta tag for non-empty album', async () => {
- const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`);
- expect(resp.status).toBe(200);
- expect(resp.header['content-type']).toContain('text/html');
- expect(resp.text).toContain(``);
- });
-
- it('should have correct asset count in meta tag for empty album', async () => {
- const resp = await request(shareUrl).get(`/${linkWithAlbum.key}`);
- expect(resp.status).toBe(200);
- expect(resp.header['content-type']).toContain('text/html');
- expect(resp.text).toContain(``);
- });
-
- it('should have correct asset count in meta tag for shared asset', async () => {
- const resp = await request(shareUrl).get(`/${linkWithAssets.key}`);
- expect(resp.status).toBe(200);
- expect(resp.header['content-type']).toContain('text/html');
- expect(resp.text).toContain(``);
- });
-
- it('should have fqdn og:image meta tag for shared asset', async () => {
- const resp = await request(shareUrl).get(`/${linkWithAssets.key}`);
- expect(resp.status).toBe(200);
- expect(resp.header['content-type']).toContain('text/html');
- expect(resp.text).toContain(``);
- });
- });
-
- describe('GET /shared-links', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).get('/shared-links');
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should get all shared links created by user', async () => {
- const { status, body } = await request(app)
- .get('/shared-links')
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toHaveLength(5);
- expect(body).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ id: linkWithAlbum.id }),
- expect.objectContaining({
- id: linkWithAssets.id,
- assets: expect.arrayContaining([expect.objectContaining({ id: asset1.id })]),
- }),
- expect.objectContaining({ id: linkWithPassword.id }),
- expect.objectContaining({ id: linkWithMetadata.id }),
- expect.objectContaining({ id: linkWithoutMetadata.id }),
- ]),
- );
- });
-
- it('should filter on albumId', async () => {
- const { status, body } = await request(app)
- .get(`/shared-links?albumId=${album.id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toHaveLength(2);
- expect(body).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ id: linkWithAlbum.id }),
- expect.objectContaining({ id: linkWithPassword.id }),
- ]),
- );
- });
-
- it('should find 0 albums', async () => {
- const { status, body } = await request(app)
- .get(`/shared-links?albumId=${uuidDto.notFound}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toHaveLength(0);
- });
-
- it('should not get shared links created by other users', async () => {
- const { status, body } = await request(app)
- .get('/shared-links')
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual([]);
- });
- });
-
- describe('GET /shared-links/me', () => {
- it('should not require admin authentication', async () => {
- const { status } = await request(app).get('/shared-links/me').set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(403);
- });
-
- it('should get data for correct shared link', async () => {
- const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithAlbum.key });
-
- expect(status).toBe(200);
- expect(body).toEqual(
- expect.objectContaining({
- album: expect.objectContaining({ id: album.id }),
- userId: user1.userId,
- type: SharedLinkType.Album,
- }),
- );
- });
-
- it('should return unauthorized for incorrect shared link', async () => {
- const { status, body } = await request(app)
- .get('/shared-links/me')
- .query({ key: linkWithAlbum.key + 'foo' });
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.invalidShareKey);
- });
-
- it('should return unauthorized if target has been soft deleted', async () => {
- const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithDeletedAlbum.key });
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.invalidShareKey);
- });
-
- it('should return unauthorized for password protected link', async () => {
- const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithPassword.key });
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.invalidSharePassword);
- });
-
- it('should get data for correct password protected link', async () => {
- const { status, body } = await request(app)
- .get('/shared-links/me')
- .query({ key: linkWithPassword.key, password: 'foo' });
-
- expect(status).toBe(200);
- expect(body).toEqual(
- expect.objectContaining({
- album: expect.objectContaining({ id: album.id }),
- userId: user1.userId,
- type: SharedLinkType.Album,
- }),
- );
- });
-
- it('should return metadata for individual shared link', async () => {
- const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key });
-
- expect(status).toBe(200);
- expect(body.assets).toHaveLength(1);
- expect(body.album).not.toBeDefined();
- });
-
- it('should not return metadata for album shared link without metadata', async () => {
- const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithoutMetadata.key });
-
- expect(status).toBe(200);
- expect(body.assets).toHaveLength(1);
- expect(body.album).not.toBeDefined();
-
- const asset = body.assets[0];
- expect(asset).not.toHaveProperty('exifInfo');
- expect(asset).not.toHaveProperty('fileCreatedAt');
- expect(asset).not.toHaveProperty('originalFilename');
- expect(asset).not.toHaveProperty('originalPath');
- });
- });
-
- describe('GET /shared-links/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).get(`/shared-links/${linkWithAlbum.id}`);
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should get shared link by id', async () => {
- const { status, body } = await request(app)
- .get(`/shared-links/${linkWithAlbum.id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual(
- expect.objectContaining({
- album: expect.objectContaining({ id: album.id }),
- userId: user1.userId,
- type: SharedLinkType.Album,
- }),
- );
- });
-
- it('should not get shared link by id if user has not created the link or it does not exist', async () => {
- const { status, body } = await request(app)
- .get(`/shared-links/${linkWithAlbum.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(400);
- expect(body).toEqual(expect.objectContaining({ message: 'Shared link not found' }));
- });
- });
-
- describe('POST /shared-links', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app)
- .post('/shared-links')
- .send({ type: SharedLinkType.Album, albumId: uuidDto.notFound });
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require a type and the correspondent asset/album id', async () => {
- const { status, body } = await request(app)
- .post('/shared-links')
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest());
- });
-
- it('should require an asset/album id', async () => {
- const { status, body } = await request(app)
- .post('/shared-links')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ type: SharedLinkType.Album });
-
- expect(status).toBe(400);
- expect(body).toEqual(expect.objectContaining({ message: 'Invalid albumId' }));
- });
-
- it('should require a valid asset id', async () => {
- const { status, body } = await request(app)
- .post('/shared-links')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ type: SharedLinkType.Individual, assetId: uuidDto.notFound });
-
- expect(status).toBe(400);
- expect(body).toEqual(expect.objectContaining({ message: 'Invalid assetIds' }));
- });
-
- it('should create a shared link', async () => {
- const { status, body } = await request(app)
- .post('/shared-links')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ type: SharedLinkType.Album, albumId: album.id });
-
- expect(status).toBe(201);
- expect(body).toEqual(
- expect.objectContaining({
- type: SharedLinkType.Album,
- userId: user1.userId,
- }),
- );
- });
- });
-
- describe('PATCH /shared-links/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app)
- .patch(`/shared-links/${linkWithAlbum.id}`)
- .send({ description: 'foo' });
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should fail if invalid link', async () => {
- const { status, body } = await request(app)
- .patch(`/shared-links/${uuidDto.notFound}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ description: 'foo' });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest());
- });
-
- it('should update shared link', async () => {
- const { status, body } = await request(app)
- .patch(`/shared-links/${linkWithAlbum.id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ description: 'foo' });
-
- expect(status).toBe(200);
- expect(body).toEqual(
- expect.objectContaining({
- type: SharedLinkType.Album,
- userId: user1.userId,
- description: 'foo',
- }),
- );
- });
- });
-
- describe('PUT /shared-links/:id/assets', () => {
- it('should not add assets to shared link (album)', async () => {
- const { status, body } = await request(app)
- .put(`/shared-links/${linkWithAlbum.id}/assets`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ assetIds: [asset2.id] });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
- });
-
- it('should add an assets to a shared link (individual)', async () => {
- const { status, body } = await request(app)
- .put(`/shared-links/${linkWithAssets.id}/assets`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ assetIds: [asset2.id] });
-
- expect(body).toEqual([{ assetId: asset2.id, success: true }]);
- expect(status).toBe(200);
- });
- });
-
- describe('DELETE /shared-links/:id/assets', () => {
- it('should not remove assets from a shared link (album)', async () => {
- const { status, body } = await request(app)
- .delete(`/shared-links/${linkWithAlbum.id}/assets`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ assetIds: [asset2.id] });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
- });
-
- it('should remove assets from a shared link (individual)', async () => {
- const { status, body } = await request(app)
- .delete(`/shared-links/${linkWithAssets.id}/assets`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ assetIds: [asset2.id] });
-
- expect(body).toEqual([{ assetId: asset2.id, success: true }]);
- expect(status).toBe(200);
- });
- });
-
- describe('DELETE /shared-links/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).delete(`/shared-links/${linkWithAlbum.id}`);
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should fail if invalid link', async () => {
- const { status, body } = await request(app)
- .delete(`/shared-links/${uuidDto.notFound}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest());
- });
-
- it('should delete a shared link', async () => {
- const { status } = await request(app)
- .delete(`/shared-links/${linkWithAlbum.id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(204);
- });
- });
-});
diff --git a/e2e/src/api/specs/stack.e2e-spec.ts b/e2e/src/api/specs/stack.e2e-spec.ts
deleted file mode 100644
index 91dd0d2a8e..0000000000
--- a/e2e/src/api/specs/stack.e2e-spec.ts
+++ /dev/null
@@ -1,202 +0,0 @@
-import { AssetMediaResponseDto, LoginResponseDto, searchStacks } from '@immich/sdk';
-import { createUserDto, uuidDto } from 'src/fixtures';
-import { errorDto } from 'src/responses';
-import { app, asBearerAuth, utils } from 'src/utils';
-import request from 'supertest';
-import { beforeAll, describe, expect, it } from 'vitest';
-
-describe('/stacks', () => {
- let admin: LoginResponseDto;
- let user1: LoginResponseDto;
- let user2: LoginResponseDto;
- let asset: AssetMediaResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
-
- admin = await utils.adminSetup();
-
- [user1, user2] = await Promise.all([
- utils.userSetup(admin.accessToken, createUserDto.user1),
- utils.userSetup(admin.accessToken, createUserDto.user2),
- ]);
-
- asset = await utils.createAsset(user1.accessToken);
- });
-
- describe('POST /stacks', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app)
- .post('/stacks')
- .send({ assetIds: [asset.id] });
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require at least two assets', async () => {
- const { status, body } = await request(app)
- .post('/stacks')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ assetIds: [asset.id] });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest());
- });
-
- it('should require a valid id', async () => {
- const { status, body } = await request(app)
- .post('/stacks')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ assetIds: [uuidDto.invalid, uuidDto.invalid] });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest());
- });
-
- it('should require access', async () => {
- const user2Asset = await utils.createAsset(user2.accessToken);
- const { status, body } = await request(app)
- .post('/stacks')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ assetIds: [asset.id, user2Asset.id] });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.noPermission);
- });
-
- it('should create a stack', async () => {
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- const { status, body } = await request(app)
- .post('/stacks')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ assetIds: [asset1.id, asset2.id] });
-
- expect(status).toBe(201);
- expect(body).toEqual({
- id: expect.any(String),
- primaryAssetId: asset1.id,
- assets: [expect.objectContaining({ id: asset1.id }), expect.objectContaining({ id: asset2.id })],
- });
- });
-
- it('should merge an existing stack', async () => {
- const [asset1, asset2, asset3] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- const response1 = await request(app)
- .post('/stacks')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ assetIds: [asset1.id, asset2.id] });
-
- expect(response1.status).toBe(201);
-
- const stacksBefore = await searchStacks({}, { headers: asBearerAuth(user1.accessToken) });
-
- const { status, body } = await request(app)
- .post('/stacks')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ assetIds: [asset1.id, asset3.id] });
-
- expect(status).toBe(201);
- expect(body).toEqual({
- id: expect.any(String),
- primaryAssetId: asset1.id,
- assets: expect.arrayContaining([
- expect.objectContaining({ id: asset1.id }),
- expect.objectContaining({ id: asset2.id }),
- expect.objectContaining({ id: asset3.id }),
- ]),
- });
-
- const stacksAfter = await searchStacks({}, { headers: asBearerAuth(user1.accessToken) });
- expect(stacksAfter.length).toBe(stacksBefore.length);
- });
- });
-
- describe('GET /assets/:id', () => {
- it('should include stack details for the primary asset', async () => {
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- await utils.createStack(user1.accessToken, [asset1.id, asset2.id]);
-
- const { status, body } = await request(app)
- .get(`/assets/${asset1.id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual(
- expect.objectContaining({
- id: asset1.id,
- stack: {
- id: expect.any(String),
- assetCount: 2,
- primaryAssetId: asset1.id,
- },
- }),
- );
- });
-
- it('should include stack details for a non-primary asset', async () => {
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- await utils.createStack(user1.accessToken, [asset1.id, asset2.id]);
-
- const { status, body } = await request(app)
- .get(`/assets/${asset2.id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual(
- expect.objectContaining({
- id: asset2.id,
- stack: {
- id: expect.any(String),
- assetCount: 2,
- primaryAssetId: asset1.id,
- },
- }),
- );
- });
- });
-
- describe('GET /stacks/:id', () => {
- it('should include exifInfo in stack assets', async () => {
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- const stack = await utils.createStack(user1.accessToken, [asset1.id, asset2.id]);
-
- const { status, body } = await request(app)
- .get(`/stacks/${stack.id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual(
- expect.objectContaining({
- id: stack.id,
- primaryAssetId: asset1.id,
- assets: expect.arrayContaining([
- expect.objectContaining({ id: asset1.id, exifInfo: expect.any(Object) }),
- expect.objectContaining({ id: asset2.id, exifInfo: expect.any(Object) }),
- ]),
- }),
- );
- });
- });
-});
diff --git a/e2e/src/api/specs/system-config.e2e-spec.ts b/e2e/src/api/specs/system-config.e2e-spec.ts
deleted file mode 100644
index 1bd7bdc489..0000000000
--- a/e2e/src/api/specs/system-config.e2e-spec.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { LoginResponseDto, getConfig } from '@immich/sdk';
-import { errorDto } from 'src/responses';
-import { app, asBearerAuth, utils } from 'src/utils';
-import request from 'supertest';
-import { beforeAll, describe, expect, it } from 'vitest';
-
-const getSystemConfig = (accessToken: string) => getConfig({ headers: asBearerAuth(accessToken) });
-
-describe('/system-config', () => {
- let admin: LoginResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
- admin = await utils.adminSetup();
- });
-
- describe('PUT /system-config', () => {
- it('should always return the new config', async () => {
- const config = await getSystemConfig(admin.accessToken);
-
- const response1 = await request(app)
- .put('/system-config')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ ...config, newVersionCheck: { enabled: false } });
-
- expect(response1.status).toBe(200);
- expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false } });
-
- const response2 = await request(app)
- .put('/system-config')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ ...config, newVersionCheck: { enabled: true } });
-
- expect(response2.status).toBe(200);
- expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true } });
- });
-
- it('should reject an invalid config entry', async () => {
- const { status, body } = await request(app)
- .put('/system-config')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({
- ...(await getSystemConfig(admin.accessToken)),
- storageTemplate: { enabled: true, hashVerificationEnabled: true, template: '{{foo}}' },
- });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest(expect.stringContaining('Invalid storage template')));
- });
- });
-});
diff --git a/e2e/src/api/specs/system-metadata.e2e-spec.ts b/e2e/src/api/specs/system-metadata.e2e-spec.ts
deleted file mode 100644
index bd17bf2524..0000000000
--- a/e2e/src/api/specs/system-metadata.e2e-spec.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { LoginResponseDto, getServerConfig } from '@immich/sdk';
-import { createUserDto } from 'src/fixtures';
-import { errorDto } from 'src/responses';
-import { app, utils } from 'src/utils';
-import request from 'supertest';
-import { beforeAll, describe, expect, it } from 'vitest';
-
-describe('/server-info', () => {
- let admin: LoginResponseDto;
- let nonAdmin: LoginResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
- admin = await utils.adminSetup({ onboarding: false });
- nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
- });
-
- describe('POST /system-metadata/admin-onboarding', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).post('/system-metadata/admin-onboarding').send({ isOnboarded: true });
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should only work for admins', async () => {
- const { status, body } = await request(app)
- .post('/system-metadata/admin-onboarding')
- .set('Authorization', `Bearer ${nonAdmin.accessToken}`)
- .send({ isOnboarded: true });
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.forbidden);
- });
-
- it('should set admin onboarding', async () => {
- const config = await getServerConfig({});
- expect(config.isOnboarded).toBe(false);
-
- const { status } = await request(app)
- .post('/system-metadata/admin-onboarding')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ isOnboarded: true });
- expect(status).toBe(204);
-
- const newConfig = await getServerConfig({});
- expect(newConfig.isOnboarded).toBe(true);
- });
- });
-
- describe('GET /system-metadata/reverse-geocoding-state', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).get('/system-metadata/reverse-geocoding-state');
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should only work for admins', async () => {
- const { status, body } = await request(app)
- .get('/system-metadata/reverse-geocoding-state')
- .set('Authorization', `Bearer ${nonAdmin.accessToken}`);
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.forbidden);
- });
-
- it('should get the reverse geocoding state', async () => {
- const { status, body } = await request(app)
- .get('/system-metadata/reverse-geocoding-state')
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual({
- lastUpdate: expect.any(String),
- lastImportFileName: 'cities500.txt',
- });
- });
- });
-});
diff --git a/e2e/src/api/specs/tag.e2e-spec.ts b/e2e/src/api/specs/tag.e2e-spec.ts
deleted file mode 100644
index d69536f3a3..0000000000
--- a/e2e/src/api/specs/tag.e2e-spec.ts
+++ /dev/null
@@ -1,603 +0,0 @@
-import {
- AssetMediaResponseDto,
- LoginResponseDto,
- Permission,
- TagCreateDto,
- TagResponseDto,
- createTag,
- getAllTags,
- tagAssets,
- upsertTags,
-} from '@immich/sdk';
-import { createUserDto, uuidDto } from 'src/fixtures';
-import { errorDto } from 'src/responses';
-import { app, asBearerAuth, utils } from 'src/utils';
-import request from 'supertest';
-import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
-
-const create = (accessToken: string, dto: TagCreateDto) =>
- createTag({ tagCreateDto: dto }, { headers: asBearerAuth(accessToken) });
-
-const upsert = (accessToken: string, tags: string[]) =>
- upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) });
-
-describe('/tags', () => {
- let admin: LoginResponseDto;
- let user: LoginResponseDto;
- let userAsset: AssetMediaResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
-
- admin = await utils.adminSetup();
- user = await utils.userSetup(admin.accessToken, createUserDto.user1);
- userAsset = await utils.createAsset(user.accessToken);
- });
-
- beforeEach(async () => {
- // tagging assets eventually triggers metadata extraction which can impact other tests
- await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
- await utils.resetDatabase(['tag']);
- });
-
- describe('POST /tags', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).post('/tags').send({ name: 'TagA' });
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require authorization (api key)', async () => {
- const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
- const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' });
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.missingPermission('tag.create'));
- });
-
- it('should work with tag.create', async () => {
- const { secret } = await utils.createApiKey(user.accessToken, [Permission.TagCreate]);
- const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' });
- expect(body).toEqual({
- id: expect.any(String),
- name: 'TagA',
- value: 'TagA',
- createdAt: expect.any(String),
- updatedAt: expect.any(String),
- });
- expect(status).toBe(201);
- });
-
- it('should create a tag', async () => {
- const { status, body } = await request(app)
- .post('/tags')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ name: 'TagA' });
- expect(body).toEqual({
- id: expect.any(String),
- name: 'TagA',
- value: 'TagA',
- createdAt: expect.any(String),
- updatedAt: expect.any(String),
- });
- expect(status).toBe(201);
- });
-
- it('should allow multiple users to create tags with the same value', async () => {
- await create(admin.accessToken, { name: 'TagA' });
- const { status, body } = await request(app)
- .post('/tags')
- .set('Authorization', `Bearer ${user.accessToken}`)
- .send({ name: 'TagA' });
- expect(body).toEqual({
- id: expect.any(String),
- name: 'TagA',
- value: 'TagA',
- createdAt: expect.any(String),
- updatedAt: expect.any(String),
- });
- expect(status).toBe(201);
- });
-
- it('should create a nested tag', async () => {
- const parent = await create(admin.accessToken, { name: 'TagA' });
- const { status, body } = await request(app)
- .post('/tags')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ name: 'TagB', parentId: parent.id });
- expect(body).toEqual({
- id: expect.any(String),
- parentId: parent.id,
- name: 'TagB',
- value: 'TagA/TagB',
- createdAt: expect.any(String),
- updatedAt: expect.any(String),
- });
- expect(status).toBe(201);
- });
- });
-
- describe('GET /tags', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).get('/tags');
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require authorization (api key)', async () => {
- const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
- const { status, body } = await request(app).get('/tags').set('x-api-key', secret);
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.missingPermission('tag.read'));
- });
-
- it('should start off empty', async () => {
- const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`);
- expect(body).toEqual([]);
- expect(status).toEqual(200);
- });
-
- it('should return a list of tags', async () => {
- const [tagA, tagB, tagC] = await Promise.all([
- create(admin.accessToken, { name: 'TagA' }),
- create(admin.accessToken, { name: 'TagB' }),
- create(admin.accessToken, { name: 'TagC' }),
- ]);
- const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`);
- expect(body).toHaveLength(3);
- expect(body).toEqual([tagA, tagB, tagC]);
- expect(status).toEqual(200);
- });
-
- it('should return a nested tags', async () => {
- await upsert(admin.accessToken, ['TagA/TagB/TagC', 'TagD']);
- const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(body).toHaveLength(4);
- expect(status).toEqual(200);
-
- const tags = body as TagResponseDto[];
- const tagA = tags.find((tag) => tag.value === 'TagA') as TagResponseDto;
- const tagB = tags.find((tag) => tag.value === 'TagA/TagB') as TagResponseDto;
- const tagC = tags.find((tag) => tag.value === 'TagA/TagB/TagC') as TagResponseDto;
- const tagD = tags.find((tag) => tag.value === 'TagD') as TagResponseDto;
-
- expect(tagA).toEqual(expect.objectContaining({ name: 'TagA', value: 'TagA' }));
- expect(tagB).toEqual(expect.objectContaining({ name: 'TagB', value: 'TagA/TagB', parentId: tagA.id }));
- expect(tagC).toEqual(expect.objectContaining({ name: 'TagC', value: 'TagA/TagB/TagC', parentId: tagB.id }));
- expect(tagD).toEqual(expect.objectContaining({ name: 'TagD', value: 'TagD' }));
- });
- });
-
- describe('PUT /tags', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).put(`/tags`).send({ name: 'TagA/TagB' });
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require authorization (api key)', async () => {
- const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
- const { status, body } = await request(app).put('/tags').set('x-api-key', secret).send({ name: 'TagA' });
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.missingPermission('tag.create'));
- });
-
- it('should upsert tags', async () => {
- const { status, body } = await request(app)
- .put(`/tags`)
- .send({ tags: ['TagA/TagB/TagC/TagD'] })
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(200);
- expect(body).toEqual([expect.objectContaining({ name: 'TagD', value: 'TagA/TagB/TagC/TagD' })]);
- });
-
- it('should upsert tags in parallel without conflicts', async () => {
- const [[tag1], [tag2], [tag3], [tag4]] = await Promise.all([
- upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']),
- upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']),
- upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']),
- upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']),
- ]);
-
- const { id, parentId, createdAt } = tag1;
- for (const tag of [tag1, tag2, tag3, tag4]) {
- expect(tag).toMatchObject({
- id,
- parentId,
- createdAt,
- name: 'TagD',
- value: 'TagA/TagB/TagC/TagD',
- });
- }
- });
- });
-
- describe('PUT /tags/assets', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).put(`/tags/assets`).send({ tagIds: [], assetIds: [] });
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require authorization (api key)', async () => {
- const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
- const { status, body } = await request(app)
- .put('/tags/assets')
- .set('x-api-key', secret)
- .send({ assetIds: [], tagIds: [] });
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.missingPermission('tag.asset'));
- });
-
- it('should skip assets that are not owned by the user', async () => {
- const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([
- create(user.accessToken, { name: 'TagA' }),
- create(user.accessToken, { name: 'TagB' }),
- create(user.accessToken, { name: 'TagC' }),
- utils.createAsset(user.accessToken),
- utils.createAsset(admin.accessToken),
- ]);
- const { status, body } = await request(app)
- .put(`/tags/assets`)
- .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] })
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(200);
- expect(body).toEqual({ count: 3 });
- });
-
- it('should skip tags that are not owned by the user', async () => {
- const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([
- create(user.accessToken, { name: 'TagA' }),
- create(user.accessToken, { name: 'TagB' }),
- create(admin.accessToken, { name: 'TagC' }),
- utils.createAsset(user.accessToken),
- utils.createAsset(user.accessToken),
- ]);
- const { status, body } = await request(app)
- .put(`/tags/assets`)
- .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] })
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(200);
- expect(body).toEqual({ count: 4 });
- });
-
- it('should bulk tag assets', async () => {
- const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([
- create(user.accessToken, { name: 'TagA' }),
- create(user.accessToken, { name: 'TagB' }),
- create(user.accessToken, { name: 'TagC' }),
- utils.createAsset(user.accessToken),
- utils.createAsset(user.accessToken),
- ]);
- const { status, body } = await request(app)
- .put(`/tags/assets`)
- .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] })
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(200);
- expect(body).toEqual({ count: 6 });
- });
- });
-
- describe('GET /tags/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).get(`/tags/${uuidDto.notFound}`);
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require authorization', async () => {
- const tag = await create(user.accessToken, { name: 'TagA' });
- const { status, body } = await request(app)
- .get(`/tags/${tag.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.noPermission);
- });
-
- it('should require authorization (api key)', async () => {
- const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
- const { status, body } = await request(app)
- .get(`/tags/${uuidDto.notFound}`)
- .set('x-api-key', secret)
- .send({ assetIds: [], tagIds: [] });
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.missingPermission('tag.read'));
- });
-
- it('should require a valid uuid', async () => {
- const { status, body } = await request(app)
- .get(`/tags/${uuidDto.invalid}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
- });
-
- it('should get tag details', async () => {
- const tag = await create(user.accessToken, { name: 'TagA' });
- const { status, body } = await request(app)
- .get(`/tags/${tag.id}`)
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(200);
- expect(body).toEqual({
- id: expect.any(String),
- name: 'TagA',
- value: 'TagA',
- createdAt: expect.any(String),
- updatedAt: expect.any(String),
- });
- });
-
- it('should get nested tag details', async () => {
- const tagA = await create(user.accessToken, { name: 'TagA' });
- const tagB = await create(user.accessToken, { name: 'TagB', parentId: tagA.id });
- const tagC = await create(user.accessToken, { name: 'TagC', parentId: tagB.id });
- const tagD = await create(user.accessToken, { name: 'TagD', parentId: tagC.id });
-
- const { status, body } = await request(app)
- .get(`/tags/${tagD.id}`)
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(200);
- expect(body).toEqual({
- id: expect.any(String),
- parentId: tagC.id,
- name: 'TagD',
- value: 'TagA/TagB/TagC/TagD',
- createdAt: expect.any(String),
- updatedAt: expect.any(String),
- });
- });
- });
-
- describe('PUT /tags/:id', () => {
- it('should require authentication', async () => {
- const tag = await create(user.accessToken, { name: 'TagA' });
- const { status, body } = await request(app).put(`/tags/${tag.id}`).send({ color: '#000000' });
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require authorization', async () => {
- const tag = await create(admin.accessToken, { name: 'tagA' });
- const { status, body } = await request(app)
- .put(`/tags/${tag.id}`)
- .send({ color: '#000000' })
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.noPermission);
- });
-
- it('should require authorization (api key)', async () => {
- const tag = await create(user.accessToken, { name: 'TagA' });
- const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
- const { status, body } = await request(app)
- .put(`/tags/${tag.id}`)
- .set('x-api-key', secret)
- .send({ color: '#000000' });
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.missingPermission('tag.update'));
- });
-
- it('should update a tag', async () => {
- const tag = await create(user.accessToken, { name: 'tagA' });
- const { status, body } = await request(app)
- .put(`/tags/${tag.id}`)
- .send({ color: '#000000' })
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(200);
- expect(body).toEqual(expect.objectContaining({ color: `#000000` }));
- });
-
- it('should update a tag color without a # prefix', async () => {
- const tag = await create(user.accessToken, { name: 'tagA' });
- const { status, body } = await request(app)
- .put(`/tags/${tag.id}`)
- .send({ color: '000000' })
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(200);
- expect(body).toEqual(expect.objectContaining({ color: `#000000` }));
- });
- });
-
- describe('DELETE /tags/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).delete(`/tags/${uuidDto.notFound}`);
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require authorization', async () => {
- const tag = await create(user.accessToken, { name: 'TagA' });
- const { status, body } = await request(app)
- .delete(`/tags/${tag.id}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.noPermission);
- });
-
- it('should require authorization (api key)', async () => {
- const tag = await create(user.accessToken, { name: 'TagA' });
- const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
- const { status, body } = await request(app).delete(`/tags/${tag.id}`).set('x-api-key', secret);
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.missingPermission('tag.delete'));
- });
-
- it('should require a valid uuid', async () => {
- const { status, body } = await request(app)
- .delete(`/tags/${uuidDto.invalid}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
- });
-
- it('should delete a tag', async () => {
- const tag = await create(user.accessToken, { name: 'TagA' });
- const { status } = await request(app)
- .delete(`/tags/${tag.id}`)
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(204);
- });
-
- it('should delete a nested tag (root)', async () => {
- const tagA = await create(user.accessToken, { name: 'TagA' });
- await create(user.accessToken, { name: 'TagB', parentId: tagA.id });
- const { status } = await request(app)
- .delete(`/tags/${tagA.id}`)
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(204);
- const tags = await getAllTags({ headers: asBearerAuth(user.accessToken) });
- expect(tags.length).toBe(0);
- });
-
- it('should delete a nested tag (leaf)', async () => {
- const tagA = await create(user.accessToken, { name: 'TagA' });
- const tagB = await create(user.accessToken, { name: 'TagB', parentId: tagA.id });
- const { status } = await request(app)
- .delete(`/tags/${tagB.id}`)
- .set('Authorization', `Bearer ${user.accessToken}`);
- expect(status).toBe(204);
- const tags = await getAllTags({ headers: asBearerAuth(user.accessToken) });
- expect(tags.length).toBe(1);
- expect(tags[0]).toEqual(tagA);
- });
- });
-
- describe('PUT /tags/:id/assets', () => {
- it('should require authentication', async () => {
- const tagA = await create(user.accessToken, { name: 'TagA' });
- const { status, body } = await request(app)
- .put(`/tags/${tagA.id}/assets`)
- .send({ ids: [userAsset.id] });
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require authorization', async () => {
- const tag = await create(user.accessToken, { name: 'TagA' });
- const { status, body } = await request(app)
- .put(`/tags/${tag.id}/assets`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ ids: [userAsset.id] });
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.noPermission);
- });
-
- it('should require authorization (api key)', async () => {
- const tag = await create(user.accessToken, { name: 'TagA' });
- const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
- const { status, body } = await request(app)
- .put(`/tags/${tag.id}/assets`)
- .set('x-api-key', secret)
- .send({ ids: [userAsset.id] });
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.missingPermission('tag.asset'));
- });
-
- it('should be able to tag own asset', async () => {
- const tagA = await create(user.accessToken, { name: 'TagA' });
- const { status, body } = await request(app)
- .put(`/tags/${tagA.id}/assets`)
- .set('Authorization', `Bearer ${user.accessToken}`)
- .send({ ids: [userAsset.id] });
-
- expect(status).toBe(200);
- expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]);
- });
-
- it("should not be able to add assets to another user's tag", async () => {
- const tagA = await create(admin.accessToken, { name: 'TagA' });
- const { status, body } = await request(app)
- .put(`/tags/${tagA.id}/assets`)
- .set('Authorization', `Bearer ${user.accessToken}`)
- .send({ ids: [userAsset.id] });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('Not found or no tag.asset access'));
- });
-
- it('should add duplicate assets only once', async () => {
- const tagA = await create(user.accessToken, { name: 'TagA' });
- const { status, body } = await request(app)
- .put(`/tags/${tagA.id}/assets`)
- .set('Authorization', `Bearer ${user.accessToken}`)
- .send({ ids: [userAsset.id, userAsset.id] });
-
- expect(status).toBe(200);
- expect(body).toEqual([
- expect.objectContaining({ id: userAsset.id, success: true }),
- expect.objectContaining({ id: userAsset.id, success: false, error: 'duplicate' }),
- ]);
- });
- });
-
- describe('DELETE /tags/:id/assets', () => {
- it('should require authentication', async () => {
- const tagA = await create(admin.accessToken, { name: 'TagA' });
- const { status, body } = await request(app)
- .delete(`/tags/${tagA}/assets`)
- .send({ ids: [userAsset.id] });
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require authorization', async () => {
- const tagA = await create(user.accessToken, { name: 'TagA' });
- await tagAssets(
- { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } },
- { headers: asBearerAuth(user.accessToken) },
- );
- const { status, body } = await request(app)
- .delete(`/tags/${tagA.id}/assets`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ ids: [userAsset.id] });
-
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.noPermission);
- });
-
- it('should require authorization (api key)', async () => {
- const tag = await create(user.accessToken, { name: 'TagA' });
- const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
- const { status, body } = await request(app)
- .delete(`/tags/${tag.id}/assets`)
- .set('x-api-key', secret)
- .send({ ids: [userAsset.id] });
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.missingPermission('tag.asset'));
- });
-
- it('should be able to remove own asset from own tag', async () => {
- const tagA = await create(user.accessToken, { name: 'TagA' });
- await tagAssets(
- { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } },
- { headers: asBearerAuth(user.accessToken) },
- );
- const { status, body } = await request(app)
- .delete(`/tags/${tagA.id}/assets`)
- .set('Authorization', `Bearer ${user.accessToken}`)
- .send({ ids: [userAsset.id] });
-
- expect(status).toBe(200);
- expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]);
- });
-
- it.skip('should remove duplicate assets only once', async () => {
- const tagA = await create(user.accessToken, { name: 'TagA' });
- await tagAssets(
- { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } },
- { headers: asBearerAuth(user.accessToken) },
- );
- const { status, body } = await request(app)
- .delete(`/tags/${tagA.id}/assets`)
- .set('Authorization', `Bearer ${user.accessToken}`)
- .send({ ids: [userAsset.id, userAsset.id] });
-
- expect(status).toBe(200);
- expect(body).toEqual([
- expect.objectContaining({ id: userAsset.id, success: true }),
- expect.objectContaining({ id: userAsset.id, success: false, error: 'not_found' }),
- ]);
- });
- });
-});
diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts
deleted file mode 100644
index 7a1a61f946..0000000000
--- a/e2e/src/api/specs/trash.e2e-spec.ts
+++ /dev/null
@@ -1,261 +0,0 @@
-import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk';
-import { existsSync } from 'node:fs';
-import { Socket } from 'socket.io-client';
-import { errorDto } from 'src/responses';
-import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils';
-import request from 'supertest';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
-
-describe('/trash', () => {
- let admin: LoginResponseDto;
- let ws: Socket;
-
- beforeAll(async () => {
- await utils.resetDatabase();
- admin = await utils.adminSetup({ onboarding: false });
- ws = await utils.connectWebsocket(admin.accessToken);
- });
-
- afterAll(() => {
- utils.disconnectWebsocket(ws);
- });
-
- describe('POST /trash/empty', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).post('/trash/empty');
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should empty the trash', async () => {
- const { id: assetId } = await utils.createAsset(admin.accessToken);
- await utils.deleteAssets(admin.accessToken, [assetId]);
-
- const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
- expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true }));
-
- const { status, body } = await request(app)
- .post('/trash/empty')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(200);
- expect(body).toEqual({ count: 1 });
-
- await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId });
-
- const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) });
- expect(after.total).toBe(0);
-
- expect(existsSync(before.originalPath)).toBe(false);
- });
-
- it('should empty the trash with archived assets', async () => {
- const { id: assetId } = await utils.createAsset(admin.accessToken);
- await utils.archiveAssets(admin.accessToken, [assetId]);
- await utils.deleteAssets(admin.accessToken, [assetId]);
-
- const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
- expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true, isArchived: true }));
-
- const { status, body } = await request(app)
- .post('/trash/empty')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(200);
- expect(body).toEqual({ count: 1 });
-
- await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId });
-
- const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) });
- expect(after.total).toBe(0);
-
- expect(existsSync(before.originalPath)).toBe(false);
- });
-
- it('should remove offline assets', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/offline`],
- });
-
- utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
- expect(assets.items.length).toBe(1);
- const asset = assets.items[0];
-
- await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
-
- await utils.scan(admin.accessToken, library.id);
-
- const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id);
- expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true });
-
- utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
-
- const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(200);
-
- await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask');
-
- const assetAfter = await utils.getAssetInfo(admin.accessToken, asset.id);
- expect(assetAfter).toMatchObject({ isTrashed: true, isOffline: true });
- });
-
- it.skip('should not delete offline assets from disk', async () => {
- // Can't be tested at the moment due to no mechanism to forward time
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/offline`],
- });
-
- utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
- expect(assets.items.length).toBe(1);
- const asset = assets.items[0];
-
- await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
-
- await utils.scan(admin.accessToken, library.id);
-
- const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id);
- expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true });
-
- utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
-
- const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(200);
-
- await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask');
-
- const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) });
- expect(after.total).toBe(0);
-
- expect(existsSync(`${testAssetDir}/temp/offline/offline.png`)).toBe(true);
-
- utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
- });
- });
-
- describe('POST /trash/restore', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).post('/trash/restore');
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should restore all trashed assets', async () => {
- const { id: assetId } = await utils.createAsset(admin.accessToken);
- await utils.deleteAssets(admin.accessToken, [assetId]);
-
- const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
- expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true }));
-
- const { status, body } = await request(app)
- .post('/trash/restore')
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(200);
- expect(body).toEqual({ count: 1 });
-
- const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
- expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: false }));
- });
-
- it('should not restore offline assets', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/offline`],
- });
-
- utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
-
- await utils.scan(admin.accessToken, library.id);
-
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
- expect(assets.count).toBe(1);
- const assetId = assets.items[0].id;
-
- await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
-
- await utils.scan(admin.accessToken, library.id);
-
- const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
- expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true }));
-
- const { status } = await request(app).post('/trash/restore').set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(200);
-
- const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
- expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true }));
-
- utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
- });
- });
-
- describe('POST /trash/restore/assets', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).post('/trash/restore/assets');
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should restore a trashed asset by id', async () => {
- const { id: assetId } = await utils.createAsset(admin.accessToken);
- await utils.deleteAssets(admin.accessToken, [assetId]);
-
- const before = await utils.getAssetInfo(admin.accessToken, assetId);
- expect(before.isTrashed).toBe(true);
-
- const { status, body } = await request(app)
- .post('/trash/restore/assets')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ ids: [assetId] });
- expect(status).toBe(200);
- expect(body).toEqual({ count: 1 });
-
- const after = await utils.getAssetInfo(admin.accessToken, assetId);
- expect(after.isTrashed).toBe(false);
- });
-
- it('should not restore an offline asset', async () => {
- const library = await utils.createLibrary(admin.accessToken, {
- ownerId: admin.userId,
- importPaths: [`${testAssetDirInternal}/temp/offline`],
- });
-
- utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
-
- await utils.scan(admin.accessToken, library.id);
- await utils.waitForQueueFinish(admin.accessToken, 'library');
-
- const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
- expect(assets.count).toBe(1);
- const assetId = assets.items[0].id;
-
- await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
-
- await utils.scan(admin.accessToken, library.id);
- await utils.waitForQueueFinish(admin.accessToken, 'library');
-
- const before = await utils.getAssetInfo(admin.accessToken, assetId);
- expect(before.isTrashed).toBe(true);
-
- const { status } = await request(app)
- .post('/trash/restore/assets')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ ids: [assetId] });
- expect(status).toBe(200);
-
- const after = await utils.getAssetInfo(admin.accessToken, assetId);
- expect(after.isTrashed).toBe(true);
-
- utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
- });
- });
-});
diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts
deleted file mode 100644
index 793c508a36..0000000000
--- a/e2e/src/api/specs/user-admin.e2e-spec.ts
+++ /dev/null
@@ -1,383 +0,0 @@
-import {
- LoginResponseDto,
- QueueName,
- createStack,
- deleteUserAdmin,
- getMyUser,
- getUserAdmin,
- getUserPreferencesAdmin,
- login,
-} from '@immich/sdk';
-import { Socket } from 'socket.io-client';
-import { createUserDto, uuidDto } from 'src/fixtures';
-import { errorDto } from 'src/responses';
-import { app, asBearerAuth, utils } from 'src/utils';
-import request from 'supertest';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
-
-describe('/admin/users', () => {
- let websocket: Socket;
-
- let admin: LoginResponseDto;
- let nonAdmin: LoginResponseDto;
- let deletedUser: LoginResponseDto;
- let userToDelete: LoginResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
- admin = await utils.adminSetup({ onboarding: false });
-
- [websocket, nonAdmin, deletedUser, userToDelete] = await Promise.all([
- utils.connectWebsocket(admin.accessToken),
- utils.userSetup(admin.accessToken, createUserDto.user1),
- utils.userSetup(admin.accessToken, createUserDto.user2),
- utils.userSetup(admin.accessToken, createUserDto.user3),
- ]);
-
- await deleteUserAdmin(
- { id: deletedUser.userId, userAdminDeleteDto: {} },
- { headers: asBearerAuth(admin.accessToken) },
- );
- });
-
- afterAll(() => {
- utils.disconnectWebsocket(websocket);
- });
-
- describe('GET /admin/users', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).get(`/admin/users`);
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require authorization', async () => {
- const { status, body } = await request(app)
- .get(`/admin/users`)
- .set('Authorization', `Bearer ${nonAdmin.accessToken}`);
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.forbidden);
- });
-
- it('should hide deleted users by default', async () => {
- const { status, body } = await request(app)
- .get(`/admin/users`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(200);
- expect(body).toHaveLength(3);
- expect(body).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ email: admin.userEmail }),
- expect.objectContaining({ email: nonAdmin.userEmail }),
- expect.objectContaining({ email: userToDelete.userEmail }),
- ]),
- );
- });
-
- it('should include deleted users', async () => {
- const { status, body } = await request(app)
- .get(`/admin/users?withDeleted=true`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toHaveLength(4);
- expect(body).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ email: admin.userEmail }),
- expect.objectContaining({ email: nonAdmin.userEmail }),
- expect.objectContaining({ email: userToDelete.userEmail }),
- expect.objectContaining({ email: deletedUser.userEmail }),
- ]),
- );
- });
- });
-
- describe('POST /admin/users', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).post(`/admin/users`).send(createUserDto.user1);
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require authorization', async () => {
- const { status, body } = await request(app)
- .post(`/admin/users`)
- .set('Authorization', `Bearer ${nonAdmin.accessToken}`)
- .send(createUserDto.user1);
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.forbidden);
- });
-
- for (const key of ['password', 'email', 'name', 'quotaSizeInBytes', 'shouldChangePassword', 'notify']) {
- it(`should not allow null ${key}`, async () => {
- const { status, body } = await request(app)
- .post(`/admin/users`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ ...createUserDto.user1, [key]: null });
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest());
- });
- }
-
- it('should accept `isAdmin`', async () => {
- const { status, body } = await request(app)
- .post(`/admin/users`)
- .send({
- isAdmin: true,
- email: 'user5@immich.cloud',
- password: 'password123',
- name: 'Immich',
- })
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(body).toMatchObject({
- email: 'user5@immich.cloud',
- isAdmin: true,
- shouldChangePassword: true,
- });
- expect(status).toBe(201);
- });
- });
-
- describe('PUT /admin/users/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).put(`/admin/users/${uuidDto.notFound}`);
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require authorization', async () => {
- const { status, body } = await request(app)
- .put(`/admin/users/${uuidDto.notFound}`)
- .set('Authorization', `Bearer ${nonAdmin.accessToken}`);
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.forbidden);
- });
-
- for (const key of ['password', 'email', 'name', 'shouldChangePassword']) {
- it(`should not allow null ${key}`, async () => {
- const { status, body } = await request(app)
- .put(`/admin/users/${uuidDto.notFound}`)
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ [key]: null });
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest());
- });
- }
-
- it('should allow a non-admin to become an admin', async () => {
- const user = await utils.userSetup(admin.accessToken, createUserDto.create('admin2'));
- const { status, body } = await request(app)
- .put(`/admin/users/${user.userId}`)
- .send({ isAdmin: true })
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toMatchObject({ isAdmin: true });
- });
-
- it('ignores updates to profileImagePath', async () => {
- const { status, body } = await request(app)
- .put(`/admin/users/${admin.userId}`)
- .send({ profileImagePath: 'invalid.jpg' })
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toMatchObject({ id: admin.userId, profileImagePath: '' });
- });
-
- it('should update first and last name', async () => {
- const before = await getUserAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
-
- const { status, body } = await request(app)
- .put(`/admin/users/${admin.userId}`)
- .send({ name: 'Name' })
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual({
- ...before,
- updatedAt: expect.any(String),
- name: 'Name',
- });
- expect(before.updatedAt).not.toEqual(body.updatedAt);
- });
-
- it('should update password', async () => {
- const { status, body } = await request(app)
- .put(`/admin/users/${nonAdmin.userId}`)
- .send({ password: 'super-secret' })
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toMatchObject({ email: nonAdmin.userEmail });
-
- const token = await login({ loginCredentialDto: { email: nonAdmin.userEmail, password: 'super-secret' } });
- expect(token.accessToken).toBeDefined();
-
- const user = await getMyUser({ headers: asBearerAuth(token.accessToken) });
- expect(user).toMatchObject({ email: nonAdmin.userEmail });
- });
-
- it('should update the avatar color', async () => {
- const { status, body } = await request(app)
- .put(`/admin/users/${admin.userId}`)
- .send({ avatarColor: 'orange' })
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toMatchObject({ avatarColor: 'orange' });
-
- const after = await getUserAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
- expect(after).toMatchObject({ avatarColor: 'orange' });
- });
- });
-
- describe('PUT /admin/users/:id/preferences', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).put(`/admin/users/${userToDelete.userId}/preferences`);
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should update memories enabled', async () => {
- const before = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
- expect(before).toMatchObject({ memories: { enabled: true } });
-
- const { status, body } = await request(app)
- .put(`/admin/users/${admin.userId}/preferences`)
- .send({ memories: { enabled: false } })
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toMatchObject({ memories: { enabled: false } });
-
- const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
- expect(after).toMatchObject({ memories: { enabled: false } });
- });
-
- it('should update download archive size', async () => {
- const { status, body } = await request(app)
- .put(`/admin/users/${admin.userId}/preferences`)
- .send({ download: { archiveSize: 1_234_567 } })
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toMatchObject({ download: { archiveSize: 1_234_567 } });
-
- const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
- expect(after).toMatchObject({ download: { archiveSize: 1_234_567 } });
- });
- });
-
- describe('DELETE /admin/users/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).delete(`/admin/users/${userToDelete.userId}`);
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require authorization', async () => {
- const { status, body } = await request(app)
- .delete(`/admin/users/${userToDelete.userId}`)
- .set('Authorization', `Bearer ${nonAdmin.accessToken}`);
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.forbidden);
- });
-
- it('should delete user', async () => {
- const { status, body } = await request(app)
- .delete(`/admin/users/${userToDelete.userId}`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toMatchObject({
- id: userToDelete.userId,
- updatedAt: expect.any(String),
- deletedAt: expect.any(String),
- });
- });
-
- it('should hard delete a user', async () => {
- const user = await utils.userSetup(admin.accessToken, createUserDto.create('hard-delete-1'));
-
- const { status, body } = await request(app)
- .delete(`/admin/users/${user.userId}`)
- .send({ force: true })
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toMatchObject({
- id: user.userId,
- updatedAt: expect.any(String),
- deletedAt: expect.any(String),
- });
-
- await utils.waitForWebsocketEvent({ event: 'userDelete', id: user.userId, timeout: 5000 });
- });
-
- it('should hard delete a user with stacked assets', async () => {
- const user = await utils.userSetup(admin.accessToken, createUserDto.create('hard-delete-1'));
-
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user.accessToken),
- utils.createAsset(user.accessToken),
- ]);
-
- await createStack(
- { stackCreateDto: { assetIds: [asset1.id, asset2.id] } },
- { headers: asBearerAuth(user.accessToken) },
- );
-
- await utils.waitForQueueFinish(admin.accessToken, QueueName.BackgroundTask);
-
- const { status, body } = await request(app)
- .delete(`/admin/users/${user.userId}`)
- .send({ force: true })
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toMatchObject({
- id: user.userId,
- updatedAt: expect.any(String),
- deletedAt: expect.any(String),
- });
-
- await utils.waitForWebsocketEvent({ event: 'userDelete', id: user.userId, timeout: 5000 });
- });
- });
-
- describe('POST /admin/users/:id/restore', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).post(`/admin/users/${userToDelete.userId}/restore`);
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should require authorization', async () => {
- const { status, body } = await request(app)
- .post(`/admin/users/${userToDelete.userId}/restore`)
- .set('Authorization', `Bearer ${nonAdmin.accessToken}`);
- expect(status).toBe(403);
- expect(body).toEqual(errorDto.forbidden);
- });
-
- it('should restore a user', async () => {
- const user = await utils.userSetup(admin.accessToken, createUserDto.create('restore'));
-
- await deleteUserAdmin({ id: user.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) });
-
- const { status, body } = await request(app)
- .post(`/admin/users/${user.userId}/restore`)
- .set('Authorization', `Bearer ${admin.accessToken}`);
- expect(status).toBe(200);
- expect(body).toEqual(
- expect.objectContaining({
- id: user.userId,
- email: user.userEmail,
- status: 'active',
- deletedAt: null,
- }),
- );
- });
- });
-});
diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts
index 3f280dddf5..7f322a0d33 100644
--- a/e2e/src/api/specs/user.e2e-spec.ts
+++ b/e2e/src/api/specs/user.e2e-spec.ts
@@ -1,4 +1,4 @@
-import { LoginResponseDto, SharedLinkType, deleteUserAdmin, getMyPreferences, getMyUser, login } from '@immich/sdk';
+import { LoginResponseDto, SharedLinkType, deleteUserAdmin, getMyPreferences, getMyUser, login } from '@server/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
diff --git a/e2e/src/cli/specs/login.e2e-spec.ts b/e2e/src/cli/specs/login.e2e-spec.ts
deleted file mode 100644
index 3bc3ebc9c2..0000000000
--- a/e2e/src/cli/specs/login.e2e-spec.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { Permission } from '@immich/sdk';
-import { stat } from 'node:fs/promises';
-import { app, immichCli, utils } from 'src/utils';
-import { beforeEach, describe, expect, it } from 'vitest';
-
-describe(`immich login`, () => {
- beforeEach(async () => {
- await utils.resetDatabase();
- });
-
- it('should require a url', async () => {
- const { stderr, exitCode } = await immichCli(['login']);
- expect(stderr).toBe("error: missing required argument 'url'");
- expect(exitCode).toBe(1);
- });
-
- it('should require a key', async () => {
- const { stderr, exitCode } = await immichCli(['login', app]);
- expect(stderr).toBe("error: missing required argument 'key'");
- expect(exitCode).toBe(1);
- });
-
- it('should require a valid key', async () => {
- const { stderr, exitCode } = await immichCli(['login', app, 'immich-is-so-cool']);
- expect(stderr).toContain('Failed to connect to server');
- expect(stderr).toContain('Invalid API key');
- expect(stderr).toContain('401');
- expect(exitCode).toBe(1);
- });
-
- it('should login and save auth.yml with 600', async () => {
- const admin = await utils.adminSetup();
- const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
- const { stdout, stderr, exitCode } = await immichCli(['login', app, `${key.secret}`]);
- expect(stdout.split('\n')).toEqual([
- 'Logging in to http://127.0.0.1:2285/api',
- 'Logged in as admin@immich.cloud',
- 'Wrote auth info to /tmp/immich/auth.yml',
- ]);
- expect(stderr).toBe('');
- expect(exitCode).toBe(0);
-
- const stats = await stat('/tmp/immich/auth.yml');
- const mode = (stats.mode & 0o777).toString(8);
- expect(mode).toEqual('600');
- });
-
- it('should login without /api in the url', async () => {
- const admin = await utils.adminSetup();
- const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
- const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), `${key.secret}`]);
- expect(stdout.split('\n')).toEqual([
- 'Logging in to http://127.0.0.1:2285',
- 'Discovered API at http://127.0.0.1:2285/api',
- 'Logged in as admin@immich.cloud',
- 'Wrote auth info to /tmp/immich/auth.yml',
- ]);
- expect(stderr).toBe('');
- expect(exitCode).toBe(0);
- });
-});
diff --git a/e2e/src/cli/specs/server-info.e2e-spec.ts b/e2e/src/cli/specs/server-info.e2e-spec.ts
deleted file mode 100644
index 96c45c8cc0..0000000000
--- a/e2e/src/cli/specs/server-info.e2e-spec.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { immichCli, utils } from 'src/utils';
-import { beforeAll, describe, expect, it } from 'vitest';
-
-describe(`immich server-info`, () => {
- beforeAll(async () => {
- await utils.resetDatabase();
- const admin = await utils.adminSetup();
- await utils.cliLogin(admin.accessToken);
- });
-
- it('should return the server info', async () => {
- const { stderr, stdout, exitCode } = await immichCli(['server-info']);
- expect(stdout.split('\n')).toEqual([
- expect.stringContaining('Server Info (via admin@immich.cloud'),
- ' Url: http://127.0.0.1:2285/api',
- expect.stringContaining('Version:'),
- ' Formats:',
- expect.stringContaining('Images:'),
- expect.stringContaining('Videos:'),
- ' Statistics:',
- ' Images: 0',
- ' Videos: 0',
- ' Total: 0',
- ]);
- expect(stderr).toBe('');
- expect(exitCode).toBe(0);
- });
-});
diff --git a/e2e/src/cli/specs/upload.e2e-spec.ts b/e2e/src/cli/specs/upload.e2e-spec.ts
deleted file mode 100644
index b53b4403f8..0000000000
--- a/e2e/src/cli/specs/upload.e2e-spec.ts
+++ /dev/null
@@ -1,763 +0,0 @@
-import { LoginResponseDto, getAllAlbums, getAssetStatistics } from '@immich/sdk';
-import { cpSync, readFileSync } from 'node:fs';
-import { mkdir, readdir, rm, symlink } from 'node:fs/promises';
-import { asKeyAuth, immichCli, specialCharStrings, testAssetDir, utils } from 'src/utils';
-import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
-
-interface Test {
- test: string;
- paths: string[];
- files: Record;
-}
-
-const tests: Test[] = [
- {
- test: 'should support globbing with *',
- paths: [`/photos*`],
- files: {
- '/photos1/image1.jpg': true,
- '/photos2/image2.jpg': true,
- '/images/image3.jpg': false,
- },
- },
- {
- test: 'should support paths with an asterisk',
- paths: [`/photos*/image1.jpg`],
- files: {
- '/photos*/image1.jpg': true,
- '/photos*/image2.jpg': false,
- '/images/image3.jpg': false,
- },
- },
- {
- test: 'should support paths with a space',
- paths: [`/my photos/image1.jpg`],
- files: {
- '/my photos/image1.jpg': true,
- '/my photos/image2.jpg': false,
- '/images/image3.jpg': false,
- },
- },
- {
- test: 'should support paths with a single quote',
- paths: [`/photos'/image1.jpg`],
- files: {
- "/photos'/image1.jpg": true,
- "/photos'/image2.jpg": false,
- '/images/image3.jpg': false,
- },
- },
- {
- test: 'should support paths with a double quote',
- paths: [`/photos"/image1.jpg`],
- files: {
- '/photos"/image1.jpg': true,
- '/photos"/image2.jpg': false,
- '/images/image3.jpg': false,
- },
- },
- {
- test: 'should support paths with a comma',
- paths: [`/photos, eh/image1.jpg`],
- files: {
- '/photos, eh/image1.jpg': true,
- '/photos, eh/image2.jpg': false,
- '/images/image3.jpg': false,
- },
- },
- {
- test: 'should support paths with an opening brace',
- paths: [`/photos{/image1.jpg`],
- files: {
- '/photos{/image1.jpg': true,
- '/photos{/image2.jpg': false,
- '/images/image3.jpg': false,
- },
- },
- {
- test: 'should support paths with a closing brace',
- paths: [`/photos}/image1.jpg`],
- files: {
- '/photos}/image1.jpg': true,
- '/photos}/image2.jpg': false,
- '/images/image3.jpg': false,
- },
- },
-];
-
-describe(`immich upload`, () => {
- let admin: LoginResponseDto;
- let key: string;
-
- beforeAll(async () => {
- await utils.resetDatabase();
-
- admin = await utils.adminSetup();
- key = await utils.cliLogin(admin.accessToken);
- });
-
- beforeEach(async () => {
- await utils.resetDatabase(['asset', 'album']);
- });
-
- describe(`immich upload /path/to/file.jpg`, () => {
- it('should upload a single file', async () => {
- const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
- expect(stderr).toContain('{message}');
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
- );
- expect(exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(1);
- });
-
- describe(`should accept special cases`, () => {
- for (const { test, paths, files } of tests) {
- it(test, async () => {
- const baseDir = `/tmp/upload/`;
-
- const testPaths = Object.keys(files).map((filePath) => `${baseDir}/${filePath}`);
- testPaths.map((filePath) => utils.createImageFile(filePath));
-
- const commandLine = paths.map((argument) => `${baseDir}/${argument}`);
-
- const expectedCount = Object.entries(files).filter((entry) => entry[1]).length;
-
- const { stderr, stdout, exitCode } = await immichCli(['upload', ...commandLine]);
- expect(stderr).toContain('{message}');
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([expect.stringContaining(`Successfully uploaded ${expectedCount} new asset`)]),
- );
- expect(exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(expectedCount);
-
- testPaths.map((filePath) => utils.removeImageFile(filePath));
- });
- }
- });
-
- it.each(specialCharStrings)(`should upload a multiple files from paths containing %s`, async (testString) => {
- // https://github.com/immich-app/immich/issues/12078
-
- // NOTE: this test must contain more than one path since a related bug is only triggered with multiple paths
-
- const testPaths = [
- `${testAssetDir}/temp/dir1${testString}name/asset.jpg`,
- `${testAssetDir}/temp/dir2${testString}name/asset.jpg`,
- ];
-
- cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, testPaths[0]);
- cpSync(`${testAssetDir}/albums/nature/silver_fir.jpg`, testPaths[1]);
-
- const { stderr, stdout, exitCode } = await immichCli(['upload', ...testPaths]);
- expect(stderr).toContain('{message}');
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([expect.stringContaining('Successfully uploaded 2 new assets')]),
- );
- expect(exitCode).toBe(0);
-
- utils.removeImageFile(testPaths[0]);
- utils.removeImageFile(testPaths[1]);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(2);
- });
-
- it('should skip a duplicate file', async () => {
- const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
- expect(first.stderr).toContain('{message}');
- expect(first.stdout.split('\n')).toEqual(
- expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
- );
- expect(first.exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(1);
-
- const second = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
- expect(second.stderr).toContain('{message}');
- expect(second.stdout.split('\n')).toEqual(
- expect.arrayContaining([
- expect.stringContaining('Found 0 new files and 1 duplicate'),
- expect.stringContaining('All assets were already uploaded, nothing to do'),
- ]),
- );
- expect(second.exitCode).toBe(0);
- });
-
- it('should skip files that do not exist', async () => {
- const { stderr, stdout, exitCode } = await immichCli(['upload', `/path/to/file`]);
- expect(stderr).toBe('');
- expect(stdout.split('\n')).toEqual(expect.arrayContaining([expect.stringContaining('No files found, exiting')]));
- expect(exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(0);
- });
-
- it('should have accurate dry run', async () => {
- const { stderr, stdout, exitCode } = await immichCli([
- 'upload',
- `${testAssetDir}/albums/nature/silver_fir.jpg`,
- '--dry-run',
- ]);
- expect(stderr).toContain('{message}');
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([expect.stringContaining('Would have uploaded 1 asset')]),
- );
- expect(exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(0);
- });
-
- it('dry run should handle duplicates', async () => {
- const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
- expect(first.stderr).toContain('{message}');
- expect(first.stdout.split('\n')).toEqual(
- expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
- );
- expect(first.exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(1);
-
- const second = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--dry-run']);
- expect(second.stderr).toContain('{message}');
- expect(second.stdout.split('\n')).toEqual(
- expect.arrayContaining([
- expect.stringContaining('Found 8 new files and 1 duplicate'),
- expect.stringContaining('Would have uploaded 8 assets'),
- ]),
- );
- expect(second.exitCode).toBe(0);
- });
- });
-
- describe('immich upload --recursive', () => {
- it('should upload a folder recursively', async () => {
- const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
- expect(stderr).toContain('{message}');
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
- );
- expect(exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(9);
- });
- });
-
- describe('immich upload --recursive --album', () => {
- it('should create albums from folder names', async () => {
- const { stderr, stdout, exitCode } = await immichCli([
- 'upload',
- `${testAssetDir}/albums/nature/`,
- '--recursive',
- '--album',
- ]);
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([
- expect.stringContaining('Successfully uploaded 9 new assets'),
- expect.stringContaining('Successfully created 1 new album'),
- expect.stringContaining('Successfully updated 9 assets'),
- ]),
- );
- expect(stderr).toContain('{message}');
- expect(exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(9);
-
- const albums = await getAllAlbums({}, { headers: asKeyAuth(key) });
- expect(albums.length).toBe(1);
- expect(albums[0].albumName).toBe('nature');
- });
-
- it('should add existing assets to albums', async () => {
- const response1 = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
- expect(response1.stdout.split('\n')).toEqual(
- expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
- );
- expect(response1.stderr).toContain('{message}');
- expect(response1.exitCode).toBe(0);
-
- const assets1 = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets1.total).toBe(9);
-
- const albums1 = await getAllAlbums({}, { headers: asKeyAuth(key) });
- expect(albums1.length).toBe(0);
-
- const response2 = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive', '--album']);
- expect(response2.stdout.split('\n')).toEqual(
- expect.arrayContaining([
- expect.stringContaining('All assets were already uploaded, nothing to do.'),
- expect.stringContaining('Successfully updated 9 assets'),
- ]),
- );
- expect(response2.stderr).toContain('{message}');
- expect(response2.exitCode).toBe(0);
-
- const assets2 = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets2.total).toBe(9);
-
- const albums2 = await getAllAlbums({}, { headers: asKeyAuth(key) });
- expect(albums2.length).toBe(1);
- expect(albums2[0].albumName).toBe('nature');
- });
-
- it('should have accurate dry run', async () => {
- const { stderr, stdout, exitCode } = await immichCli([
- 'upload',
- `${testAssetDir}/albums/nature/`,
- '--recursive',
- '--album',
- '--dry-run',
- ]);
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([
- expect.stringContaining('Would have uploaded 9 assets'),
- expect.stringContaining('Would have created 1 new album'),
- expect.stringContaining('Would have updated albums of 9 assets'),
- ]),
- );
- expect(stderr).toContain('{message}');
- expect(exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(0);
-
- const albums = await getAllAlbums({}, { headers: asKeyAuth(key) });
- expect(albums.length).toBe(0);
- });
- });
-
- describe('immich upload --recursive --album-name=e2e', () => {
- it('should create a named album', async () => {
- const { stderr, stdout, exitCode } = await immichCli([
- 'upload',
- `${testAssetDir}/albums/nature/`,
- '--recursive',
- '--album-name=e2e',
- ]);
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([
- expect.stringContaining('Successfully uploaded 9 new assets'),
- expect.stringContaining('Successfully created 1 new album'),
- expect.stringContaining('Successfully updated 9 assets'),
- ]),
- );
- expect(stderr).toContain('{message}');
- expect(exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(9);
-
- const albums = await getAllAlbums({}, { headers: asKeyAuth(key) });
- expect(albums.length).toBe(1);
- expect(albums[0].albumName).toBe('e2e');
- });
-
- it('should have accurate dry run', async () => {
- const { stderr, stdout, exitCode } = await immichCli([
- 'upload',
- `${testAssetDir}/albums/nature/`,
- '--recursive',
- '--album-name=e2e',
- '--dry-run',
- ]);
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([
- expect.stringContaining('Would have uploaded 9 assets'),
- expect.stringContaining('Would have created 1 new album'),
- expect.stringContaining('Would have updated albums of 9 assets'),
- ]),
- );
- expect(stderr).toContain('{message}');
- expect(exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(0);
-
- const albums = await getAllAlbums({}, { headers: asKeyAuth(key) });
- expect(albums.length).toBe(0);
- });
- });
-
- describe('immich upload --delete', () => {
- it('should delete local files if specified', async () => {
- await mkdir(`/tmp/albums/nature`, { recursive: true });
- const filesToLink = await readdir(`${testAssetDir}/albums/nature`);
- for (const file of filesToLink) {
- await symlink(`${testAssetDir}/albums/nature/${file}`, `/tmp/albums/nature/${file}`);
- }
-
- const { stderr, stdout, exitCode } = await immichCli(['upload', `/tmp/albums/nature`, '--delete']);
-
- const files = await readdir(`/tmp/albums/nature`);
- await rm(`/tmp/albums/nature`, { recursive: true });
- expect(files).toEqual([]);
-
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([
- expect.stringContaining('Successfully uploaded 9 new assets'),
- expect.stringContaining('Deleting assets that have been uploaded'),
- ]),
- );
- expect(stderr).toContain('{message}');
- expect(exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(9);
- });
-
- it('should have accurate dry run', async () => {
- await mkdir(`/tmp/albums/nature`, { recursive: true });
- const filesToLink = await readdir(`${testAssetDir}/albums/nature`);
- for (const file of filesToLink) {
- await symlink(`${testAssetDir}/albums/nature/${file}`, `/tmp/albums/nature/${file}`);
- }
-
- const { stderr, stdout, exitCode } = await immichCli(['upload', `/tmp/albums/nature`, '--delete', '--dry-run']);
-
- const files = await readdir(`/tmp/albums/nature`);
- await rm(`/tmp/albums/nature`, { recursive: true });
- expect(files.length).toBe(9);
-
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([
- expect.stringContaining('Would have uploaded 9 assets'),
- expect.stringContaining('Would have deleted 9 local assets'),
- ]),
- );
- expect(stderr).toContain('{message}');
- expect(exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(0);
- });
- });
-
- describe('immich upload --delete-duplicates', () => {
- it('should delete local duplicate files', async () => {
- const {
- stderr: firstStderr,
- stdout: firstStdout,
- exitCode: firstExitCode,
- } = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
- expect(firstStderr).toContain('{message}');
- expect(firstStdout.split('\n')).toEqual(
- expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
- );
- expect(firstExitCode).toBe(0);
-
- await mkdir(`/tmp/albums/nature`, { recursive: true });
- await symlink(`${testAssetDir}/albums/nature/silver_fir.jpg`, `/tmp/albums/nature/silver_fir.jpg`);
-
- // Upload with --delete-duplicates flag
- const { stderr, stdout, exitCode } = await immichCli([
- 'upload',
- `/tmp/albums/nature/silver_fir.jpg`,
- '--delete-duplicates',
- ]);
-
- // Check that the duplicate file was deleted
- const files = await readdir(`/tmp/albums/nature`);
- await rm(`/tmp/albums/nature`, { recursive: true });
- expect(files.length).toBe(0);
-
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([
- expect.stringContaining('Found 0 new files and 1 duplicate'),
- expect.stringContaining('All assets were already uploaded, nothing to do'),
- ]),
- );
- expect(stderr).toContain('{message}');
- expect(exitCode).toBe(0);
-
- // Verify no new assets were uploaded
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(1);
- });
-
- it('should have accurate dry run with --delete-duplicates', async () => {
- const {
- stderr: firstStderr,
- stdout: firstStdout,
- exitCode: firstExitCode,
- } = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
- expect(firstStderr).toContain('{message}');
- expect(firstStdout.split('\n')).toEqual(
- expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
- );
- expect(firstExitCode).toBe(0);
-
- await mkdir(`/tmp/albums/nature`, { recursive: true });
- await symlink(`${testAssetDir}/albums/nature/silver_fir.jpg`, `/tmp/albums/nature/silver_fir.jpg`);
-
- // Upload with --delete-duplicates and --dry-run flags
- const { stderr, stdout, exitCode } = await immichCli([
- 'upload',
- `/tmp/albums/nature/silver_fir.jpg`,
- '--delete-duplicates',
- '--dry-run',
- ]);
-
- // Check that the duplicate file was NOT deleted in dry run mode
- const files = await readdir(`/tmp/albums/nature`);
- await rm(`/tmp/albums/nature`, { recursive: true });
- expect(files.length).toBe(1);
-
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([
- expect.stringContaining('Found 0 new files and 1 duplicate'),
- expect.stringContaining('Would have deleted 1 local asset'),
- ]),
- );
- expect(stderr).toContain('{message}');
- expect(exitCode).toBe(0);
-
- // Verify no new assets were uploaded
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(1);
- });
-
- it('should work with both --delete and --delete-duplicates flags', async () => {
- // First, upload a file to create a duplicate on the server
- const {
- stderr: firstStderr,
- stdout: firstStdout,
- exitCode: firstExitCode,
- } = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
- expect(firstStderr).toContain('{message}');
- expect(firstStdout.split('\n')).toEqual(
- expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
- );
- expect(firstExitCode).toBe(0);
-
- // Both new and duplicate files
- await mkdir(`/tmp/albums/nature`, { recursive: true });
- await symlink(`${testAssetDir}/albums/nature/silver_fir.jpg`, `/tmp/albums/nature/silver_fir.jpg`); // duplicate
- await symlink(`${testAssetDir}/albums/nature/el_torcal_rocks.jpg`, `/tmp/albums/nature/el_torcal_rocks.jpg`); // new
-
- // Upload with both --delete and --delete-duplicates flags
- const { stderr, stdout, exitCode } = await immichCli([
- 'upload',
- `/tmp/albums/nature`,
- '--delete',
- '--delete-duplicates',
- ]);
-
- // Check that both files were deleted (new file due to --delete, duplicate due to --delete-duplicates)
- const files = await readdir(`/tmp/albums/nature`);
- await rm(`/tmp/albums/nature`, { recursive: true });
- expect(files.length).toBe(0);
-
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([
- expect.stringContaining('Found 1 new files and 1 duplicate'),
- expect.stringContaining('Successfully uploaded 1 new asset'),
- expect.stringContaining('Deleting assets that have been uploaded'),
- ]),
- );
- expect(stderr).toContain('{message}');
- expect(exitCode).toBe(0);
-
- // Verify one new asset was uploaded (total should be 2 now)
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(2);
- });
-
- it('should only delete duplicates when --delete-duplicates is used without --delete', async () => {
- const {
- stderr: firstStderr,
- stdout: firstStdout,
- exitCode: firstExitCode,
- } = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
- expect(firstStderr).toContain('{message}');
- expect(firstStdout.split('\n')).toEqual(
- expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
- );
- expect(firstExitCode).toBe(0);
-
- // Both new and duplicate files
- await mkdir(`/tmp/albums/nature`, { recursive: true });
- await symlink(`${testAssetDir}/albums/nature/silver_fir.jpg`, `/tmp/albums/nature/silver_fir.jpg`); // duplicate
- await symlink(`${testAssetDir}/albums/nature/el_torcal_rocks.jpg`, `/tmp/albums/nature/el_torcal_rocks.jpg`); // new
-
- // Upload with only --delete-duplicates flag
- const { stderr, stdout, exitCode } = await immichCli(['upload', `/tmp/albums/nature`, '--delete-duplicates']);
-
- // Check that only the duplicate was deleted, new file should remain
- const files = await readdir(`/tmp/albums/nature`);
- await rm(`/tmp/albums/nature`, { recursive: true });
- expect(files).toEqual(['el_torcal_rocks.jpg']);
-
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([
- expect.stringContaining('Found 1 new files and 1 duplicate'),
- expect.stringContaining('Successfully uploaded 1 new asset'),
- ]),
- );
- expect(stderr).toContain('{message}');
- expect(exitCode).toBe(0);
-
- // Verify one new asset was uploaded (total should be 2 now)
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(2);
- });
- });
-
- describe('immich upload --skip-hash', () => {
- it('should skip hashing', async () => {
- const filename = `albums/nature/silver_fir.jpg`;
- await utils.createAsset(admin.accessToken, {
- assetData: {
- bytes: readFileSync(`${testAssetDir}/${filename}`),
- filename: 'silver_fit.jpg',
- },
- });
- const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/${filename}`, '--skip-hash']);
-
- expect(stderr).toBe('');
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([
- 'Skipping hash check, assuming all files are new',
- expect.stringContaining('Successfully uploaded 0 new assets'),
- expect.stringContaining('Skipped 1 duplicate asset'),
- ]),
- );
- expect(exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(1);
- });
-
- it('should throw an error if attempting dry run', async () => {
- const { stderr, stdout, exitCode } = await immichCli([
- 'upload',
- `${testAssetDir}/albums/nature/`,
- '--skip-hash',
- '--dry-run',
- ]);
-
- expect(stdout).toBe('');
- expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '-h, --skip-hash'`);
- expect(exitCode).not.toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(0);
- });
- });
-
- describe('immich upload --concurrency ', () => {
- it('should work', async () => {
- const { stderr, stdout, exitCode } = await immichCli([
- 'upload',
- `${testAssetDir}/albums/nature/`,
- '--concurrency',
- '2',
- ]);
-
- expect(stderr).toContain('{message}');
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([
- 'Found 9 new files and 0 duplicates',
- expect.stringContaining('Successfully uploaded 9 new assets'),
- ]),
- );
- expect(exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(9);
- });
-
- it('should reject string argument', async () => {
- const { stderr, exitCode } = await immichCli([
- 'upload',
- `${testAssetDir}/albums/nature/`,
- '--concurrency string',
- ]);
-
- expect(stderr).toContain('unknown option');
- expect(exitCode).not.toBe(0);
- });
-
- it('should reject command without number', async () => {
- const { stderr, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--concurrency']);
-
- expect(stderr).toContain('argument missing');
- expect(exitCode).not.toBe(0);
- });
- });
-
- describe('immich upload --ignore ', () => {
- it('should work', async () => {
- const { stderr, stdout, exitCode } = await immichCli([
- 'upload',
- `${testAssetDir}/albums/nature/`,
- '--ignore',
- 'silver_fir.jpg',
- ]);
-
- expect(stderr).toContain('{message}');
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([
- 'Found 8 new files and 0 duplicates',
- expect.stringContaining('Successfully uploaded 8 new assets'),
- ]),
- );
- expect(exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(8);
- });
-
- it('should ignore assets matching glob pattern', async () => {
- const { stderr, stdout, exitCode } = await immichCli([
- 'upload',
- `${testAssetDir}/albums/nature/`,
- '--ignore',
- '!(*_*_*).jpg',
- ]);
-
- expect(stderr).toContain('{message}');
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([
- 'Found 1 new files and 0 duplicates',
- expect.stringContaining('Successfully uploaded 1 new asset'),
- ]),
- );
- expect(exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(1);
- });
-
- it('should have accurate dry run', async () => {
- const { stderr, stdout, exitCode } = await immichCli([
- 'upload',
- `${testAssetDir}/albums/nature/`,
- '--ignore',
- 'silver_fir.jpg',
- '--dry-run',
- ]);
-
- expect(stderr).toContain('{message}');
- expect(stdout.split('\n')).toEqual(
- expect.arrayContaining([
- 'Found 8 new files and 0 duplicates',
- expect.stringContaining('Would have uploaded 8 assets'),
- ]),
- );
- expect(exitCode).toBe(0);
-
- const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
- expect(assets.total).toBe(0);
- });
- });
-});
diff --git a/e2e/src/cli/specs/version.e2e-spec.ts b/e2e/src/cli/specs/version.e2e-spec.ts
deleted file mode 100644
index 56a0d8b0b1..0000000000
--- a/e2e/src/cli/specs/version.e2e-spec.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { readFileSync } from 'node:fs';
-import { immichCli } from 'src/utils';
-import { describe, expect, it } from 'vitest';
-
-const pkg = JSON.parse(readFileSync('../cli/package.json', 'utf8'));
-
-describe(`immich --version`, () => {
- describe('immich --version', () => {
- it('should print the cli version', async () => {
- const { stdout, stderr, exitCode } = await immichCli(['--version']);
- expect(stdout).toEqual(pkg.version);
- expect(stderr).toEqual('');
- expect(exitCode).toBe(0);
- });
- });
-
- describe('immich -V', () => {
- it('should print the cli version', async () => {
- const { stdout, stderr, exitCode } = await immichCli(['-V']);
- expect(stdout).toEqual(pkg.version);
- expect(stderr).toEqual('');
- expect(exitCode).toBe(0);
- });
- });
-});
diff --git a/e2e/src/generators.ts b/e2e/src/generators.ts
deleted file mode 100644
index 5e4895d708..0000000000
--- a/e2e/src/generators.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { PNG } from 'pngjs';
-
-const createPNG = (r: number, g: number, b: number) => {
- const image = new PNG({ width: 1, height: 1 });
- image.data[0] = r;
- image.data[1] = g;
- image.data[2] = b;
- image.data[3] = 255;
- return PNG.sync.write(image);
-};
-
-function* newPngFactory() {
- for (let r = 0; r < 255; r++) {
- for (let g = 0; g < 255; g++) {
- for (let b = 0; b < 255; b++) {
- yield createPNG(r, g, b);
- }
- }
- }
-}
-
-const pngFactory = newPngFactory();
-
-export const makeRandomImage = () => {
- const { value } = pngFactory.next();
- if (!value) {
- throw new Error('Ran out of random asset data');
- }
- return value;
-};
diff --git a/e2e/src/generators/memory.ts b/e2e/src/generators/memory.ts
deleted file mode 100644
index c17b4aa476..0000000000
--- a/e2e/src/generators/memory.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { generateMemoriesFromTimeline, generateMemory } from './memory/model-objects';
-export type { MemoryConfig, MemoryYearConfig } from './memory/model-objects';
diff --git a/e2e/src/generators/memory/model-objects.ts b/e2e/src/generators/memory/model-objects.ts
deleted file mode 100644
index 1bcc703ed8..0000000000
--- a/e2e/src/generators/memory/model-objects.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { faker } from '@faker-js/faker';
-import { MemoryType, type MemoryResponseDto, type OnThisDayDto } from '@immich/sdk';
-import { DateTime } from 'luxon';
-import { toAssetResponseDto } from 'src/generators/timeline/rest-response';
-import type { MockTimelineAsset } from 'src/generators/timeline/timeline-config';
-import { SeededRandom, selectRandomMultiple } from 'src/generators/timeline/utils';
-
-export type MemoryConfig = {
- id?: string;
- ownerId: string;
- year: number;
- memoryAt: string;
- isSaved?: boolean;
-};
-
-export type MemoryYearConfig = {
- year: number;
- assetCount: number;
-};
-
-export function generateMemory(config: MemoryConfig, assets: MockTimelineAsset[]): MemoryResponseDto {
- const now = new Date().toISOString();
- const memoryId = config.id ?? faker.string.uuid();
-
- return {
- id: memoryId,
- assets: assets.map((asset) => toAssetResponseDto(asset)),
- data: { year: config.year } as OnThisDayDto,
- memoryAt: config.memoryAt,
- createdAt: now,
- updatedAt: now,
- isSaved: config.isSaved ?? false,
- ownerId: config.ownerId,
- type: MemoryType.OnThisDay,
- };
-}
-
-export function generateMemoriesFromTimeline(
- timelineAssets: MockTimelineAsset[],
- ownerId: string,
- memoryConfigs: MemoryYearConfig[],
- seed: number = 42,
-): MemoryResponseDto[] {
- const rng = new SeededRandom(seed);
- const memories: MemoryResponseDto[] = [];
- const usedAssetIds = new Set();
-
- for (const config of memoryConfigs) {
- const yearAssets = timelineAssets.filter((asset) => {
- const assetYear = DateTime.fromISO(asset.fileCreatedAt).year;
- return assetYear === config.year && !usedAssetIds.has(asset.id);
- });
-
- if (yearAssets.length === 0) {
- continue;
- }
-
- const countToSelect = Math.min(config.assetCount, yearAssets.length);
- const selectedAssets = selectRandomMultiple(yearAssets, countToSelect, rng);
-
- for (const asset of selectedAssets) {
- usedAssetIds.add(asset.id);
- }
-
- selectedAssets.sort(
- (a, b) => DateTime.fromISO(b.fileCreatedAt).diff(DateTime.fromISO(a.fileCreatedAt)).milliseconds,
- );
-
- const memoryAt = DateTime.now().set({ year: config.year }).toISO()!;
-
- memories.push(
- generateMemory(
- {
- ownerId,
- year: config.year,
- memoryAt,
- },
- selectedAssets,
- ),
- );
- }
-
- return memories;
-}
diff --git a/e2e/src/generators/timeline.ts b/e2e/src/generators/timeline.ts
deleted file mode 100644
index d4c91d667f..0000000000
--- a/e2e/src/generators/timeline.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-export { generateTimelineData } from './timeline/model-objects';
-
-export { createDefaultTimelineConfig, validateTimelineConfig } from './timeline/timeline-config';
-
-export type {
- MockAlbum,
- MonthSpec,
- SerializedTimelineData,
- MockTimelineAsset as TimelineAssetConfig,
- TimelineConfig,
- MockTimelineData as TimelineData,
-} from './timeline/timeline-config';
-
-export {
- getAlbum,
- getAsset,
- getTimeBucket,
- getTimeBuckets,
- toAssetResponseDto,
- toColumnarFormat,
-} from './timeline/rest-response';
-
-export type { Changes } from './timeline/rest-response';
-
-export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images';
-
-export {
- SeededRandom,
- getMockAsset,
- parseTimeBucketKey,
- selectRandom,
- selectRandomDays,
- selectRandomMultiple,
-} from './timeline/utils';
-
-export { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './timeline/distribution-patterns';
-export type { DayPattern, MonthDistribution } from './timeline/distribution-patterns';
diff --git a/e2e/src/generators/timeline/distribution-patterns.ts b/e2e/src/generators/timeline/distribution-patterns.ts
deleted file mode 100644
index ae621fd9c5..0000000000
--- a/e2e/src/generators/timeline/distribution-patterns.ts
+++ /dev/null
@@ -1,183 +0,0 @@
-import { generateConsecutiveDays, generateDayAssets } from 'src/generators/timeline/model-objects';
-import { SeededRandom, selectRandomDays } from 'src/generators/timeline/utils';
-import type { MockTimelineAsset } from './timeline-config';
-import { GENERATION_CONSTANTS } from './timeline-config';
-
-type AssetDistributionStrategy = (rng: SeededRandom) => number;
-
-type DayDistributionStrategy = (
- year: number,
- month: number,
- daysInMonth: number,
- totalAssets: number,
- ownerId: string,
- rng: SeededRandom,
-) => MockTimelineAsset[];
-
-/**
- * Strategies for determining total asset count per month
- */
-export const ASSET_DISTRIBUTION: Record = {
- empty: null, // Special case - handled separately
- sparse: (rng) => rng.nextInt(3, 9), // 3-8 assets
- medium: (rng) => rng.nextInt(15, 31), // 15-30 assets
- dense: (rng) => rng.nextInt(50, 81), // 50-80 assets
- 'very-dense': (rng) => rng.nextInt(80, 151), // 80-150 assets
-};
-
-/**
- * Strategies for distributing assets across days within a month
- */
-export const DAY_DISTRIBUTION: Record = {
- 'single-day': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
- // All assets on one day in the middle of the month
- const day = Math.floor(daysInMonth / 2);
- return generateDayAssets(year, month, day, totalAssets, ownerId, rng);
- },
-
- 'consecutive-large': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
- // 3-5 consecutive days with evenly distributed assets
- const numDays = Math.min(5, Math.floor(totalAssets / 15));
- const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
- return generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
- },
-
- 'consecutive-small': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
- // Multiple consecutive days with 1-3 assets each (side-by-side layout)
- const assets: MockTimelineAsset[] = [];
- const numDays = Math.min(totalAssets, Math.floor(daysInMonth / 2));
- const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
- let assetIndex = 0;
-
- for (let i = 0; i < numDays && assetIndex < totalAssets; i++) {
- const dayAssets = Math.min(3, rng.nextInt(1, 4));
- const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
- // Create a new RNG for this day
- const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
- assets.push(...generateDayAssets(year, month, startDay + i, actualAssets, ownerId, dayRng));
- assetIndex += actualAssets;
- }
- return assets;
- },
-
- alternating: (year, month, daysInMonth, totalAssets, ownerId, rng) => {
- // Alternate between large (15-25) and small (1-3) days
- const assets: MockTimelineAsset[] = [];
- let day = 1;
- let isLarge = true;
- let assetIndex = 0;
-
- while (assetIndex < totalAssets && day <= daysInMonth) {
- const dayAssets = isLarge ? Math.min(25, rng.nextInt(15, 26)) : rng.nextInt(1, 4);
-
- const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
- // Create a new RNG for this day
- const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
- assets.push(...generateDayAssets(year, month, day, actualAssets, ownerId, dayRng));
- assetIndex += actualAssets;
-
- day += isLarge ? 1 : 1; // Could add gaps here
- isLarge = !isLarge;
- }
- return assets;
- },
-
- 'sparse-scattered': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
- // Spread assets across random days with gaps
- const assets: MockTimelineAsset[] = [];
- const numDays = Math.min(totalAssets, Math.floor(daysInMonth * GENERATION_CONSTANTS.SPARSE_DAY_COVERAGE));
- const daysWithPhotos = selectRandomDays(daysInMonth, numDays, rng);
- let assetIndex = 0;
-
- for (let i = 0; i < daysWithPhotos.length && assetIndex < totalAssets; i++) {
- const dayAssets =
- Math.floor(totalAssets / numDays) + (i === daysWithPhotos.length - 1 ? totalAssets % numDays : 0);
- // Create a new RNG for this day
- const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
- assets.push(...generateDayAssets(year, month, daysWithPhotos[i], dayAssets, ownerId, dayRng));
- assetIndex += dayAssets;
- }
- return assets;
- },
-
- 'start-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
- // Most assets in first week
- const assets: MockTimelineAsset[] = [];
- const firstWeekAssets = Math.floor(totalAssets * 0.7);
- const remainingAssets = totalAssets - firstWeekAssets;
-
- // First 7 days
- assets.push(...generateConsecutiveDays(year, month, 1, 7, firstWeekAssets, ownerId, rng));
-
- // Remaining scattered
- if (remainingAssets > 0) {
- const midDay = Math.floor(daysInMonth / 2);
- // Create a new RNG for the remaining assets
- const remainingRng = new SeededRandom(rng.nextInt(0, 1_000_000));
- assets.push(...generateDayAssets(year, month, midDay, remainingAssets, ownerId, remainingRng));
- }
- return assets;
- },
-
- 'end-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
- // Most assets in last week
- const assets: MockTimelineAsset[] = [];
- const lastWeekAssets = Math.floor(totalAssets * 0.7);
- const remainingAssets = totalAssets - lastWeekAssets;
-
- // Remaining at start
- if (remainingAssets > 0) {
- // Create a new RNG for the start assets
- const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
- assets.push(...generateDayAssets(year, month, 2, remainingAssets, ownerId, startRng));
- }
-
- // Last 7 days
- const startDay = daysInMonth - 6;
- assets.push(...generateConsecutiveDays(year, month, startDay, 7, lastWeekAssets, ownerId, rng));
- return assets;
- },
-
- 'mid-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
- // Most assets in middle of month
- const assets: MockTimelineAsset[] = [];
- const midAssets = Math.floor(totalAssets * 0.7);
- const sideAssets = Math.floor((totalAssets - midAssets) / 2);
-
- // Start
- if (sideAssets > 0) {
- // Create a new RNG for the start assets
- const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
- assets.push(...generateDayAssets(year, month, 2, sideAssets, ownerId, startRng));
- }
-
- // Middle
- const midStart = Math.floor(daysInMonth / 2) - 3;
- assets.push(...generateConsecutiveDays(year, month, midStart, 7, midAssets, ownerId, rng));
-
- // End
- const endAssets = totalAssets - midAssets - sideAssets;
- if (endAssets > 0) {
- // Create a new RNG for the end assets
- const endRng = new SeededRandom(rng.nextInt(0, 1_000_000));
- assets.push(...generateDayAssets(year, month, daysInMonth - 1, endAssets, ownerId, endRng));
- }
- return assets;
- },
-};
-export type MonthDistribution =
- | 'empty' // 0 assets
- | 'sparse' // 3-8 assets
- | 'medium' // 15-30 assets
- | 'dense' // 50-80 assets
- | 'very-dense'; // 80-150 assets
-
-export type DayPattern =
- | 'single-day' // All images in one day
- | 'consecutive-large' // Multiple days with 15-25 images each
- | 'consecutive-small' // Multiple days with 1-3 images each (side-by-side)
- | 'alternating' // Alternating large/small days
- | 'sparse-scattered' // Few images scattered across month
- | 'start-heavy' // Most images at start of month
- | 'end-heavy' // Most images at end of month
- | 'mid-heavy'; // Most images in middle of month
diff --git a/e2e/src/generators/timeline/images.ts b/e2e/src/generators/timeline/images.ts
deleted file mode 100644
index 69ec576714..0000000000
--- a/e2e/src/generators/timeline/images.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import sharp from 'sharp';
-import { SeededRandom } from 'src/generators/timeline/utils';
-
-export const randomThumbnail = async (seed: string, ratio: number) => {
- const height = 235;
- const width = Math.round(height * ratio);
- return randomImageFromString(seed, { width, height });
-};
-
-export const randomPreview = async (seed: string, ratio: number) => {
- const height = 500;
- const width = Math.round(height * ratio);
- return randomImageFromString(seed, { width, height });
-};
-
-export const randomImageFromString = async (
- seed: string = '',
- { width = 100, height = 100 }: { width: number; height: number },
-) => {
- // Convert string to number for seeding
- let seedNumber = 0;
- for (let i = 0; i < seed.length; i++) {
- seedNumber = (seedNumber << 5) - seedNumber + (seed.codePointAt(i) ?? 0);
- seedNumber = seedNumber & seedNumber; // Convert to 32bit integer
- }
- return randomImage(new SeededRandom(Math.abs(seedNumber)), { width, height });
-};
-
-export const randomImage = async (rng: SeededRandom, { width, height }: { width: number; height: number }) => {
- const r1 = rng.nextInt(0, 256);
- const g1 = rng.nextInt(0, 256);
- const b1 = rng.nextInt(0, 256);
- const r2 = rng.nextInt(0, 256);
- const g2 = rng.nextInt(0, 256);
- const b2 = rng.nextInt(0, 256);
- const patternType = rng.nextInt(0, 5);
-
- let svgPattern = '';
-
- switch (patternType) {
- case 0: {
- // Solid color
- svgPattern = ``;
- break;
- }
-
- case 1: {
- // Horizontal stripes
- const stripeHeight = 10;
- svgPattern = ``;
- break;
- }
-
- case 2: {
- // Vertical stripes
- const stripeWidth = 10;
- svgPattern = ``;
- break;
- }
-
- case 3: {
- // Checkerboard
- const squareSize = 10;
- svgPattern = ``;
- break;
- }
-
- case 4: {
- // Diagonal stripes
- svgPattern = ``;
- break;
- }
- }
-
- const svgBuffer = Buffer.from(svgPattern);
- const jpegData = await sharp(svgBuffer).jpeg({ quality: 50 }).toBuffer();
- return jpegData;
-};
diff --git a/e2e/src/generators/timeline/model-objects.ts b/e2e/src/generators/timeline/model-objects.ts
deleted file mode 100644
index f06596fd1a..0000000000
--- a/e2e/src/generators/timeline/model-objects.ts
+++ /dev/null
@@ -1,265 +0,0 @@
-/**
- * Generator functions for timeline model objects
- */
-
-import { faker } from '@faker-js/faker';
-import { AssetVisibility } from '@immich/sdk';
-import { DateTime } from 'luxon';
-import { writeFileSync } from 'node:fs';
-import { SeededRandom } from 'src/generators/timeline/utils';
-import type { DayPattern, MonthDistribution } from './distribution-patterns';
-import { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './distribution-patterns';
-import type { MockTimelineAsset, MockTimelineData, SerializedTimelineData, TimelineConfig } from './timeline-config';
-import { ASPECT_RATIO_WEIGHTS, GENERATION_CONSTANTS, validateTimelineConfig } from './timeline-config';
-
-/**
- * Generate a random aspect ratio based on weighted probabilities
- */
-export function generateAspectRatio(rng: SeededRandom): string {
- const random = rng.next();
- let cumulative = 0;
-
- for (const [ratio, weight] of Object.entries(ASPECT_RATIO_WEIGHTS)) {
- cumulative += weight;
- if (random < cumulative) {
- return ratio;
- }
- }
- return '16:9'; // Default fallback
-}
-
-export function generateThumbhash(rng: SeededRandom): string {
- return Array.from({ length: 10 }, () => rng.nextInt(0, 256).toString(16).padStart(2, '0')).join('');
-}
-
-export function generateDuration(rng: SeededRandom): string {
- return `${rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS)}.${rng.nextInt(0, 1000).toString().padStart(3, '0')}`;
-}
-
-export function generateUUID(): string {
- return faker.string.uuid();
-}
-
-export function generateAsset(
- year: number,
- month: number,
- day: number,
- ownerId: string,
- rng: SeededRandom,
-): MockTimelineAsset {
- const from = DateTime.fromObject({ year, month, day }).setZone('UTC');
- const to = from.endOf('day');
- const date = faker.date.between({ from: from.toJSDate(), to: to.toJSDate() });
- const isVideo = rng.next() < GENERATION_CONSTANTS.VIDEO_PROBABILITY;
-
- const assetId = generateUUID();
- const hasGPS = rng.next() < GENERATION_CONSTANTS.GPS_PERCENTAGE;
-
- const ratio = generateAspectRatio(rng);
-
- const asset: MockTimelineAsset = {
- id: assetId,
- ownerId,
- ratio: Number.parseFloat(ratio.split(':')[0]) / Number.parseFloat(ratio.split(':')[1]),
- thumbhash: generateThumbhash(rng),
- localDateTime: date.toISOString(),
- fileCreatedAt: date.toISOString(),
- isFavorite: rng.next() < GENERATION_CONSTANTS.FAVORITE_PROBABILITY,
- isTrashed: false,
- isVideo,
- isImage: !isVideo,
- duration: isVideo ? generateDuration(rng) : null,
- projectionType: null,
- livePhotoVideoId: null,
- city: hasGPS ? faker.location.city() : null,
- country: hasGPS ? faker.location.country() : null,
- people: null,
- latitude: hasGPS ? faker.location.latitude() : null,
- longitude: hasGPS ? faker.location.longitude() : null,
- visibility: AssetVisibility.Timeline,
- stack: null,
- fileSizeInByte: faker.number.int({ min: 510, max: 5_000_000 }),
- checksum: faker.string.alphanumeric({ length: 5 }),
- };
-
- return asset;
-}
-
-/**
- * Generate assets for a specific day
- */
-export function generateDayAssets(
- year: number,
- month: number,
- day: number,
- assetCount: number,
- ownerId: string,
- rng: SeededRandom,
-): MockTimelineAsset[] {
- return Array.from({ length: assetCount }, () => generateAsset(year, month, day, ownerId, rng));
-}
-
-/**
- * Distribute assets evenly across consecutive days
- *
- * @returns Array of generated timeline assets
- */
-export function generateConsecutiveDays(
- year: number,
- month: number,
- startDay: number,
- numDays: number,
- totalAssets: number,
- ownerId: string,
- rng: SeededRandom,
-): MockTimelineAsset[] {
- const assets: MockTimelineAsset[] = [];
- const assetsPerDay = Math.floor(totalAssets / numDays);
-
- for (let i = 0; i < numDays; i++) {
- const dayAssets =
- i === numDays - 1
- ? totalAssets - assetsPerDay * (numDays - 1) // Remainder on last day
- : assetsPerDay;
- // Create a new RNG with a different seed for each day
- const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000) + i * 100);
- assets.push(...generateDayAssets(year, month, startDay + i, dayAssets, ownerId, dayRng));
- }
-
- return assets;
-}
-
-/**
- * Generate assets for a month with specified distribution pattern
- */
-export function generateMonthAssets(
- year: number,
- month: number,
- ownerId: string,
- distribution: MonthDistribution = 'medium',
- pattern: DayPattern = 'consecutive-large',
- rng: SeededRandom,
-): MockTimelineAsset[] {
- const daysInMonth = new Date(year, month, 0).getDate();
-
- if (distribution === 'empty') {
- return [];
- }
-
- const distributionStrategy = ASSET_DISTRIBUTION[distribution];
- if (!distributionStrategy) {
- console.warn(`Unknown distribution: ${distribution}, defaulting to medium`);
- return [];
- }
- const totalAssets = distributionStrategy(rng);
-
- const dayStrategy = DAY_DISTRIBUTION[pattern];
- if (!dayStrategy) {
- console.warn(`Unknown pattern: ${pattern}, defaulting to consecutive-large`);
- // Fallback to consecutive-large pattern
- const numDays = Math.min(5, Math.floor(totalAssets / 15));
- const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
- const assets = generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
- assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
- return assets;
- }
-
- const assets = dayStrategy(year, month, daysInMonth, totalAssets, ownerId, rng);
- assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
- return assets;
-}
-
-/**
- * Main generator function for timeline data
- */
-export function generateTimelineData(config: TimelineConfig): MockTimelineData {
- validateTimelineConfig(config);
-
- const buckets = new Map();
- const monthStats: Record = {};
-
- const globalRng = new SeededRandom(config.seed || GENERATION_CONSTANTS.DEFAULT_SEED);
- faker.seed(globalRng.nextInt(0, 1_000_000));
- for (const monthConfig of config.months) {
- const { year, month, distribution, pattern } = monthConfig;
-
- const monthSeed = globalRng.nextInt(0, 1_000_000);
- const monthRng = new SeededRandom(monthSeed);
-
- const monthAssets = generateMonthAssets(
- year,
- month,
- config.ownerId || generateUUID(),
- distribution,
- pattern,
- monthRng,
- );
-
- if (monthAssets.length > 0) {
- const monthKey = `${year}-${month.toString().padStart(2, '0')}`;
- monthStats[monthKey] = {
- count: monthAssets.length,
- distribution,
- pattern,
- };
-
- // Create bucket key (YYYY-MM-01)
- const bucketKey = `${year}-${month.toString().padStart(2, '0')}-01`;
- buckets.set(bucketKey, monthAssets);
- }
- }
-
- // Create a mock album from random assets
- const allAssets = [...buckets.values()].flat();
-
- // Select 10-30 random assets for the album (or all assets if less than 10)
- const albumSize = Math.min(allAssets.length, globalRng.nextInt(10, 31));
- const selectedAssetConfigs: MockTimelineAsset[] = [];
- const usedIndices = new Set();
-
- while (selectedAssetConfigs.length < albumSize && usedIndices.size < allAssets.length) {
- const randomIndex = globalRng.nextInt(0, allAssets.length);
- if (!usedIndices.has(randomIndex)) {
- usedIndices.add(randomIndex);
- selectedAssetConfigs.push(allAssets[randomIndex]);
- }
- }
-
- // Sort selected assets by date (newest first)
- selectedAssetConfigs.sort(
- (a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds,
- );
-
- const selectedAssets = selectedAssetConfigs.map((asset) => asset.id);
-
- const now = new Date().toISOString();
- const album = {
- id: generateUUID(),
- albumName: 'Test Album',
- description: 'A mock album for testing',
- assetIds: selectedAssets,
- thumbnailAssetId: selectedAssets.length > 0 ? selectedAssets[0] : null,
- createdAt: now,
- updatedAt: now,
- };
-
- // Write to file if configured
- if (config.writeToFile) {
- const outputPath = config.outputPath || '/tmp/timeline-data.json';
-
- // Convert Map to object for serialization
- const serializedData: SerializedTimelineData = {
- buckets: Object.fromEntries(buckets),
- album,
- };
-
- try {
- writeFileSync(outputPath, JSON.stringify(serializedData, null, 2));
- console.log(`Timeline data written to ${outputPath}`);
- } catch (error) {
- console.error(`Failed to write timeline data to ${outputPath}:`, error);
- }
- }
-
- return { buckets, album };
-}
diff --git a/e2e/src/generators/timeline/rest-response.ts b/e2e/src/generators/timeline/rest-response.ts
deleted file mode 100644
index a193535cd3..0000000000
--- a/e2e/src/generators/timeline/rest-response.ts
+++ /dev/null
@@ -1,439 +0,0 @@
-/**
- * REST API output functions for converting timeline data to API response formats
- */
-
-import {
- AssetTypeEnum,
- AssetVisibility,
- UserAvatarColor,
- type AlbumResponseDto,
- type AssetResponseDto,
- type ExifResponseDto,
- type TimeBucketAssetResponseDto,
- type TimeBucketsResponseDto,
- type UserResponseDto,
-} from '@immich/sdk';
-import { DateTime } from 'luxon';
-import { signupDto } from 'src/fixtures';
-import { parseTimeBucketKey } from 'src/generators/timeline/utils';
-import type { MockTimelineAsset, MockTimelineData } from './timeline-config';
-
-/**
- * Convert timeline/asset models to columnar format (parallel arrays)
- */
-export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetResponseDto {
- const result: TimeBucketAssetResponseDto = {
- id: [],
- ownerId: [],
- ratio: [],
- thumbhash: [],
- fileCreatedAt: [],
- localOffsetHours: [],
- isFavorite: [],
- isTrashed: [],
- isImage: [],
- duration: [],
- projectionType: [],
- livePhotoVideoId: [],
- city: [],
- country: [],
- visibility: [],
- };
-
- for (const asset of assets) {
- result.id.push(asset.id);
- result.ownerId.push(asset.ownerId);
- result.ratio.push(asset.ratio);
- result.thumbhash.push(asset.thumbhash);
- result.fileCreatedAt.push(asset.fileCreatedAt);
- result.localOffsetHours.push(0); // Assuming UTC for mocks
- result.isFavorite.push(asset.isFavorite);
- result.isTrashed.push(asset.isTrashed);
- result.isImage.push(asset.isImage);
- result.duration.push(asset.duration);
- result.projectionType.push(asset.projectionType);
- result.livePhotoVideoId.push(asset.livePhotoVideoId);
- result.city.push(asset.city);
- result.country.push(asset.country);
- result.visibility.push(asset.visibility);
- }
-
- if (assets.some((a) => a.latitude !== null || a.longitude !== null)) {
- result.latitude = assets.map((a) => a.latitude);
- result.longitude = assets.map((a) => a.longitude);
- }
-
- result.stack = assets.map(() => null);
- return result;
-}
-
-/**
- * Extract a single bucket from timeline data (mimics getTimeBucket API)
- * Automatically handles both ISO timestamp and simple month formats
- * Returns data in columnar format matching the actual API
- * When albumId is provided, only returns assets from that album
- */
-export function getTimeBucket(
- timelineData: MockTimelineData,
- timeBucket: string,
- isTrashed: boolean | undefined,
- isArchived: boolean | undefined,
- isFavorite: boolean | undefined,
- albumId: string | undefined,
- changes: Changes,
-): TimeBucketAssetResponseDto {
- const bucketKey = parseTimeBucketKey(timeBucket);
- let assets = timelineData.buckets.get(bucketKey);
-
- if (!assets) {
- return toColumnarFormat([]);
- }
-
- // Create sets for quick lookups
- const deletedAssetIds = new Set(changes.assetDeletions);
- const archivedAssetIds = new Set(changes.assetArchivals);
- const favoritedAssetIds = new Set(changes.assetFavorites);
-
- // Filter assets based on trashed/archived status
- assets = assets.filter((asset) =>
- shouldIncludeAsset(asset, isTrashed, isArchived, isFavorite, deletedAssetIds, archivedAssetIds, favoritedAssetIds),
- );
-
- // Filter to only include assets from the specified album
- if (albumId) {
- const album = timelineData.album;
- if (!album || album.id !== albumId) {
- return toColumnarFormat([]);
- }
-
- // Create a Set for faster lookup
- const albumAssetIds = new Set([...album.assetIds, ...changes.albumAdditions]);
- assets = assets.filter((asset) => albumAssetIds.has(asset.id));
- }
-
- // Override properties for assets in changes arrays
- const assetsWithOverrides = assets.map((asset) => {
- if (deletedAssetIds.has(asset.id) || archivedAssetIds.has(asset.id) || favoritedAssetIds.has(asset.id)) {
- return {
- ...asset,
- isFavorite: favoritedAssetIds.has(asset.id) ? true : asset.isFavorite,
- isTrashed: deletedAssetIds.has(asset.id) ? true : asset.isTrashed,
- visibility: archivedAssetIds.has(asset.id) ? AssetVisibility.Archive : asset.visibility,
- };
- }
- return asset;
- });
-
- return toColumnarFormat(assetsWithOverrides);
-}
-
-export type Changes = {
- // ids of assets that are newly added to the album
- albumAdditions: string[];
- // ids of assets that are newly deleted
- assetDeletions: string[];
- // ids of assets that are newly archived
- assetArchivals: string[];
- // ids of assets that are newly favorited
- assetFavorites: string[];
-};
-
-/**
- * Helper function to determine if an asset should be included based on filter criteria
- * @param asset - The asset to check
- * @param isTrashed - Filter for trashed status (undefined means no filter)
- * @param isArchived - Filter for archived status (undefined means no filter)
- * @param isFavorite - Filter for favorite status (undefined means no filter)
- * @param deletedAssetIds - Set of IDs for assets that have been deleted
- * @param archivedAssetIds - Set of IDs for assets that have been archived
- * @param favoritedAssetIds - Set of IDs for assets that have been favorited
- * @returns true if the asset matches all filter criteria
- */
-function shouldIncludeAsset(
- asset: MockTimelineAsset,
- isTrashed: boolean | undefined,
- isArchived: boolean | undefined,
- isFavorite: boolean | undefined,
- deletedAssetIds: Set,
- archivedAssetIds: Set,
- favoritedAssetIds: Set,
-): boolean {
- // Determine actual status (property or in changes)
- const actuallyTrashed = asset.isTrashed || deletedAssetIds.has(asset.id);
- const actuallyArchived = asset.visibility === 'archive' || archivedAssetIds.has(asset.id);
- const actuallyFavorited = asset.isFavorite || favoritedAssetIds.has(asset.id);
-
- // Apply filters
- if (isTrashed !== undefined && actuallyTrashed !== isTrashed) {
- return false;
- }
- if (isArchived !== undefined && actuallyArchived !== isArchived) {
- return false;
- }
- if (isFavorite !== undefined && actuallyFavorited !== isFavorite) {
- return false;
- }
-
- return true;
-}
-/**
- * Get summary for all buckets (mimics getTimeBuckets API)
- * When albumId is provided, only includes buckets that contain assets from that album
- */
-export function getTimeBuckets(
- timelineData: MockTimelineData,
- isTrashed: boolean | undefined,
- isArchived: boolean | undefined,
- isFavorite: boolean | undefined,
- albumId: string | undefined,
- changes: Changes,
-): TimeBucketsResponseDto[] {
- const summary: TimeBucketsResponseDto[] = [];
-
- // Create sets for quick lookups
- const deletedAssetIds = new Set(changes.assetDeletions);
- const archivedAssetIds = new Set(changes.assetArchivals);
- const favoritedAssetIds = new Set(changes.assetFavorites);
-
- // If no albumId is specified, return summary for all assets
- if (albumId) {
- // Filter to only include buckets with assets from the specified album
- const album = timelineData.album;
- if (!album || album.id !== albumId) {
- return [];
- }
-
- // Create a Set for faster lookup
- const albumAssetIds = new Set([...album.assetIds, ...changes.albumAdditions]);
- for (const removed of changes.assetDeletions) {
- albumAssetIds.delete(removed);
- }
- for (const [bucketKey, assets] of timelineData.buckets) {
- // Count how many assets in this bucket are in the album and match trashed/archived filters
- const albumAssetsInBucket = assets.filter((asset) => {
- // Must be in the album
- if (!albumAssetIds.has(asset.id)) {
- return false;
- }
-
- return shouldIncludeAsset(
- asset,
- isTrashed,
- isArchived,
- isFavorite,
- deletedAssetIds,
- archivedAssetIds,
- favoritedAssetIds,
- );
- });
-
- if (albumAssetsInBucket.length > 0) {
- summary.push({
- timeBucket: bucketKey,
- count: albumAssetsInBucket.length,
- });
- }
- }
- } else {
- for (const [bucketKey, assets] of timelineData.buckets) {
- // Filter assets based on trashed/archived status
- const filteredAssets = assets.filter((asset) =>
- shouldIncludeAsset(
- asset,
- isTrashed,
- isArchived,
- isFavorite,
- deletedAssetIds,
- archivedAssetIds,
- favoritedAssetIds,
- ),
- );
-
- if (filteredAssets.length > 0) {
- summary.push({
- timeBucket: bucketKey,
- count: filteredAssets.length,
- });
- }
- }
- }
-
- // Sort summary by date (newest first) using luxon
- summary.sort((a, b) => {
- const dateA = DateTime.fromISO(a.timeBucket);
- const dateB = DateTime.fromISO(b.timeBucket);
- return dateB.diff(dateA).milliseconds;
- });
-
- return summary;
-}
-
-const createDefaultOwner = (ownerId: string) => {
- const defaultOwner: UserResponseDto = {
- id: ownerId,
- email: signupDto.admin.email,
- name: signupDto.admin.name,
- profileImagePath: '',
- profileChangedAt: new Date().toISOString(),
- avatarColor: UserAvatarColor.Blue,
- };
- return defaultOwner;
-};
-
-/**
- * Convert a TimelineAssetConfig to a full AssetResponseDto
- * This matches the response from GET /api/assets/:id
- */
-export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
- const now = new Date().toISOString();
-
- // Default owner if not provided
- const defaultOwner = createDefaultOwner(asset.ownerId);
-
- const exifInfo: ExifResponseDto = {
- make: null,
- model: null,
- exifImageWidth: asset.ratio > 1 ? 4000 : 3000,
- exifImageHeight: asset.ratio > 1 ? Math.round(4000 / asset.ratio) : Math.round(3000 * asset.ratio),
- fileSizeInByte: asset.fileSizeInByte,
- orientation: '1',
- dateTimeOriginal: asset.fileCreatedAt,
- modifyDate: asset.fileCreatedAt,
- timeZone: asset.latitude === null ? null : 'UTC',
- lensModel: null,
- fNumber: null,
- focalLength: null,
- iso: null,
- exposureTime: null,
- latitude: asset.latitude,
- longitude: asset.longitude,
- city: asset.city,
- country: asset.country,
- state: null,
- description: null,
- };
-
- return {
- id: asset.id,
- deviceAssetId: `device-${asset.id}`,
- ownerId: asset.ownerId,
- owner: owner || defaultOwner,
- libraryId: `library-${asset.ownerId}`,
- deviceId: `device-${asset.ownerId}`,
- type: asset.isVideo ? AssetTypeEnum.Video : AssetTypeEnum.Image,
- originalPath: `/original/${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
- originalFileName: `${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
- originalMimeType: asset.isVideo ? 'video/mp4' : 'image/jpeg',
- thumbhash: asset.thumbhash,
- fileCreatedAt: asset.fileCreatedAt,
- fileModifiedAt: asset.fileCreatedAt,
- localDateTime: asset.localDateTime,
- updatedAt: now,
- createdAt: asset.fileCreatedAt,
- isFavorite: asset.isFavorite,
- isArchived: false,
- isTrashed: asset.isTrashed,
- visibility: asset.visibility,
- duration: asset.duration || '0:00:00.00000',
- exifInfo,
- livePhotoVideoId: asset.livePhotoVideoId,
- tags: [],
- people: [],
- unassignedFaces: [],
- stack: asset.stack,
- isOffline: false,
- hasMetadata: true,
- duplicateId: null,
- resized: true,
- checksum: asset.checksum,
- width: exifInfo.exifImageWidth ?? 1,
- height: exifInfo.exifImageHeight ?? 1,
- isEdited: false,
- };
-}
-
-/**
- * Get a single asset by ID from timeline data
- * This matches the response from GET /api/assets/:id
- */
-export function getAsset(
- timelineData: MockTimelineData,
- assetId: string,
- owner?: UserResponseDto,
-): AssetResponseDto | undefined {
- // Search through all buckets for the asset
- const buckets = [...timelineData.buckets.values()];
- for (const assets of buckets) {
- const asset = assets.find((a) => a.id === assetId);
- if (asset) {
- return toAssetResponseDto(asset, owner);
- }
- }
- return undefined;
-}
-
-/**
- * Get a mock album from timeline data
- * This matches the response from GET /api/albums/:id
- */
-export function getAlbum(
- timelineData: MockTimelineData,
- ownerId: string,
- albumId: string | undefined,
- changes: Changes,
-): AlbumResponseDto | undefined {
- if (!timelineData.album) {
- return undefined;
- }
-
- // If albumId is provided and doesn't match, return undefined
- if (albumId && albumId !== timelineData.album.id) {
- return undefined;
- }
-
- const album = timelineData.album;
- const albumOwner = createDefaultOwner(ownerId);
-
- // Get the actual asset objects from the timeline data
- const albumAssets: AssetResponseDto[] = [];
- const allAssets = [...timelineData.buckets.values()].flat();
-
- for (const assetId of album.assetIds) {
- const assetConfig = allAssets.find((a) => a.id === assetId);
- if (assetConfig) {
- albumAssets.push(toAssetResponseDto(assetConfig, albumOwner));
- }
- }
- for (const assetId of changes.albumAdditions ?? []) {
- const assetConfig = allAssets.find((a) => a.id === assetId);
- if (assetConfig) {
- albumAssets.push(toAssetResponseDto(assetConfig, albumOwner));
- }
- }
-
- albumAssets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
-
- // For a basic mock album, we don't include any albumUsers (shared users)
- // The owner is represented by the owner field, not in albumUsers
- const response: AlbumResponseDto = {
- id: album.id,
- albumName: album.albumName,
- description: album.description,
- albumThumbnailAssetId: album.thumbnailAssetId,
- createdAt: album.createdAt,
- updatedAt: album.updatedAt,
- ownerId: albumOwner.id,
- owner: albumOwner,
- albumUsers: [], // Empty array for non-shared album
- shared: false,
- hasSharedLink: false,
- isActivityEnabled: true,
- assetCount: albumAssets.length,
- assets: albumAssets,
- startDate: albumAssets.length > 0 ? albumAssets.at(-1)?.fileCreatedAt : undefined,
- endDate: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
- lastModifiedAssetTimestamp: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
- };
-
- return response;
-}
diff --git a/e2e/src/generators/timeline/timeline-config.ts b/e2e/src/generators/timeline/timeline-config.ts
deleted file mode 100644
index 8dbe8399b1..0000000000
--- a/e2e/src/generators/timeline/timeline-config.ts
+++ /dev/null
@@ -1,200 +0,0 @@
-import type { AssetVisibility } from '@immich/sdk';
-import { DayPattern, MonthDistribution } from 'src/generators/timeline/distribution-patterns';
-
-// Constants for generation parameters
-export const GENERATION_CONSTANTS = {
- VIDEO_PROBABILITY: 0.15, // 15% of assets are videos
- GPS_PERCENTAGE: 0.7, // 70% of assets have GPS data
- FAVORITE_PROBABILITY: 0.1, // 10% of assets are favorited
- MIN_VIDEO_DURATION_SECONDS: 5,
- MAX_VIDEO_DURATION_SECONDS: 300,
- DEFAULT_SEED: 12_345,
- DEFAULT_OWNER_ID: 'user-1',
- MAX_SELECT_ATTEMPTS: 10,
- SPARSE_DAY_COVERAGE: 0.4, // 40% of days have photos in sparse pattern
-} as const;
-
-// Aspect ratio distribution weights (must sum to 1)
-export const ASPECT_RATIO_WEIGHTS = {
- '4:3': 0.35, // 35% 4:3 landscape
- '3:2': 0.25, // 25% 3:2 landscape
- '16:9': 0.2, // 20% 16:9 landscape
- '2:3': 0.1, // 10% 2:3 portrait
- '1:1': 0.09, // 9% 1:1 square
- '3:1': 0.01, // 1% 3:1 panorama
-} as const;
-
-export type AspectRatio = {
- width: number;
- height: number;
- ratio: number;
- name: string;
-};
-
-// Mock configuration for asset generation - will be transformed to API response formats
-export type MockTimelineAsset = {
- id: string;
- ownerId: string;
- ratio: number;
- thumbhash: string | null;
- localDateTime: string;
- fileCreatedAt: string;
- isFavorite: boolean;
- isTrashed: boolean;
- isVideo: boolean;
- isImage: boolean;
- duration: string | null;
- projectionType: string | null;
- livePhotoVideoId: string | null;
- city: string | null;
- country: string | null;
- people: string[] | null;
- latitude: number | null;
- longitude: number | null;
- visibility: AssetVisibility;
- stack: null;
- checksum: string;
- fileSizeInByte: number;
-};
-
-export type MonthSpec = {
- year: number;
- month: number; // 1-12
- distribution: MonthDistribution;
- pattern: DayPattern;
-};
-
-/**
- * Configuration for timeline data generation
- */
-export type TimelineConfig = {
- ownerId?: string;
- months: MonthSpec[];
- seed?: number;
- writeToFile?: boolean;
- outputPath?: string;
-};
-
-export type MockAlbum = {
- id: string;
- albumName: string;
- description: string;
- assetIds: string[]; // IDs of assets in the album
- thumbnailAssetId: string | null;
- createdAt: string;
- updatedAt: string;
-};
-
-export type MockTimelineData = {
- buckets: Map;
- album: MockAlbum; // Mock album created from random assets
-};
-
-export type SerializedTimelineData = {
- buckets: Record;
- album: MockAlbum;
-};
-
-/**
- * Validates a TimelineConfig object to ensure all values are within expected ranges
- */
-export function validateTimelineConfig(config: TimelineConfig): void {
- if (!config.months || config.months.length === 0) {
- throw new Error('TimelineConfig must contain at least one month');
- }
-
- const seenMonths = new Set();
-
- for (const month of config.months) {
- if (month.month < 1 || month.month > 12) {
- throw new Error(`Invalid month: ${month.month}. Must be between 1 and 12`);
- }
-
- if (month.year < 1900 || month.year > 2100) {
- throw new Error(`Invalid year: ${month.year}. Must be between 1900 and 2100`);
- }
-
- const monthKey = `${month.year}-${month.month}`;
- if (seenMonths.has(monthKey)) {
- throw new Error(`Duplicate month found: ${monthKey}`);
- }
- seenMonths.add(monthKey);
-
- // Validate distribution if provided
- if (month.distribution && !['empty', 'sparse', 'medium', 'dense', 'very-dense'].includes(month.distribution)) {
- throw new Error(
- `Invalid distribution: ${month.distribution}. Must be one of: empty, sparse, medium, dense, very-dense`,
- );
- }
-
- const validPatterns = [
- 'single-day',
- 'consecutive-large',
- 'consecutive-small',
- 'alternating',
- 'sparse-scattered',
- 'start-heavy',
- 'end-heavy',
- 'mid-heavy',
- ];
- if (month.pattern && !validPatterns.includes(month.pattern)) {
- throw new Error(`Invalid pattern: ${month.pattern}. Must be one of: ${validPatterns.join(', ')}`);
- }
- }
-
- // Validate seed if provided
- if (config.seed !== undefined && (config.seed < 0 || !Number.isInteger(config.seed))) {
- throw new Error('Seed must be a non-negative integer');
- }
-
- // Validate ownerId if provided
- if (config.ownerId !== undefined && config.ownerId.trim() === '') {
- throw new Error('Owner ID cannot be an empty string');
- }
-}
-
-/**
- * Create a default timeline configuration
- */
-export function createDefaultTimelineConfig(): TimelineConfig {
- const months: MonthSpec[] = [
- // 2024 - Mix of patterns
- { year: 2024, month: 12, distribution: 'very-dense', pattern: 'alternating' },
- { year: 2024, month: 11, distribution: 'dense', pattern: 'consecutive-large' },
- { year: 2024, month: 10, distribution: 'medium', pattern: 'mid-heavy' },
- { year: 2024, month: 9, distribution: 'sparse', pattern: 'consecutive-small' },
- { year: 2024, month: 8, distribution: 'empty', pattern: 'single-day' },
- { year: 2024, month: 7, distribution: 'dense', pattern: 'start-heavy' },
- { year: 2024, month: 6, distribution: 'medium', pattern: 'sparse-scattered' },
- { year: 2024, month: 5, distribution: 'sparse', pattern: 'single-day' },
- { year: 2024, month: 4, distribution: 'very-dense', pattern: 'consecutive-large' },
- { year: 2024, month: 3, distribution: 'empty', pattern: 'single-day' },
- { year: 2024, month: 2, distribution: 'medium', pattern: 'end-heavy' },
- { year: 2024, month: 1, distribution: 'dense', pattern: 'alternating' },
-
- // 2023 - Testing year boundaries and more patterns
- { year: 2023, month: 12, distribution: 'very-dense', pattern: 'end-heavy' },
- { year: 2023, month: 11, distribution: 'sparse', pattern: 'consecutive-small' },
- { year: 2023, month: 10, distribution: 'empty', pattern: 'single-day' },
- { year: 2023, month: 9, distribution: 'medium', pattern: 'alternating' },
- { year: 2023, month: 8, distribution: 'dense', pattern: 'mid-heavy' },
- { year: 2023, month: 7, distribution: 'sparse', pattern: 'sparse-scattered' },
- { year: 2023, month: 6, distribution: 'medium', pattern: 'consecutive-large' },
- { year: 2023, month: 5, distribution: 'empty', pattern: 'single-day' },
- { year: 2023, month: 4, distribution: 'sparse', pattern: 'single-day' },
- { year: 2023, month: 3, distribution: 'dense', pattern: 'start-heavy' },
- { year: 2023, month: 2, distribution: 'medium', pattern: 'alternating' },
- { year: 2023, month: 1, distribution: 'very-dense', pattern: 'consecutive-large' },
- ];
-
- for (let year = 2022; year >= 2000; year--) {
- for (let month = 12; month >= 1; month--) {
- months.push({ year, month, distribution: 'medium', pattern: 'sparse-scattered' });
- }
- }
-
- return {
- months,
- seed: 42,
- };
-}
diff --git a/e2e/src/generators/timeline/utils.ts b/e2e/src/generators/timeline/utils.ts
deleted file mode 100644
index 686a8223ef..0000000000
--- a/e2e/src/generators/timeline/utils.ts
+++ /dev/null
@@ -1,186 +0,0 @@
-import { DateTime } from 'luxon';
-import { GENERATION_CONSTANTS, MockTimelineAsset } from 'src/generators/timeline/timeline-config';
-
-/**
- * Linear Congruential Generator for deterministic pseudo-random numbers
- */
-export class SeededRandom {
- private seed: number;
-
- constructor(seed: number) {
- this.seed = seed;
- }
-
- /**
- * Generate next random number in range [0, 1)
- */
- next(): number {
- // LCG parameters from Numerical Recipes
- this.seed = (this.seed * 1_664_525 + 1_013_904_223) % 2_147_483_647;
- return this.seed / 2_147_483_647;
- }
-
- /**
- * Generate random integer in range [min, max)
- */
- nextInt(min: number, max: number): number {
- return Math.floor(this.next() * (max - min)) + min;
- }
-
- /**
- * Generate random boolean with given probability
- */
- nextBoolean(probability = 0.5): boolean {
- return this.next() < probability;
- }
-}
-
-/**
- * Select random days using seed variation to avoid collisions.
- *
- * @param daysInMonth - Total number of days in the month
- * @param numDays - Number of days to select
- * @param rng - Random number generator instance
- * @returns Array of selected day numbers, sorted in descending order
- */
-export function selectRandomDays(daysInMonth: number, numDays: number, rng: SeededRandom): number[] {
- const selectedDays = new Set();
- const maxAttempts = numDays * GENERATION_CONSTANTS.MAX_SELECT_ATTEMPTS; // Safety limit
- let attempts = 0;
-
- while (selectedDays.size < numDays && attempts < maxAttempts) {
- const day = rng.nextInt(1, daysInMonth + 1);
- selectedDays.add(day);
- attempts++;
- }
-
- // Fallback: if we couldn't select enough random days, fill with sequential days
- if (selectedDays.size < numDays) {
- for (let day = 1; day <= daysInMonth && selectedDays.size < numDays; day++) {
- selectedDays.add(day);
- }
- }
-
- return [...selectedDays].toSorted((a, b) => b - a);
-}
-
-/**
- * Select item from array using seeded random
- */
-export function selectRandom(arr: T[], rng: SeededRandom): T {
- if (arr.length === 0) {
- throw new Error('Cannot select from empty array');
- }
- const index = rng.nextInt(0, arr.length);
- return arr[index];
-}
-
-/**
- * Select multiple random items from array using seeded random without duplicates
- */
-export function selectRandomMultiple(arr: T[], count: number, rng: SeededRandom): T[] {
- if (arr.length === 0) {
- throw new Error('Cannot select from empty array');
- }
- if (count < 0) {
- throw new Error('Count must be non-negative');
- }
- if (count > arr.length) {
- throw new Error('Count cannot exceed array length');
- }
-
- const result: T[] = [];
- const selectedIndices = new Set();
-
- while (result.length < count) {
- const index = rng.nextInt(0, arr.length);
- if (!selectedIndices.has(index)) {
- selectedIndices.add(index);
- result.push(arr[index]);
- }
- }
-
- return result;
-}
-
-/**
- * Parse timeBucket parameter to extract year-month key
- * Handles both formats:
- * - ISO timestamp: "2024-12-01T00:00:00.000Z" -> "2024-12-01"
- * - Simple format: "2024-12-01" -> "2024-12-01"
- */
-export function parseTimeBucketKey(timeBucket: string): string {
- if (!timeBucket) {
- throw new Error('timeBucket parameter cannot be empty');
- }
-
- const dt = DateTime.fromISO(timeBucket, { zone: 'utc' });
-
- if (!dt.isValid) {
- // Fallback to regex if not a valid ISO string
- const match = timeBucket.match(/^(\d{4}-\d{2}-\d{2})/);
- return match ? match[1] : timeBucket;
- }
-
- // Format as YYYY-MM-01 (first day of month)
- return `${dt.year}-${String(dt.month).padStart(2, '0')}-01`;
-}
-
-export function getMockAsset(
- asset: MockTimelineAsset,
- sortedDescendingAssets: MockTimelineAsset[],
- direction: 'next' | 'previous',
- unit: 'day' | 'month' | 'year' = 'day',
-): MockTimelineAsset | null {
- const currentDateTime = DateTime.fromISO(asset.localDateTime, { zone: 'utc' });
-
- const currentIndex = sortedDescendingAssets.findIndex((a) => a.id === asset.id);
-
- if (currentIndex === -1) {
- return null;
- }
-
- const step = direction === 'next' ? 1 : -1;
- const startIndex = currentIndex + step;
-
- if (direction === 'next' && currentIndex >= sortedDescendingAssets.length - 1) {
- return null;
- }
- if (direction === 'previous' && currentIndex <= 0) {
- return null;
- }
-
- const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
- if (unit === 'day') {
- return !date1.startOf('day').equals(date2.startOf('day'));
- } else if (unit === 'month') {
- return date1.year !== date2.year || date1.month !== date2.month;
- } else {
- return date1.year !== date2.year;
- }
- };
-
- if (direction === 'next') {
- // Search forward in array (backwards in time)
- for (let i = startIndex; i < sortedDescendingAssets.length; i++) {
- const nextAsset = sortedDescendingAssets[i];
- const nextDate = DateTime.fromISO(nextAsset.localDateTime, { zone: 'utc' });
-
- if (isInDifferentPeriod(nextDate, currentDateTime)) {
- return nextAsset;
- }
- }
- } else {
- // Search backward in array (forwards in time)
- for (let i = startIndex; i >= 0; i--) {
- const prevAsset = sortedDescendingAssets[i];
- const prevDate = DateTime.fromISO(prevAsset.localDateTime, { zone: 'utc' });
-
- if (isInDifferentPeriod(prevDate, currentDateTime)) {
- return prevAsset;
- }
- }
- }
-
- return null;
-}
diff --git a/e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts b/e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts
deleted file mode 100644
index 24699cda30..0000000000
--- a/e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import { immichAdmin, utils } from 'src/utils';
-import { beforeAll, describe, expect, it } from 'vitest';
-
-describe(`immich-admin`, () => {
- beforeAll(async () => {
- await utils.resetDatabase();
- await utils.adminSetup();
- });
-
- describe('revoke-admin', () => {
- it('should revoke admin privileges from a user', async () => {
- const { child, promise } = immichAdmin(['revoke-admin']);
-
- let data = '';
- child.stdout.on('data', (chunk) => {
- data += chunk;
- if (data.includes('Please enter the user email:')) {
- child.stdin.end('admin@immich.cloud\n');
- }
- });
-
- const { stdout, exitCode } = await promise;
- expect(exitCode).toBe(0);
-
- expect(stdout).toContain('Admin access has been revoked from');
- });
- });
-
- describe('grant-admin', () => {
- it('should grant admin privileges to a user', async () => {
- const { child, promise } = immichAdmin(['grant-admin']);
-
- let data = '';
- child.stdout.on('data', (chunk) => {
- data += chunk;
- if (data.includes('Please enter the user email:')) {
- child.stdin.end('admin@immich.cloud\n');
- }
- });
-
- const { stdout, exitCode } = await promise;
- expect(exitCode).toBe(0);
-
- expect(stdout).toContain('Admin access has been granted to');
- });
- });
-
- describe('list-users', () => {
- it('should list the admin user', async () => {
- const { stdout, exitCode } = await immichAdmin(['list-users']).promise;
- expect(exitCode).toBe(0);
-
- // TODO: Vitest needs upgrade to Node 22.x to fix the failed check
- // expect(stderr).toBe('');
- expect(stdout).toContain("email: 'admin@immich.cloud'");
- expect(stdout).toContain("name: 'Immich Admin'");
- });
- });
-
- describe('reset-admin-password', () => {
- it('should reset admin password', async () => {
- const { child, promise } = immichAdmin(['reset-admin-password']);
-
- let data = '';
- child.stdout.on('data', (chunk) => {
- data += chunk;
- if (data.includes('Please choose a new password (optional)')) {
- child.stdin.end('\n');
- }
- });
-
- const { stdout, exitCode } = await promise;
- expect(exitCode).toBe(0);
- // TODO: Vitest needs upgrade to Node 22.x to fix the failed check
- // expect(stderr).toBe('');
- expect(stdout).toContain('The admin password has been updated to:');
- });
- });
-});
diff --git a/e2e/src/mock-network/base-network.ts b/e2e/src/mock-network/base-network.ts
deleted file mode 100644
index f23202ca77..0000000000
--- a/e2e/src/mock-network/base-network.ts
+++ /dev/null
@@ -1,285 +0,0 @@
-import { BrowserContext } from '@playwright/test';
-import { playwrightHost } from 'playwright.config';
-
-export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
- await context.addCookies([
- {
- name: 'immich_is_authenticated',
- value: 'true',
- domain: playwrightHost,
- path: '/',
- },
- ]);
- await context.route('**/api/users/me', async (route) => {
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: {
- id: adminUserId,
- email: 'admin@immich.cloud',
- name: 'Immich Admin',
- profileImagePath: '',
- avatarColor: 'orange',
- profileChangedAt: '2025-01-22T21:31:23.996Z',
- storageLabel: 'admin',
- shouldChangePassword: true,
- isAdmin: true,
- createdAt: '2025-01-22T21:31:23.996Z',
- deletedAt: null,
- updatedAt: '2025-11-14T00:00:00.369Z',
- oauthId: '',
- quotaSizeInBytes: null,
- quotaUsageInBytes: 20_849_000_159,
- status: 'active',
- license: null,
- },
- });
- });
- await context.route('**/users/me/preferences', async (route) => {
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: {
- albums: {
- defaultAssetOrder: 'desc',
- },
- folders: {
- enabled: false,
- sidebarWeb: false,
- },
- memories: {
- enabled: true,
- duration: 5,
- },
- people: {
- enabled: true,
- sidebarWeb: false,
- },
- sharedLinks: {
- enabled: true,
- sidebarWeb: false,
- },
- ratings: {
- enabled: false,
- },
- tags: {
- enabled: false,
- sidebarWeb: false,
- },
- emailNotifications: {
- enabled: true,
- albumInvite: true,
- albumUpdate: true,
- },
- download: {
- archiveSize: 4_294_967_296,
- includeEmbeddedVideos: false,
- },
- purchase: {
- showSupportBadge: true,
- hideBuyButtonUntil: '2100-02-12T00:00:00.000Z',
- },
- cast: {
- gCastEnabled: false,
- },
- },
- });
- });
- await context.route('**/server/about', async (route) => {
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: {
- version: 'v2.2.3',
- versionUrl: 'https://github.com/immich-app/immich/releases/tag/v2.2.3',
- licensed: false,
- build: '1234567890',
- buildUrl: 'https://github.com/immich-app/immich/actions/runs/1234567890',
- buildImage: 'e2e',
- buildImageUrl: 'https://github.com/immich-app/immich/pkgs/container/immich-server',
- repository: 'immich-app/immich',
- repositoryUrl: 'https://github.com/immich-app/immich',
- sourceRef: 'e2e',
- sourceCommit: 'e2eeeeeeeeeeeeeeeeee',
- sourceUrl: 'https://github.com/immich-app/immich/commit/e2eeeeeeeeeeeeeeeeee',
- nodejs: 'v22.18.0',
- exiftool: '13.41',
- ffmpeg: '7.1.1-6',
- libvips: '8.17.2',
- imagemagick: '7.1.2-2',
- },
- });
- });
- await context.route('**/api/server/features', async (route) => {
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: {
- smartSearch: false,
- facialRecognition: false,
- duplicateDetection: false,
- map: true,
- reverseGeocoding: true,
- importFaces: false,
- sidecar: true,
- search: true,
- trash: true,
- oauth: false,
- oauthAutoLaunch: false,
- ocr: false,
- passwordLogin: true,
- configFile: false,
- email: false,
- },
- });
- });
- await context.route('**/api/server/config', async (route) => {
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: {
- loginPageMessage: '',
- trashDays: 30,
- userDeleteDelay: 7,
- oauthButtonText: 'Login with OAuth',
- isInitialized: true,
- isOnboarded: true,
- externalDomain: '',
- publicUsers: true,
- mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
- mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
- maintenanceMode: false,
- },
- });
- });
- await context.route('**/api/server/media-types', async (route) => {
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: {
- video: [
- '.3gp',
- '.3gpp',
- '.avi',
- '.flv',
- '.insv',
- '.m2t',
- '.m2ts',
- '.m4v',
- '.mkv',
- '.mov',
- '.mp4',
- '.mpe',
- '.mpeg',
- '.mpg',
- '.mts',
- '.vob',
- '.webm',
- '.wmv',
- ],
- image: [
- '.3fr',
- '.ari',
- '.arw',
- '.cap',
- '.cin',
- '.cr2',
- '.cr3',
- '.crw',
- '.dcr',
- '.dng',
- '.erf',
- '.fff',
- '.iiq',
- '.k25',
- '.kdc',
- '.mrw',
- '.nef',
- '.nrw',
- '.orf',
- '.ori',
- '.pef',
- '.psd',
- '.raf',
- '.raw',
- '.rw2',
- '.rwl',
- '.sr2',
- '.srf',
- '.srw',
- '.x3f',
- '.avif',
- '.gif',
- '.jpeg',
- '.jpg',
- '.png',
- '.webp',
- '.bmp',
- '.heic',
- '.heif',
- '.hif',
- '.insp',
- '.jp2',
- '.jpe',
- '.jxl',
- '.svg',
- '.tif',
- '.tiff',
- ],
- sidecar: ['.xmp'],
- },
- });
- });
- await context.route('**/api/notifications*', async (route) => {
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: [],
- });
- });
- await context.route('**/api/albums*', async (route, request) => {
- if (request.url().endsWith('albums?shared=true') || request.url().endsWith('albums')) {
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: [],
- });
- }
- await route.fallback();
- });
- await context.route('**/api/memories*', async (route) => {
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: [],
- });
- });
- await context.route('**/api/server/storage', async (route) => {
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: {
- diskSize: '100.0 GiB',
- diskUse: '74.4 GiB',
- diskAvailable: '25.6 GiB',
- diskSizeRaw: 107_374_182_400,
- diskUseRaw: 79_891_660_800,
- diskAvailableRaw: 27_482_521_600,
- diskUsagePercentage: 74.4,
- },
- });
- });
- await context.route('**/api/server/version-history', async (route) => {
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: [
- {
- id: 'd1fbeadc-cb4f-4db3-8d19-8c6a921d5d8e',
- createdAt: '2025-11-15T20:14:01.935Z',
- version: '2.2.3',
- },
- ],
- });
- });
-};
diff --git a/e2e/src/mock-network/memory-network.ts b/e2e/src/mock-network/memory-network.ts
deleted file mode 100644
index 9a3a9e6555..0000000000
--- a/e2e/src/mock-network/memory-network.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import type { MemoryResponseDto } from '@immich/sdk';
-import { BrowserContext } from '@playwright/test';
-
-export type MemoryChanges = {
- memoryDeletions: string[];
- assetRemovals: Map;
-};
-
-export const setupMemoryMockApiRoutes = async (
- context: BrowserContext,
- memories: MemoryResponseDto[],
- changes: MemoryChanges,
-) => {
- await context.route('**/api/memories*', async (route, request) => {
- const url = new URL(request.url());
- const pathname = url.pathname;
-
- if (pathname === '/api/memories' && request.method() === 'GET') {
- const activeMemories = memories
- .filter((memory) => !changes.memoryDeletions.includes(memory.id))
- .map((memory) => {
- const removedAssets = changes.assetRemovals.get(memory.id) ?? [];
- return {
- ...memory,
- assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
- };
- })
- .filter((memory) => memory.assets.length > 0);
-
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: activeMemories,
- });
- }
-
- const memoryMatch = pathname.match(/\/api\/memories\/([^/]+)$/);
- if (memoryMatch && request.method() === 'GET') {
- const memoryId = memoryMatch[1];
- const memory = memories.find((m) => m.id === memoryId);
-
- if (!memory || changes.memoryDeletions.includes(memoryId)) {
- return route.fulfill({ status: 404 });
- }
-
- const removedAssets = changes.assetRemovals.get(memoryId) ?? [];
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: {
- ...memory,
- assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
- },
- });
- }
-
- if (/\/api\/memories\/([^/]+)$/.test(pathname) && request.method() === 'DELETE') {
- const memoryId = pathname.split('/').pop()!;
- changes.memoryDeletions.push(memoryId);
- return route.fulfill({ status: 204 });
- }
-
- await route.fallback();
- });
-};
diff --git a/e2e/src/mock-network/timeline-network.ts b/e2e/src/mock-network/timeline-network.ts
deleted file mode 100644
index 8780409657..0000000000
--- a/e2e/src/mock-network/timeline-network.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { AssetResponseDto } from '@immich/sdk';
-import { BrowserContext, Page, Request, Route } from '@playwright/test';
-import { basename } from 'node:path';
-import {
- Changes,
- getAlbum,
- getAsset,
- getTimeBucket,
- getTimeBuckets,
- randomPreview,
- randomThumbnail,
- TimelineData,
-} from 'src/generators/timeline';
-import { sleep } from 'src/web/specs/timeline/utils';
-
-export class TimelineTestContext {
- slowBucket = false;
- adminId = '';
-}
-
-export const setupTimelineMockApiRoutes = async (
- context: BrowserContext,
- timelineRestData: TimelineData,
- changes: Changes,
- testContext: TimelineTestContext,
-) => {
- await context.route('**/api/timeline**', async (route, request) => {
- const url = new URL(request.url());
- const pathname = url.pathname;
- if (pathname === '/api/timeline/buckets') {
- const albumId = url.searchParams.get('albumId') || undefined;
- const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
- const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
- const isArchived = url.searchParams.get('visibility')
- ? url.searchParams.get('visibility') === 'archive'
- : undefined;
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: getTimeBuckets(timelineRestData, isTrashed, isArchived, isFavorite, albumId, changes),
- });
- } else if (pathname === '/api/timeline/bucket') {
- const timeBucket = url.searchParams.get('timeBucket');
- if (!timeBucket) {
- return route.continue();
- }
- const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
- const isArchived = url.searchParams.get('visibility')
- ? url.searchParams.get('visibility') === 'archive'
- : undefined;
- const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
- const albumId = url.searchParams.get('albumId') || undefined;
- const assets = getTimeBucket(timelineRestData, timeBucket, isTrashed, isArchived, isFavorite, albumId, changes);
- if (testContext.slowBucket) {
- await sleep(5000);
- }
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: assets,
- });
- }
- return route.continue();
- });
-
- await context.route('**/api/assets/*', async (route, request) => {
- if (request.method() === 'GET') {
- const url = new URL(request.url());
- const pathname = url.pathname;
- const assetId = basename(pathname);
- let asset = getAsset(timelineRestData, assetId);
- if (changes.assetDeletions.includes(asset!.id)) {
- asset = {
- ...asset,
- isTrashed: true,
- } as AssetResponseDto;
- }
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: asset,
- });
- }
- await route.fallback();
- });
-
- await context.route('**/api/assets', async (route, request) => {
- if (request.method() === 'DELETE') {
- return route.fulfill({
- status: 204,
- });
- }
- await route.fallback();
- });
-
- await context.route('**/api/assets/*/ocr', async (route) => {
- return route.fulfill({ status: 200, contentType: 'application/json', json: [] });
- });
-
- await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
- const pattern = /\/api\/assets\/(?[^/]+)\/thumbnail\?size=(?preview|thumbnail)/;
- const match = request.url().match(pattern);
- if (!match?.groups) {
- throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
- }
-
- if (match.groups.size === 'preview') {
- if (!route.request().serviceWorker()) {
- return route.continue();
- }
- const asset = getAsset(timelineRestData, match.groups.assetId);
- return route.fulfill({
- status: 200,
- headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
- body: await randomPreview(
- match.groups.assetId,
- (asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
- ),
- });
- }
- if (match.groups.size === 'thumbnail') {
- if (!route.request().serviceWorker()) {
- return route.continue();
- }
- const asset = getAsset(timelineRestData, match.groups.assetId);
- return route.fulfill({
- status: 200,
- headers: { 'content-type': 'image/jpeg' },
- body: await randomThumbnail(
- match.groups.assetId,
- (asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
- ),
- });
- }
- return route.continue();
- });
-
- await context.route('**/api/albums/**', async (route, request) => {
- const albumsMatch = request.url().match(/\/api\/albums\/(?[^/?]+)/);
- if (albumsMatch) {
- const album = getAlbum(timelineRestData, testContext.adminId, albumsMatch.groups?.albumId, changes);
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: album,
- });
- }
- return route.fallback();
- });
-
- await context.route('**/api/albums**', async (route, request) => {
- const allAlbums = request.url().match(/\/api\/albums\?assetId=(?[^&]+)/);
- if (allAlbums) {
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: [],
- });
- }
- return route.fallback();
- });
-};
-
-export const pageRoutePromise = async (
- page: Page,
- route: string,
- callback: (route: Route, request: Request) => Promise,
-) => {
- let resolveRequest: ((value: unknown | PromiseLike) => void) | undefined;
- const deleteRequest = new Promise((resolve) => {
- resolveRequest = resolve;
- });
- await page.route(route, async (route, request) => {
- await callback(route, request);
- const requestJson = request.postDataJSON();
- resolveRequest?.(requestJson);
- });
- return deleteRequest;
-};
diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts
index 7307f87854..b1db2e4218 100644
--- a/e2e/src/utils.ts
+++ b/e2e/src/utils.ts
@@ -1,3 +1,4 @@
+import { BrowserContext } from '@playwright/test';
import {
AssetMediaCreateDto,
AssetMediaResponseDto,
@@ -53,8 +54,7 @@ import {
updateMyPreferences,
upsertTags,
validate,
-} from '@immich/sdk';
-import { BrowserContext } from '@playwright/test';
+} from '@server/sdk';
import { exec, spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
diff --git a/e2e/src/web/specs/album.e2e-spec.ts b/e2e/src/web/specs/album.e2e-spec.ts
deleted file mode 100644
index 953c7d00ae..0000000000
--- a/e2e/src/web/specs/album.e2e-spec.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { LoginResponseDto } from '@immich/sdk';
-import { test } from '@playwright/test';
-import { utils } from 'src/utils';
-
-test.describe('Album', () => {
- let admin: LoginResponseDto;
-
- test.beforeAll(async () => {
- utils.initSdk();
- await utils.resetDatabase();
- admin = await utils.adminSetup();
- });
-
- test(`doesn't delete album after canceling add assets`, async ({ context, page }) => {
- await utils.setAuthCookies(context, admin.accessToken);
-
- await page.goto('/albums');
- await page.getByRole('button', { name: 'Create album' }).click();
- await page.getByRole('button', { name: 'Select photos' }).click();
- await page.getByRole('button', { name: 'Close' }).click();
-
- await page.reload();
- await page.getByRole('button', { name: 'Select photos' }).waitFor();
- });
-});
diff --git a/e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts b/e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts
deleted file mode 100644
index 669f1b815c..0000000000
--- a/e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts
+++ /dev/null
@@ -1,269 +0,0 @@
-import { faker } from '@faker-js/faker';
-import { expect, test } from '@playwright/test';
-import {
- Changes,
- createDefaultTimelineConfig,
- generateTimelineData,
- SeededRandom,
- selectRandom,
- TimelineAssetConfig,
- TimelineData,
-} from 'src/generators/timeline';
-import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
-import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
-import { utils } from 'src/utils';
-import { assetViewerUtils } from 'src/web/specs/timeline/utils';
-
-test.describe.configure({ mode: 'parallel' });
-test.describe('asset-viewer', () => {
- const rng = new SeededRandom(529);
- let adminUserId: string;
- let timelineRestData: TimelineData;
- const assets: TimelineAssetConfig[] = [];
- const yearMonths: string[] = [];
- const testContext = new TimelineTestContext();
- const changes: Changes = {
- albumAdditions: [],
- assetDeletions: [],
- assetArchivals: [],
- assetFavorites: [],
- };
-
- test.beforeAll(async () => {
- utils.initSdk();
- adminUserId = faker.string.uuid();
- testContext.adminId = adminUserId;
- timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
- for (const timeBucket of timelineRestData.buckets.values()) {
- assets.push(...timeBucket);
- }
- for (const yearMonth of timelineRestData.buckets.keys()) {
- const [year, month] = yearMonth.split('-');
- yearMonths.push(`${year}-${Number(month)}`);
- }
- });
-
- test.beforeEach(async ({ context }) => {
- await setupBaseMockApiRoutes(context, adminUserId);
- await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
- });
-
- test.afterEach(() => {
- testContext.slowBucket = false;
- changes.albumAdditions = [];
- changes.assetDeletions = [];
- changes.assetArchivals = [];
- changes.assetFavorites = [];
- });
-
- test.describe('/photos/:id', () => {
- test('Navigate to next asset via button', async ({ page }) => {
- const asset = selectRandom(assets, rng);
- const index = assets.indexOf(asset);
- await page.goto(`/photos/${asset.id}`);
- await assetViewerUtils.waitForViewerLoad(page, asset);
- await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
-
- await page.getByLabel('View next asset').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
- await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
- });
-
- test('Navigate to previous asset via button', async ({ page }) => {
- const asset = selectRandom(assets, rng);
- const index = assets.indexOf(asset);
- await page.goto(`/photos/${asset.id}`);
- await assetViewerUtils.waitForViewerLoad(page, asset);
- await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
-
- await page.getByLabel('View previous asset').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
- await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
- });
-
- test('Navigate to next asset via keyboard (ArrowRight)', async ({ page }) => {
- const asset = selectRandom(assets, rng);
- const index = assets.indexOf(asset);
- await page.goto(`/photos/${asset.id}`);
- await assetViewerUtils.waitForViewerLoad(page, asset);
- await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
-
- await page.keyboard.press('ArrowRight');
- await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
- await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
- });
-
- test('Navigate to previous asset via keyboard (ArrowLeft)', async ({ page }) => {
- const asset = selectRandom(assets, rng);
- const index = assets.indexOf(asset);
- await page.goto(`/photos/${asset.id}`);
- await assetViewerUtils.waitForViewerLoad(page, asset);
- await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
-
- await page.keyboard.press('ArrowLeft');
- await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
- await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
- });
-
- test('Navigate forward 5 times via button', async ({ page }) => {
- const asset = selectRandom(assets, rng);
- const index = assets.indexOf(asset);
- await page.goto(`/photos/${asset.id}`);
- await assetViewerUtils.waitForViewerLoad(page, asset);
-
- for (let i = 1; i <= 5; i++) {
- await page.getByLabel('View next asset').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
- await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + i].id}`);
- }
- });
-
- test('Navigate backward 5 times via button', async ({ page }) => {
- const asset = selectRandom(assets, rng);
- const index = assets.indexOf(asset);
- await page.goto(`/photos/${asset.id}`);
- await assetViewerUtils.waitForViewerLoad(page, asset);
-
- for (let i = 1; i <= 5; i++) {
- await page.getByLabel('View previous asset').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[index - i]);
- await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - i].id}`);
- }
- });
-
- test('Navigate forward then backward via keyboard', async ({ page }) => {
- const asset = selectRandom(assets, rng);
- const index = assets.indexOf(asset);
- await page.goto(`/photos/${asset.id}`);
- await assetViewerUtils.waitForViewerLoad(page, asset);
-
- // Navigate forward 3 times
- for (let i = 1; i <= 3; i++) {
- await page.keyboard.press('ArrowRight');
- await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
- }
-
- // Navigate backward 3 times to return to original
- for (let i = 2; i >= 0; i--) {
- await page.keyboard.press('ArrowLeft');
- await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
- }
-
- // Verify we're back at the original asset
- await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
- });
-
- test('Verify no next button on last asset', async ({ page }) => {
- const lastAsset = assets.at(-1)!;
- await page.goto(`/photos/${lastAsset.id}`);
- await assetViewerUtils.waitForViewerLoad(page, lastAsset);
-
- // Verify next button doesn't exist
- await expect(page.getByLabel('View next asset')).toHaveCount(0);
- });
-
- test('Verify no previous button on first asset', async ({ page }) => {
- const firstAsset = assets[0];
- await page.goto(`/photos/${firstAsset.id}`);
- await assetViewerUtils.waitForViewerLoad(page, firstAsset);
-
- // Verify previous button doesn't exist
- await expect(page.getByLabel('View previous asset')).toHaveCount(0);
- });
-
- test('Delete photo advances to next', async ({ page }) => {
- const asset = selectRandom(assets, rng);
- await page.goto(`/photos/${asset.id}`);
- await assetViewerUtils.waitForViewerLoad(page, asset);
- await page.getByLabel('Delete').click();
- const index = assets.indexOf(asset);
- await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
- });
- test('Delete photo advances to next (2x)', async ({ page }) => {
- const asset = selectRandom(assets, rng);
- await page.goto(`/photos/${asset.id}`);
- await assetViewerUtils.waitForViewerLoad(page, asset);
- await page.getByLabel('Delete').click();
- const index = assets.indexOf(asset);
- await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
- await page.getByLabel('Delete').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
- });
- test('Delete last photo advances to prev', async ({ page }) => {
- const asset = assets.at(-1)!;
- await page.goto(`/photos/${asset.id}`);
- await assetViewerUtils.waitForViewerLoad(page, asset);
- await page.getByLabel('Delete').click();
- const index = assets.indexOf(asset);
- await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
- });
- test('Delete last photo advances to prev (2x)', async ({ page }) => {
- const asset = assets.at(-1)!;
- await page.goto(`/photos/${asset.id}`);
- await assetViewerUtils.waitForViewerLoad(page, asset);
- await page.getByLabel('Delete').click();
- const index = assets.indexOf(asset);
- await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
- await page.getByLabel('Delete').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]);
- });
- });
- test.describe('/trash/photos/:id', () => {
- test('Delete trashed photo advances to next', async ({ page }) => {
- const asset = selectRandom(assets, rng);
- const index = assets.indexOf(asset);
- const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
- changes.assetDeletions.push(...deletedAssets);
- await page.goto(`/trash/photos/${asset.id}`);
- await assetViewerUtils.waitForViewerLoad(page, asset);
- await page.getByLabel('Delete').click();
- // confirm dialog
- await page.getByRole('button').getByText('Delete').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
- });
- test('Delete trashed photo advances to next 2x', async ({ page }) => {
- const asset = selectRandom(assets, rng);
- const index = assets.indexOf(asset);
- const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
- changes.assetDeletions.push(...deletedAssets);
- await page.goto(`/trash/photos/${asset.id}`);
- await assetViewerUtils.waitForViewerLoad(page, asset);
- await page.getByLabel('Delete').click();
- // confirm dialog
- await page.getByRole('button').getByText('Delete').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
- await page.getByLabel('Delete').click();
- // confirm dialog
- await page.getByRole('button').getByText('Delete').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
- });
- test('Delete trashed photo advances to prev', async ({ page }) => {
- const asset = selectRandom(assets, rng);
- const index = assets.indexOf(asset);
- const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
- changes.assetDeletions.push(...deletedAssets);
- await page.goto(`/trash/photos/${assets[index + 9].id}`);
- await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
- await page.getByLabel('Delete').click();
- // confirm dialog
- await page.getByRole('button').getByText('Delete').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
- });
- test('Delete trashed photo advances to prev 2x', async ({ page }) => {
- const asset = selectRandom(assets, rng);
- const index = assets.indexOf(asset);
- const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
- changes.assetDeletions.push(...deletedAssets);
- await page.goto(`/trash/photos/${assets[index + 9].id}`);
- await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
- await page.getByLabel('Delete').click();
- // confirm dialog
- await page.getByRole('button').getByText('Delete').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
- await page.getByLabel('Delete').click();
- // confirm dialog
- await page.getByRole('button').getByText('Delete').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]);
- });
- });
-});
diff --git a/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts b/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts
deleted file mode 100644
index 2f90e4e3d8..0000000000
--- a/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
-import { expect, test } from '@playwright/test';
-import type { Socket } from 'socket.io-client';
-import { utils } from 'src/utils';
-
-test.describe('Detail Panel', () => {
- let admin: LoginResponseDto;
- let asset: AssetMediaResponseDto;
- let websocket: Socket;
-
- test.beforeAll(async () => {
- utils.initSdk();
- await utils.resetDatabase();
- admin = await utils.adminSetup();
- asset = await utils.createAsset(admin.accessToken);
- websocket = await utils.connectWebsocket(admin.accessToken);
- });
-
- test.afterAll(() => {
- utils.disconnectWebsocket(websocket);
- });
-
- test('can be opened for shared links', async ({ page }) => {
- const sharedLink = await utils.createSharedLink(admin.accessToken, {
- type: SharedLinkType.Individual,
- assetIds: [asset.id],
- });
- await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`);
- await page.waitForSelector('#immich-asset-viewer');
-
- await expect(page.getByRole('button', { name: 'Info' })).toBeVisible();
- await page.keyboard.press('i');
- await expect(page.locator('#detail-panel')).toBeVisible();
- await page.keyboard.press('i');
- await expect(page.locator('#detail-panel')).toHaveCount(0);
- });
-
- test('cannot be opened for shared links with hidden metadata', async ({ page }) => {
- const sharedLink = await utils.createSharedLink(admin.accessToken, {
- type: SharedLinkType.Individual,
- assetIds: [asset.id],
- showMetadata: false,
- });
- await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`);
- await page.waitForSelector('#immich-asset-viewer');
-
- await expect(page.getByRole('button', { name: 'Info' })).toHaveCount(0);
- await page.keyboard.press('i');
- await expect(page.locator('#detail-panel')).toHaveCount(0);
- await page.keyboard.press('i');
- await expect(page.locator('#detail-panel')).toHaveCount(0);
- });
-
- test('description is visible for owner on shared links', async ({ context, page }) => {
- const sharedLink = await utils.createSharedLink(admin.accessToken, {
- type: SharedLinkType.Individual,
- assetIds: [asset.id],
- });
- await utils.setAuthCookies(context, admin.accessToken);
- await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`);
-
- const textarea = page.getByRole('textbox', { name: 'Add a description' });
- await page.getByRole('button', { name: 'Info' }).click();
- await expect(textarea).toBeVisible();
- await expect(textarea).not.toBeDisabled();
- });
-
- test('description changes are visible after reopening', async ({ context, page }) => {
- await utils.setAuthCookies(context, admin.accessToken);
- await page.goto(`/photos/${asset.id}`);
- await page.waitForSelector('#immich-asset-viewer');
-
- await page.getByRole('button', { name: 'Info' }).click();
- const textarea = page.getByRole('textbox', { name: 'Add a description' });
- await textarea.fill('new description');
- await expect(textarea).toHaveValue('new description');
-
- await page.getByRole('button', { name: 'Info' }).click();
- await expect(textarea).not.toBeVisible();
- await page.getByRole('button', { name: 'Info' }).click();
- await expect(textarea).toBeVisible();
-
- await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
- await expect(textarea).toHaveValue('new description');
- });
-});
diff --git a/e2e/src/web/specs/asset-viewer/navbar.e2e-spec.ts b/e2e/src/web/specs/asset-viewer/navbar.e2e-spec.ts
deleted file mode 100644
index 8fcd1bbdb4..0000000000
--- a/e2e/src/web/specs/asset-viewer/navbar.e2e-spec.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
-import { expect, test } from '@playwright/test';
-import { utils } from 'src/utils';
-
-test.describe('Asset Viewer Navbar', () => {
- let admin: LoginResponseDto;
- let asset: AssetMediaResponseDto;
-
- test.beforeAll(async () => {
- utils.initSdk();
- await utils.resetDatabase();
- admin = await utils.adminSetup();
- });
-
- test.beforeEach(async () => {
- asset = await utils.createAsset(admin.accessToken);
- });
-
- test.describe('shared link without metadata', () => {
- test('visible guest actions', async ({ page }) => {
- const sharedLink = await utils.createSharedLink(admin.accessToken, {
- type: SharedLinkType.Individual,
- assetIds: [asset.id],
- showMetadata: false,
- });
- await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`);
- await page.waitForSelector('#immich-asset-viewer');
-
- const expected = ['Zoom Image', 'Copy Image', 'Download'];
- const buttons = await page.getByTestId('asset-viewer-navbar-actions').getByRole('button').all();
-
- for (const [i, button] of buttons.entries()) {
- await expect(button).toHaveAccessibleName(expected[i]);
- }
- });
-
- test('visible owner actions', async ({ context, page }) => {
- const sharedLink = await utils.createSharedLink(admin.accessToken, {
- type: SharedLinkType.Individual,
- assetIds: [asset.id],
- showMetadata: false,
- });
- await utils.setAuthCookies(context, admin.accessToken);
- await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`);
- await page.waitForSelector('#immich-asset-viewer');
-
- const expected = ['Share', 'Zoom Image', 'Copy Image', 'Download'];
- const buttons = await page.getByTestId('asset-viewer-navbar-actions').getByRole('button').all();
-
- for (const [i, button] of buttons.entries()) {
- await expect(button).toHaveAccessibleName(expected[i]);
- }
- });
- });
-
- test.describe('actions', () => {
- test('favorite asset with shortcut', async ({ context, page }) => {
- await utils.setAuthCookies(context, admin.accessToken);
- await page.goto(`/photos/${asset.id}`);
- await page.waitForSelector('#immich-asset-viewer');
- await page.keyboard.press('f');
- await expect(page.getByText('Added to favorites')).toBeVisible();
- });
- });
-});
diff --git a/e2e/src/web/specs/asset-viewer/slideshow.e2e-spec.ts b/e2e/src/web/specs/asset-viewer/slideshow.e2e-spec.ts
deleted file mode 100644
index c8cbc21588..0000000000
--- a/e2e/src/web/specs/asset-viewer/slideshow.e2e-spec.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
-import { expect, type Page, test } from '@playwright/test';
-import { utils } from 'src/utils';
-
-test.describe('Slideshow', () => {
- let admin: LoginResponseDto;
- let asset: AssetMediaResponseDto;
-
- test.beforeAll(async () => {
- utils.initSdk();
- await utils.resetDatabase();
- admin = await utils.adminSetup();
- asset = await utils.createAsset(admin.accessToken);
- });
-
- const openSlideshow = async (page: Page) => {
- await page.goto(`/photos/${asset.id}`);
- await page.waitForSelector('#immich-asset-viewer');
- await page.getByRole('button', { name: 'More' }).click();
- await page.getByRole('menuitem', { name: 'Slideshow' }).click();
- };
-
- test('open slideshow', async ({ context, page }) => {
- await utils.setAuthCookies(context, admin.accessToken);
- await openSlideshow(page);
- await expect(page.getByRole('button', { name: 'Exit Slideshow' })).toBeVisible();
- });
-
- test('exit slideshow with button', async ({ context, page }) => {
- await utils.setAuthCookies(context, admin.accessToken);
- await openSlideshow(page);
-
- const exitButton = page.getByRole('button', { name: 'Exit Slideshow' });
- await exitButton.click();
- await expect(exitButton).not.toBeVisible();
- });
-
- test('exit slideshow with shortcut', async ({ context, page }) => {
- await utils.setAuthCookies(context, admin.accessToken);
- await openSlideshow(page);
-
- const exitButton = page.getByRole('button', { name: 'Exit Slideshow' });
- await expect(exitButton).toBeVisible();
- await page.keyboard.press('Escape');
- await expect(exitButton).not.toBeVisible();
- });
-
- test('favorite shortcut is disabled', async ({ context, page }) => {
- await utils.setAuthCookies(context, admin.accessToken);
- await openSlideshow(page);
-
- await expect(page.getByRole('button', { name: 'Exit Slideshow' })).toBeVisible();
- await page.keyboard.press('f');
- await expect(page.getByText('Added to favorites')).not.toBeVisible();
- });
-});
diff --git a/e2e/src/web/specs/asset-viewer/stack.e2e-spec.ts b/e2e/src/web/specs/asset-viewer/stack.e2e-spec.ts
deleted file mode 100644
index cb40f82c0a..0000000000
--- a/e2e/src/web/specs/asset-viewer/stack.e2e-spec.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
-import { expect, Page, test } from '@playwright/test';
-import { utils } from 'src/utils';
-
-async function ensureDetailPanelVisible(page: Page) {
- await page.waitForSelector('#immich-asset-viewer');
-
- const isVisible = await page.locator('#detail-panel').isVisible();
- if (!isVisible) {
- await page.keyboard.press('i');
- await page.waitForSelector('#detail-panel');
- }
-}
-
-test.describe('Asset Viewer stack', () => {
- let admin: LoginResponseDto;
- let assetOne: AssetMediaResponseDto;
- let assetTwo: AssetMediaResponseDto;
-
- test.beforeAll(async () => {
- utils.initSdk();
- await utils.resetDatabase();
- admin = await utils.adminSetup();
- await utils.updateMyPreferences(admin.accessToken, { tags: { enabled: true } });
-
- assetOne = await utils.createAsset(admin.accessToken);
- assetTwo = await utils.createAsset(admin.accessToken);
- await utils.createStack(admin.accessToken, [assetOne.id, assetTwo.id]);
-
- const tags = await utils.upsertTags(admin.accessToken, ['test/1', 'test/2']);
- const tagOne = tags.find((tag) => tag.value === 'test/1')!;
- const tagTwo = tags.find((tag) => tag.value === 'test/2')!;
- await utils.tagAssets(admin.accessToken, tagOne.id, [assetOne.id]);
- await utils.tagAssets(admin.accessToken, tagTwo.id, [assetTwo.id]);
- });
-
- test('stack slideshow is visible', async ({ page, context }) => {
- await utils.setAuthCookies(context, admin.accessToken);
- await page.goto(`/photos/${assetOne.id}`);
-
- const stackAssets = page.locator('#stack-slideshow [data-asset]');
- await expect(stackAssets.first()).toBeVisible();
- await expect(stackAssets.nth(1)).toBeVisible();
- });
-
- test('tags of primary asset are visible', async ({ page, context }) => {
- await utils.setAuthCookies(context, admin.accessToken);
- await page.goto(`/photos/${assetOne.id}`);
- await ensureDetailPanelVisible(page);
-
- const tags = page.getByTestId('detail-panel-tags').getByRole('link');
- await expect(tags.first()).toHaveText('test/1');
- });
-
- test('tags of second asset are visible', async ({ page, context }) => {
- await utils.setAuthCookies(context, admin.accessToken);
- await page.goto(`/photos/${assetOne.id}`);
- await ensureDetailPanelVisible(page);
-
- const stackAssets = page.locator('#stack-slideshow [data-asset]');
- await stackAssets.nth(1).click();
-
- const tags = page.getByTestId('detail-panel-tags').getByRole('link');
- await expect(tags.first()).toHaveText('test/2');
- });
-});
diff --git a/e2e/src/web/specs/database-backups.e2e-spec.ts b/e2e/src/web/specs/database-backups.e2e-spec.ts
deleted file mode 100644
index d101215ceb..0000000000
--- a/e2e/src/web/specs/database-backups.e2e-spec.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { LoginResponseDto } from '@immich/sdk';
-import { expect, test } from '@playwright/test';
-import { utils } from 'src/utils';
-
-test.describe.configure({ mode: 'serial' });
-
-test.describe('Database Backups', () => {
- let admin: LoginResponseDto;
-
- test.beforeAll(async () => {
- utils.initSdk();
- await utils.resetDatabase();
- admin = await utils.adminSetup();
- });
-
- test('restore a backup from settings', async ({ context, page }) => {
- test.setTimeout(60_000);
-
- await utils.resetBackups(admin.accessToken);
- const filename = await utils.createBackup(admin.accessToken);
- await utils.setAuthCookies(context, admin.accessToken);
-
- // work-around until test is running on released version
- await utils.move(
- `/data/backups/${filename}`,
- '/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
- );
-
- await page.goto('/admin/maintenance?isOpen=backups');
- await page.getByRole('button', { name: 'Restore', exact: true }).click();
- await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
-
- await page.waitForURL('/maintenance?**');
- await page.waitForURL('/admin/maintenance**', { timeout: 60_000 });
- });
-
- test('handle backup restore failure', async ({ context, page }) => {
- test.setTimeout(60_000);
-
- await utils.resetBackups(admin.accessToken);
- await utils.prepareTestBackup('corrupted');
- await utils.setAuthCookies(context, admin.accessToken);
-
- await page.goto('/admin/maintenance?isOpen=backups');
- await page.getByRole('button', { name: 'Restore', exact: true }).click();
- await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
-
- await page.waitForURL('/maintenance?**');
- await expect(page.getByText('IM CORRUPTED')).toBeVisible({ timeout: 60_000 });
- await page.getByRole('button', { name: 'End maintenance mode' }).click();
- await page.waitForURL('/admin/maintenance**');
- });
-
- test('rollback to restore point if backup is missing admin', async ({ context, page }) => {
- test.setTimeout(60_000);
-
- await utils.resetBackups(admin.accessToken);
- await utils.prepareTestBackup('empty');
- await utils.setAuthCookies(context, admin.accessToken);
-
- await page.goto('/admin/maintenance?isOpen=backups');
- await page.getByRole('button', { name: 'Restore', exact: true }).click();
- await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
-
- await page.waitForURL('/maintenance?**');
- await expect(page.getByText('Server health check failed, no admin exists.')).toBeVisible({ timeout: 60_000 });
- await page.getByRole('button', { name: 'End maintenance mode' }).click();
- await page.waitForURL('/admin/maintenance**');
- });
-
- test('restore a backup from onboarding', async ({ context, page }) => {
- test.setTimeout(60_000);
-
- await utils.resetBackups(admin.accessToken);
- const filename = await utils.createBackup(admin.accessToken);
- await utils.setAuthCookies(context, admin.accessToken);
-
- // work-around until test is running on released version
- await utils.move(
- `/data/backups/${filename}`,
- '/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
- );
-
- await utils.resetDatabase();
-
- await page.goto('/');
- await page.getByRole('button', { name: 'Restore from backup' }).click();
-
- try {
- await page.waitForURL('/maintenance**');
- } catch {
- // when chained with the rest of the tests
- // this navigation may fail..? not sure why...
- await page.goto('/maintenance');
- await page.waitForURL('/maintenance**');
- }
-
- await page.getByRole('button', { name: 'Next' }).click();
- await page.getByRole('button', { name: 'Restore', exact: true }).click();
- await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
-
- await page.waitForURL('/maintenance?**');
- await page.waitForURL('/photos', { timeout: 60_000 });
- });
-});
diff --git a/e2e/src/web/specs/maintenance.e2e-spec.ts b/e2e/src/web/specs/maintenance.e2e-spec.ts
deleted file mode 100644
index 8b1631f0bf..0000000000
--- a/e2e/src/web/specs/maintenance.e2e-spec.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { LoginResponseDto } from '@immich/sdk';
-import { expect, test } from '@playwright/test';
-import { utils } from 'src/utils';
-
-test.describe.configure({ mode: 'serial' });
-
-test.describe('Maintenance', () => {
- let admin: LoginResponseDto;
-
- test.beforeAll(async () => {
- utils.initSdk();
- await utils.resetDatabase();
- admin = await utils.adminSetup();
- });
-
- test('enter and exit maintenance mode', async ({ context, page }) => {
- await utils.setAuthCookies(context, admin.accessToken);
-
- await page.goto('/admin/maintenance');
- await page.getByRole('button', { name: 'Switch to maintenance mode' }).click();
-
- await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
- await page.getByRole('button', { name: 'End maintenance mode' }).click();
- await page.waitForURL('**/admin/maintenance*', { timeout: 10_000 });
- });
-
- test('maintenance shows no options to users until they authenticate', async ({ page }) => {
- const setCookie = await utils.enterMaintenance(admin.accessToken);
- const cookie = setCookie
- ?.map((cookie) => cookie.split(';')[0].split('='))
- ?.find(([name]) => name === 'immich_maintenance_token');
-
- expect(cookie).toBeTruthy();
-
- await expect(async () => {
- await page.goto('/');
- await page.waitForURL('**/maintenance?**', {
- timeout: 1000,
- });
- }).toPass({ timeout: 10_000 });
-
- await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
- await expect(page.getByRole('button', { name: 'End maintenance mode' })).toHaveCount(0);
-
- await page.goto(`/maintenance?${new URLSearchParams({ token: cookie![1] })}`);
- await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
- await expect(page.getByRole('button', { name: 'End maintenance mode' })).toBeVisible();
- await page.getByRole('button', { name: 'End maintenance mode' }).click();
- await page.waitForURL('**/auth/login');
- });
-});
diff --git a/e2e/src/web/specs/memory/memory-viewer.ui-spec.ts b/e2e/src/web/specs/memory/memory-viewer.ui-spec.ts
deleted file mode 100644
index 11e73fbe25..0000000000
--- a/e2e/src/web/specs/memory/memory-viewer.ui-spec.ts
+++ /dev/null
@@ -1,289 +0,0 @@
-import { faker } from '@faker-js/faker';
-import type { MemoryResponseDto } from '@immich/sdk';
-import { test } from '@playwright/test';
-import { generateMemoriesFromTimeline } from 'src/generators/memory';
-import {
- Changes,
- createDefaultTimelineConfig,
- generateTimelineData,
- TimelineAssetConfig,
- TimelineData,
-} from 'src/generators/timeline';
-import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
-import { MemoryChanges, setupMemoryMockApiRoutes } from 'src/mock-network/memory-network';
-import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
-import { memoryAssetViewerUtils, memoryGalleryUtils, memoryViewerUtils } from 'src/web/specs/memory/utils';
-
-test.describe.configure({ mode: 'parallel' });
-
-test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
- let adminUserId: string;
- let timelineRestData: TimelineData;
- let memories: MemoryResponseDto[];
- const assets: TimelineAssetConfig[] = [];
- const testContext = new TimelineTestContext();
- const changes: Changes = {
- albumAdditions: [],
- assetDeletions: [],
- assetArchivals: [],
- assetFavorites: [],
- };
- const memoryChanges: MemoryChanges = {
- memoryDeletions: [],
- assetRemovals: new Map(),
- };
-
- test.beforeAll(async () => {
- adminUserId = faker.string.uuid();
- testContext.adminId = adminUserId;
-
- timelineRestData = generateTimelineData({
- ...createDefaultTimelineConfig(),
- ownerId: adminUserId,
- });
-
- for (const timeBucket of timelineRestData.buckets.values()) {
- assets.push(...timeBucket);
- }
-
- memories = generateMemoriesFromTimeline(
- assets,
- adminUserId,
- [
- { year: 2024, assetCount: 3 },
- { year: 2023, assetCount: 2 },
- { year: 2022, assetCount: 4 },
- ],
- 42,
- );
- });
-
- test.beforeEach(async ({ context }) => {
- await setupBaseMockApiRoutes(context, adminUserId);
- await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
- await setupMemoryMockApiRoutes(context, memories, memoryChanges);
- });
-
- test.afterEach(() => {
- testContext.slowBucket = false;
- changes.albumAdditions = [];
- changes.assetDeletions = [];
- changes.assetArchivals = [];
- changes.assetFavorites = [];
- memoryChanges.memoryDeletions = [];
- memoryChanges.assetRemovals.clear();
- });
-
- test.describe('Asset viewer navigation from gallery', () => {
- test('shows both prev/next buttons for middle asset within a memory', async ({ page }) => {
- const firstMemory = memories[0];
- const middleAsset = firstMemory.assets[1];
-
- await memoryViewerUtils.openMemoryPageWithAsset(page, middleAsset.id);
- await memoryGalleryUtils.clickThumbnail(page, middleAsset.id);
-
- await memoryAssetViewerUtils.waitForViewerOpen(page);
- await memoryAssetViewerUtils.waitForAssetLoad(page, middleAsset);
-
- await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
- await memoryAssetViewerUtils.expectNextButtonVisible(page);
- });
-
- test('shows next button when at last asset of first memory (next memory exists)', async ({ page }) => {
- const firstMemory = memories[0];
- const lastAssetOfFirstMemory = firstMemory.assets.at(-1)!;
-
- await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirstMemory.id);
- await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirstMemory.id);
-
- await memoryAssetViewerUtils.waitForViewerOpen(page);
- await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirstMemory);
-
- await memoryAssetViewerUtils.expectNextButtonVisible(page);
- await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
- });
-
- test('shows prev button when at first asset of last memory (prev memory exists)', async ({ page }) => {
- const lastMemory = memories.at(-1)!;
- const firstAssetOfLastMemory = lastMemory.assets[0];
-
- await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfLastMemory.id);
- await memoryGalleryUtils.clickThumbnail(page, firstAssetOfLastMemory.id);
-
- await memoryAssetViewerUtils.waitForViewerOpen(page);
- await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfLastMemory);
-
- await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
- await memoryAssetViewerUtils.expectNextButtonVisible(page);
- });
-
- test('can navigate from last asset of memory to first asset of next memory', async ({ page }) => {
- const firstMemory = memories[0];
- const secondMemory = memories[1];
- const lastAssetOfFirst = firstMemory.assets.at(-1)!;
- const firstAssetOfSecond = secondMemory.assets[0];
-
- await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
- await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
-
- await memoryAssetViewerUtils.waitForViewerOpen(page);
- await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
-
- await memoryAssetViewerUtils.clickNextButton(page);
- await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
-
- await memoryAssetViewerUtils.expectCurrentAssetId(page, firstAssetOfSecond.id);
- });
-
- test('can navigate from first asset of memory to last asset of previous memory', async ({ page }) => {
- const firstMemory = memories[0];
- const secondMemory = memories[1];
- const lastAssetOfFirst = firstMemory.assets.at(-1)!;
- const firstAssetOfSecond = secondMemory.assets[0];
-
- await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
- await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
-
- await memoryAssetViewerUtils.waitForViewerOpen(page);
- await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
-
- await memoryAssetViewerUtils.clickPreviousButton(page);
- await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
- });
-
- test('hides prev button at very first asset (first memory, first asset, no prev memory)', async ({ page }) => {
- const firstMemory = memories[0];
- const veryFirstAsset = firstMemory.assets[0];
-
- await memoryViewerUtils.openMemoryPageWithAsset(page, veryFirstAsset.id);
- await memoryGalleryUtils.clickThumbnail(page, veryFirstAsset.id);
-
- await memoryAssetViewerUtils.waitForViewerOpen(page);
- await memoryAssetViewerUtils.waitForAssetLoad(page, veryFirstAsset);
-
- await memoryAssetViewerUtils.expectPreviousButtonNotVisible(page);
- await memoryAssetViewerUtils.expectNextButtonVisible(page);
- });
-
- test('hides next button at very last asset (last memory, last asset, no next memory)', async ({ page }) => {
- const lastMemory = memories.at(-1)!;
- const veryLastAsset = lastMemory.assets.at(-1)!;
-
- await memoryViewerUtils.openMemoryPageWithAsset(page, veryLastAsset.id);
- await memoryGalleryUtils.clickThumbnail(page, veryLastAsset.id);
-
- await memoryAssetViewerUtils.waitForViewerOpen(page);
- await memoryAssetViewerUtils.waitForAssetLoad(page, veryLastAsset);
-
- await memoryAssetViewerUtils.expectNextButtonNotVisible(page);
- await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
- });
- });
-
- test.describe('Keyboard navigation', () => {
- test('ArrowLeft navigates to previous asset across memory boundary', async ({ page }) => {
- const firstMemory = memories[0];
- const secondMemory = memories[1];
- const lastAssetOfFirst = firstMemory.assets.at(-1)!;
- const firstAssetOfSecond = secondMemory.assets[0];
-
- await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
- await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
-
- await memoryAssetViewerUtils.waitForViewerOpen(page);
- await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
-
- await page.keyboard.press('ArrowLeft');
- await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
- });
-
- test('ArrowRight navigates to next asset across memory boundary', async ({ page }) => {
- const firstMemory = memories[0];
- const secondMemory = memories[1];
- const lastAssetOfFirst = firstMemory.assets.at(-1)!;
- const firstAssetOfSecond = secondMemory.assets[0];
-
- await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
- await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
-
- await memoryAssetViewerUtils.waitForViewerOpen(page);
- await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
-
- await page.keyboard.press('ArrowRight');
- await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
- });
- });
-});
-
-test.describe('Memory Viewer - Single Asset Memory Edge Cases', () => {
- let adminUserId: string;
- let timelineRestData: TimelineData;
- let memories: MemoryResponseDto[];
- const assets: TimelineAssetConfig[] = [];
- const testContext = new TimelineTestContext();
- const changes: Changes = {
- albumAdditions: [],
- assetDeletions: [],
- assetArchivals: [],
- assetFavorites: [],
- };
- const memoryChanges: MemoryChanges = {
- memoryDeletions: [],
- assetRemovals: new Map(),
- };
-
- test.beforeAll(async () => {
- adminUserId = faker.string.uuid();
- testContext.adminId = adminUserId;
-
- timelineRestData = generateTimelineData({
- ...createDefaultTimelineConfig(),
- ownerId: adminUserId,
- });
-
- for (const timeBucket of timelineRestData.buckets.values()) {
- assets.push(...timeBucket);
- }
-
- memories = generateMemoriesFromTimeline(
- assets,
- adminUserId,
- [
- { year: 2024, assetCount: 2 },
- { year: 2023, assetCount: 1 },
- { year: 2022, assetCount: 2 },
- ],
- 123,
- );
- });
-
- test.beforeEach(async ({ context }) => {
- await setupBaseMockApiRoutes(context, adminUserId);
- await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
- await setupMemoryMockApiRoutes(context, memories, memoryChanges);
- });
-
- test.afterEach(() => {
- testContext.slowBucket = false;
- changes.albumAdditions = [];
- changes.assetDeletions = [];
- changes.assetArchivals = [];
- changes.assetFavorites = [];
- memoryChanges.memoryDeletions = [];
- memoryChanges.assetRemovals.clear();
- });
-
- test('single asset memory shows both prev/next when surrounded by other memories', async ({ page }) => {
- const singleAssetMemory = memories[1];
- const singleAsset = singleAssetMemory.assets[0];
-
- await memoryViewerUtils.openMemoryPageWithAsset(page, singleAsset.id);
- await memoryGalleryUtils.clickThumbnail(page, singleAsset.id);
-
- await memoryAssetViewerUtils.waitForViewerOpen(page);
- await memoryAssetViewerUtils.waitForAssetLoad(page, singleAsset);
-
- await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
- await memoryAssetViewerUtils.expectNextButtonVisible(page);
- });
-});
diff --git a/e2e/src/web/specs/memory/utils.ts b/e2e/src/web/specs/memory/utils.ts
deleted file mode 100644
index cf99033e7e..0000000000
--- a/e2e/src/web/specs/memory/utils.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import type { AssetResponseDto } from '@immich/sdk';
-import { expect, Page } from '@playwright/test';
-
-function getAssetIdFromUrl(url: URL): string | null {
- const pathMatch = url.pathname.match(/\/memory\/photos\/([^/]+)/);
- if (pathMatch) {
- return pathMatch[1];
- }
- return url.searchParams.get('id');
-}
-
-export const memoryViewerUtils = {
- locator(page: Page) {
- return page.locator('#memory-viewer');
- },
-
- async waitForMemoryLoad(page: Page) {
- await expect(this.locator(page)).toBeVisible();
- await expect(page.locator('#memory-viewer img').first()).toBeVisible();
- },
-
- async openMemoryPage(page: Page) {
- await page.goto('/memory');
- await this.waitForMemoryLoad(page);
- },
-
- async openMemoryPageWithAsset(page: Page, assetId: string) {
- await page.goto(`/memory?id=${assetId}`);
- await this.waitForMemoryLoad(page);
- },
-};
-
-export const memoryGalleryUtils = {
- locator(page: Page) {
- return page.locator('#gallery-memory');
- },
-
- thumbnailWithAssetId(page: Page, assetId: string) {
- return page.locator(`#gallery-memory [data-thumbnail-focus-container][data-asset="${assetId}"]`);
- },
-
- async scrollToGallery(page: Page) {
- const showGalleryButton = page.getByLabel('Show gallery');
- if (await showGalleryButton.isVisible()) {
- await showGalleryButton.click();
- }
- await expect(this.locator(page)).toBeInViewport();
- },
-
- async clickThumbnail(page: Page, assetId: string) {
- await this.scrollToGallery(page);
- await this.thumbnailWithAssetId(page, assetId).click();
- },
-
- async getAllThumbnails(page: Page) {
- await this.scrollToGallery(page);
- return page.locator('#gallery-memory [data-thumbnail-focus-container]');
- },
-};
-
-export const memoryAssetViewerUtils = {
- locator(page: Page) {
- return page.locator('#immich-asset-viewer');
- },
-
- async waitForViewerOpen(page: Page) {
- await expect(this.locator(page)).toBeVisible();
- },
-
- async waitForAssetLoad(page: Page, asset: AssetResponseDto) {
- const viewer = this.locator(page);
- const imgLocator = viewer.locator(`img[draggable="false"][src*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
- const videoLocator = viewer.locator(`video[poster*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
-
- await imgLocator.or(videoLocator).waitFor({ timeout: 10_000 });
- },
-
- nextButton(page: Page) {
- return page.getByLabel('View next asset');
- },
-
- previousButton(page: Page) {
- return page.getByLabel('View previous asset');
- },
-
- async expectNextButtonVisible(page: Page) {
- await expect(this.nextButton(page)).toBeVisible();
- },
-
- async expectNextButtonNotVisible(page: Page) {
- await expect(this.nextButton(page)).toHaveCount(0);
- },
-
- async expectPreviousButtonVisible(page: Page) {
- await expect(this.previousButton(page)).toBeVisible();
- },
-
- async expectPreviousButtonNotVisible(page: Page) {
- await expect(this.previousButton(page)).toHaveCount(0);
- },
-
- async clickNextButton(page: Page) {
- await this.nextButton(page).click();
- },
-
- async clickPreviousButton(page: Page) {
- await this.previousButton(page).click();
- },
-
- async closeViewer(page: Page) {
- await page.keyboard.press('Escape');
- await expect(this.locator(page)).not.toBeVisible();
- },
-
- getCurrentAssetId(page: Page): string | null {
- const url = new URL(page.url());
- return getAssetIdFromUrl(url);
- },
-
- async expectCurrentAssetId(page: Page, expectedAssetId: string) {
- await expect.poll(() => this.getCurrentAssetId(page)).toBe(expectedAssetId);
- },
-};
diff --git a/e2e/src/web/specs/photo-viewer.e2e-spec.ts b/e2e/src/web/specs/photo-viewer.e2e-spec.ts
deleted file mode 100644
index 3f9bb4237a..0000000000
--- a/e2e/src/web/specs/photo-viewer.e2e-spec.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
-import { Page, expect, test } from '@playwright/test';
-import { utils } from 'src/utils';
-
-function imageLocator(page: Page) {
- return page.getByAltText('Image taken').locator('visible=true');
-}
-test.describe('Photo Viewer', () => {
- let admin: LoginResponseDto;
- let asset: AssetMediaResponseDto;
- let rawAsset: AssetMediaResponseDto;
-
- test.beforeAll(async () => {
- utils.initSdk();
- await utils.resetDatabase();
- admin = await utils.adminSetup();
- asset = await utils.createAsset(admin.accessToken);
- rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
- });
-
- test.beforeEach(async ({ context, page }) => {
- // before each test, login as user
- await utils.setAuthCookies(context, admin.accessToken);
- await page.waitForLoadState('networkidle');
- });
-
- test('loads original photo when zoomed', async ({ page }) => {
- await page.goto(`/photos/${asset.id}`);
- await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
- const box = await imageLocator(page).boundingBox();
- expect(box).toBeTruthy();
- const { x, y, width, height } = box!;
- await page.mouse.move(x + width / 2, y + height / 2);
- await page.mouse.wheel(0, -1);
- await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
- });
-
- test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
- await page.goto(`/photos/${rawAsset.id}`);
- await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
- const box = await imageLocator(page).boundingBox();
- expect(box).toBeTruthy();
- const { x, y, width, height } = box!;
- await page.mouse.move(x + width / 2, y + height / 2);
- await page.mouse.wheel(0, -1);
- await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
- });
-
- test('reloads photo when checksum changes', async ({ page }) => {
- await page.goto(`/photos/${asset.id}`);
- await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
- const initialSrc = await imageLocator(page).getAttribute('src');
- await utils.replaceAsset(admin.accessToken, asset.id);
- await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
- });
-});
diff --git a/e2e/src/web/specs/search/search-gallery.ui-spec.ts b/e2e/src/web/specs/search/search-gallery.ui-spec.ts
deleted file mode 100644
index e358bed154..0000000000
--- a/e2e/src/web/specs/search/search-gallery.ui-spec.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { faker } from '@faker-js/faker';
-import { expect, test } from '@playwright/test';
-import {
- Changes,
- createDefaultTimelineConfig,
- generateTimelineData,
- TimelineAssetConfig,
- TimelineData,
-} from 'src/generators/timeline';
-import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
-import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
-import { assetViewerUtils } from 'src/web/specs/timeline/utils';
-
-const buildSearchUrl = (assetId: string) => {
- const searchQuery = encodeURIComponent(JSON.stringify({ originalFileName: 'test' }));
- return `/search/photos/${assetId}?query=${searchQuery}`;
-};
-
-test.describe.configure({ mode: 'parallel' });
-test.describe('search gallery-viewer', () => {
- let adminUserId: string;
- let timelineRestData: TimelineData;
- const assets: TimelineAssetConfig[] = [];
- const testContext = new TimelineTestContext();
- const changes: Changes = {
- albumAdditions: [],
- assetDeletions: [],
- assetArchivals: [],
- assetFavorites: [],
- };
-
- test.beforeAll(async () => {
- adminUserId = faker.string.uuid();
- testContext.adminId = adminUserId;
- timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
- for (const timeBucket of timelineRestData.buckets.values()) {
- assets.push(...timeBucket);
- }
- });
-
- test.beforeEach(async ({ context }) => {
- await setupBaseMockApiRoutes(context, adminUserId);
- await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
-
- await context.route('**/api/search/metadata', async (route, request) => {
- if (request.method() === 'POST') {
- const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: {
- albums: { total: 0, count: 0, items: [], facets: [] },
- assets: {
- total: searchAssets.length,
- count: searchAssets.length,
- items: searchAssets,
- facets: [],
- nextPage: null,
- },
- },
- });
- }
- await route.fallback();
- });
- });
-
- test.afterEach(() => {
- testContext.slowBucket = false;
- changes.albumAdditions = [];
- changes.assetDeletions = [];
- changes.assetArchivals = [];
- changes.assetFavorites = [];
- });
-
- test.describe('/search/photos/:id', () => {
- test('Deleting a photo advances to the next photo', async ({ page }) => {
- const asset = assets[0];
- await page.goto(buildSearchUrl(asset.id));
- await assetViewerUtils.waitForViewerLoad(page, asset);
- await page.getByLabel('Delete').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[1]);
- });
-
- test('Deleting two photos in a row advances to the next photo each time', async ({ page }) => {
- const asset = assets[0];
- await page.goto(buildSearchUrl(asset.id));
- await assetViewerUtils.waitForViewerLoad(page, asset);
- await page.getByLabel('Delete').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[1]);
- await page.getByLabel('Delete').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[2]);
- });
-
- test('Navigating backward then deleting advances to the next photo', async ({ page }) => {
- const asset = assets[1];
- await page.goto(buildSearchUrl(asset.id));
- await assetViewerUtils.waitForViewerLoad(page, asset);
- await page.getByLabel('View previous asset').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[0]);
- await page.getByLabel('View next asset').click();
- await assetViewerUtils.waitForViewerLoad(page, asset);
- await page.getByLabel('Delete').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[2]);
- });
-
- test('Deleting the last photo advances to the previous photo', async ({ page }) => {
- const lastAsset = assets[4];
- await page.goto(buildSearchUrl(lastAsset.id));
- await assetViewerUtils.waitForViewerLoad(page, lastAsset);
- await expect(page.getByLabel('View next asset')).toHaveCount(0);
- await page.getByLabel('Delete').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[3]);
- await expect(page.getByLabel('View previous asset')).toBeVisible();
- });
- });
-});
diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts
deleted file mode 100644
index 017bc0fcb2..0000000000
--- a/e2e/src/web/specs/shared-link.e2e-spec.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import {
- AlbumResponseDto,
- AssetMediaResponseDto,
- LoginResponseDto,
- SharedLinkResponseDto,
- SharedLinkType,
- createAlbum,
-} from '@immich/sdk';
-import { expect, test } from '@playwright/test';
-import { asBearerAuth, utils } from 'src/utils';
-
-test.describe('Shared Links', () => {
- let admin: LoginResponseDto;
- let asset: AssetMediaResponseDto;
- let album: AlbumResponseDto;
- let sharedLink: SharedLinkResponseDto;
- let sharedLinkPassword: SharedLinkResponseDto;
-
- test.beforeAll(async () => {
- utils.initSdk();
- await utils.resetDatabase();
- admin = await utils.adminSetup();
- asset = await utils.createAsset(admin.accessToken);
- album = await createAlbum(
- {
- createAlbumDto: {
- albumName: 'Test Album',
- assetIds: [asset.id],
- },
- },
- { headers: asBearerAuth(admin.accessToken) },
- );
- sharedLink = await utils.createSharedLink(admin.accessToken, {
- type: SharedLinkType.Album,
- albumId: album.id,
- });
- sharedLinkPassword = await utils.createSharedLink(admin.accessToken, {
- type: SharedLinkType.Album,
- albumId: album.id,
- password: 'test-password',
- });
- });
-
- test('download from a shared link', async ({ page }) => {
- await page.goto(`/share/${sharedLink.key}`);
- await page.getByRole('heading', { name: 'Test Album' }).waitFor();
- await page.locator(`[data-asset-id="${asset.id}"]`).hover();
- await page.waitForSelector('[data-group] svg');
- await page.getByRole('checkbox').click();
- await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]);
- });
-
- test('download all from shared link', async ({ page }) => {
- await page.goto(`/share/${sharedLink.key}`);
- await page.getByRole('heading', { name: 'Test Album' }).waitFor();
- await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]);
- });
-
- test('enter password for a shared link', async ({ page }) => {
- await page.goto(`/share/${sharedLinkPassword.key}`);
- await page.getByPlaceholder('Password').fill('test-password');
- await page.getByRole('button', { name: 'Submit' }).click();
- await page.getByRole('heading', { name: 'Test Album' }).waitFor();
- });
-
- test('show-password button visible', async ({ page }) => {
- await page.goto(`/share/${sharedLinkPassword.key}`);
- await page.getByPlaceholder('Password').fill('test-password');
- await page.getByRole('button', { name: 'Show password' }).waitFor();
- });
-
- test('view password for shared link', async ({ page }) => {
- await page.goto(`/share/${sharedLinkPassword.key}`);
- const input = page.getByPlaceholder('Password');
- await input.fill('test-password');
- await page.getByRole('button', { name: 'Show password' }).click();
- // await page.getByText('test-password', { exact: true }).waitFor();
- await expect(input).toHaveAttribute('type', 'text');
- });
-
- test('hide-password button visible', async ({ page }) => {
- await page.goto(`/share/${sharedLinkPassword.key}`);
- const input = page.getByPlaceholder('Password');
- await input.fill('test-password');
- await page.getByRole('button', { name: 'Show password' }).click();
- await page.getByRole('button', { name: 'Hide password' }).waitFor();
- });
-
- test('hide password for shared link', async ({ page }) => {
- await page.goto(`/share/${sharedLinkPassword.key}`);
- const input = page.getByPlaceholder('Password');
- await input.fill('test-password');
- await page.getByRole('button', { name: 'Show password' }).click();
- await page.getByRole('button', { name: 'Hide password' }).click();
- await expect(input).toHaveAttribute('type', 'password');
- });
-
- test('show error for invalid shared link', async ({ page }) => {
- await page.goto('/share/invalid');
- await page.getByRole('heading', { name: 'Invalid share key' }).waitFor();
- });
-
- test('auth on navigation from shared link to timeline', async ({ context, page }) => {
- await utils.setAuthCookies(context, admin.accessToken);
-
- await page.goto(`/share/${sharedLink.key}`);
- await page.getByRole('heading', { name: 'Test Album' }).waitFor();
-
- await page.locator('a[href="/"]').click();
- await page.waitForURL('/photos');
- await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
- });
-});
diff --git a/e2e/src/web/specs/timeline/timeline.ui-spec.ts b/e2e/src/web/specs/timeline/timeline.ui-spec.ts
deleted file mode 100644
index 47026e2cd4..0000000000
--- a/e2e/src/web/specs/timeline/timeline.ui-spec.ts
+++ /dev/null
@@ -1,862 +0,0 @@
-import { faker } from '@faker-js/faker';
-import { expect, test } from '@playwright/test';
-import { DateTime } from 'luxon';
-import {
- Changes,
- createDefaultTimelineConfig,
- generateTimelineData,
- getAsset,
- getMockAsset,
- SeededRandom,
- selectRandom,
- selectRandomMultiple,
- TimelineAssetConfig,
- TimelineData,
-} from 'src/generators/timeline';
-import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
-import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
-import { utils } from 'src/utils';
-import {
- assetViewerUtils,
- padYearMonth,
- pageUtils,
- poll,
- thumbnailUtils,
- timelineUtils,
-} from 'src/web/specs/timeline/utils';
-
-test.describe.configure({ mode: 'parallel' });
-test.describe('Timeline', () => {
- let adminUserId: string;
- let timelineRestData: TimelineData;
- const assets: TimelineAssetConfig[] = [];
- const yearMonths: string[] = [];
- const testContext = new TimelineTestContext();
- const changes: Changes = {
- albumAdditions: [],
- assetDeletions: [],
- assetArchivals: [],
- assetFavorites: [],
- };
-
- test.beforeAll(async () => {
- test.fail(
- process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
- 'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
- );
- utils.initSdk();
- adminUserId = faker.string.uuid();
- testContext.adminId = adminUserId;
- timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
- for (const timeBucket of timelineRestData.buckets.values()) {
- assets.push(...timeBucket);
- }
- for (const yearMonth of timelineRestData.buckets.keys()) {
- const [year, month] = yearMonth.split('-');
- yearMonths.push(`${year}-${Number(month)}`);
- }
- });
-
- test.beforeEach(async ({ context }) => {
- await setupBaseMockApiRoutes(context, adminUserId);
- await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
- });
-
- test.afterEach(() => {
- testContext.slowBucket = false;
- changes.albumAdditions = [];
- changes.assetDeletions = [];
- changes.assetArchivals = [];
- changes.assetFavorites = [];
- });
-
- test.describe('/photos', () => {
- test('Open /photos', async ({ page }) => {
- await page.goto(`/photos`);
- await page.waitForSelector('#asset-grid');
- await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
- });
- test('Deep link to last photo', async ({ page }) => {
- const lastAsset = assets.at(-1)!;
- await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
- await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
- await thumbnailUtils.expectInViewport(page, lastAsset.id);
- });
- const rng = new SeededRandom(529);
- for (let i = 0; i < 10; i++) {
- test('Deep link to random asset ' + i, async ({ page }) => {
- const asset = selectRandom(assets, rng);
- await pageUtils.deepLinkPhotosPage(page, asset.id);
- await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
- await thumbnailUtils.expectInViewport(page, asset.id);
- });
- }
- test('Open /photos, open asset-viewer, browser back', async ({ page }) => {
- const rng = new SeededRandom(22);
- const asset = selectRandom(assets, rng);
- await pageUtils.deepLinkPhotosPage(page, asset.id);
- const scrollTopBefore = await timelineUtils.getScrollTop(page);
- await thumbnailUtils.clickAssetId(page, asset.id);
- await assetViewerUtils.waitForViewerLoad(page, asset);
- await page.goBack();
- await timelineUtils.locator(page).waitFor();
- const scrollTopAfter = await timelineUtils.getScrollTop(page);
- expect(scrollTopAfter).toBe(scrollTopBefore);
- });
- test('Open /photos, open asset-viewer, next photo, browser back, back', async ({ page }) => {
- const rng = new SeededRandom(49);
- const asset = selectRandom(assets, rng);
- const assetIndex = assets.indexOf(asset);
- const nextAsset = assets[assetIndex + 1];
- await pageUtils.deepLinkPhotosPage(page, asset.id);
- const scrollTopBefore = await timelineUtils.getScrollTop(page);
- await thumbnailUtils.clickAssetId(page, asset.id);
- await assetViewerUtils.waitForViewerLoad(page, asset);
- await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
- await page.getByLabel('View next asset').click();
- await assetViewerUtils.waitForViewerLoad(page, nextAsset);
- await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${nextAsset.id}`);
- await page.goBack();
- await assetViewerUtils.waitForViewerLoad(page, asset);
- await page.goBack();
- await page.waitForURL('**/photos?at=*');
- const scrollTopAfter = await timelineUtils.getScrollTop(page);
- expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThan(5);
- });
- test('Open /photos, open asset-viewer, next photo 15x, backwardsArrow', async ({ page }) => {
- await pageUtils.deepLinkPhotosPage(page, assets[0].id);
- await thumbnailUtils.clickAssetId(page, assets[0].id);
- await assetViewerUtils.waitForViewerLoad(page, assets[0]);
- for (let i = 1; i <= 15; i++) {
- await page.getByLabel('View next asset').click();
- await assetViewerUtils.waitForViewerLoad(page, assets[i]);
- }
- await page.getByLabel('Go back').click();
- await page.waitForURL('**/photos?at=*');
- await thumbnailUtils.expectInViewport(page, assets[15].id);
- await thumbnailUtils.expectBottomIsTimelineBottom(page, assets[15]!.id);
- });
- test('Open /photos, open asset-viewer, previous photo 15x, backwardsArrow', async ({ page }) => {
- const lastAsset = assets.at(-1)!;
- await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
- await thumbnailUtils.clickAssetId(page, lastAsset.id);
- await assetViewerUtils.waitForViewerLoad(page, lastAsset);
- for (let i = 1; i <= 15; i++) {
- await page.getByLabel('View previous asset').click();
- await assetViewerUtils.waitForViewerLoad(page, assets.at(-1 - i)!);
- }
- await page.getByLabel('Go back').click();
- await page.waitForURL('**/photos?at=*');
- await thumbnailUtils.expectInViewport(page, assets.at(-1 - 15)!.id);
- await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(-1 - 15)!.id);
- });
- });
- test.describe('keyboard', () => {
- /**
- * This text tests keyboard nativation, and also ensures that the scroll-to-asset behavior
- * scrolls the minimum amount. That is, if you are navigating using right arrow (auto scrolling
- * as necessary downwards), then the asset should always be at the lowest row of the grid.
- */
- test('Next/previous asset - ArrowRight/ArrowLeft', async ({ page }) => {
- await pageUtils.openPhotosPage(page);
- await thumbnailUtils.withAssetId(page, assets[0].id).focus();
- const rightKey = 'ArrowRight';
- const leftKey = 'ArrowLeft';
- for (let i = 1; i < 15; i++) {
- await page.keyboard.press(rightKey);
- await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
- }
- for (let i = 15; i <= 20; i++) {
- await page.keyboard.press(rightKey);
- await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
- expect(await thumbnailUtils.expectBottomIsTimelineBottom(page, assets.at(i)!.id));
- }
- // now test previous asset
- for (let i = 19; i >= 15; i--) {
- await page.keyboard.press(leftKey);
- await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
- }
- for (let i = 14; i > 0; i--) {
- await page.keyboard.press(leftKey);
- await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
- expect(await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(i)!.id));
- }
- });
- test('Next/previous asset - Tab/Shift+Tab', async ({ page }) => {
- await pageUtils.openPhotosPage(page);
- await thumbnailUtils.withAssetId(page, assets[0].id).focus();
- const rightKey = 'Tab';
- const leftKey = 'Shift+Tab';
- for (let i = 1; i < 15; i++) {
- await page.keyboard.press(rightKey);
- await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
- }
- for (let i = 15; i <= 20; i++) {
- await page.keyboard.press(rightKey);
- await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
- }
- // now test previous asset
- for (let i = 19; i >= 15; i--) {
- await page.keyboard.press(leftKey);
- await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
- }
- for (let i = 14; i > 0; i--) {
- await page.keyboard.press(leftKey);
- await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
- }
- });
- test('Next/previous day - d, Shift+D', async ({ page }) => {
- await pageUtils.openPhotosPage(page);
- let asset = assets[0];
- await timelineUtils.locator(page).hover();
- await page.keyboard.press('d');
- await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
- for (let i = 0; i < 15; i++) {
- await page.keyboard.press('d');
- const next = getMockAsset(asset, assets, 'next', 'day')!;
- await assetViewerUtils.expectActiveAssetToBe(page, next.id);
- asset = next;
- }
- for (let i = 0; i < 15; i++) {
- await page.keyboard.press('Shift+D');
- const previous = getMockAsset(asset, assets, 'previous', 'day')!;
- await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
- asset = previous;
- }
- });
- test('Next/previous month - m, Shift+M', async ({ page }) => {
- await pageUtils.openPhotosPage(page);
- let asset = assets[0];
- await timelineUtils.locator(page).hover();
- await page.keyboard.press('m');
- await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
- for (let i = 0; i < 15; i++) {
- await page.keyboard.press('m');
- const next = getMockAsset(asset, assets, 'next', 'month')!;
- await assetViewerUtils.expectActiveAssetToBe(page, next.id);
- asset = next;
- }
- for (let i = 0; i < 15; i++) {
- await page.keyboard.press('Shift+M');
- const previous = getMockAsset(asset, assets, 'previous', 'month')!;
- await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
- asset = previous;
- }
- });
- test('Next/previous year - y, Shift+Y', async ({ page }) => {
- await pageUtils.openPhotosPage(page);
- let asset = assets[0];
- await timelineUtils.locator(page).hover();
- await page.keyboard.press('y');
- await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
- for (let i = 0; i < 15; i++) {
- await page.keyboard.press('y');
- const next = getMockAsset(asset, assets, 'next', 'year')!;
- await assetViewerUtils.expectActiveAssetToBe(page, next.id);
- asset = next;
- }
- for (let i = 0; i < 15; i++) {
- await page.keyboard.press('Shift+Y');
- const previous = getMockAsset(asset, assets, 'previous', 'year')!;
- await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
- asset = previous;
- }
- });
- test('Navigate to time - g', async ({ page }) => {
- const rng = new SeededRandom(4782);
- await pageUtils.openPhotosPage(page);
- for (let i = 0; i < 10; i++) {
- const asset = selectRandom(assets, rng);
- await pageUtils.goToAsset(page, asset.fileCreatedAt);
- await thumbnailUtils.expectInViewport(page, asset.id);
- }
- });
- });
- test.describe('selection', () => {
- test('Select day, unselect day', async ({ page }) => {
- await pageUtils.openPhotosPage(page);
- await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
- await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
- await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
- await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
- });
- test('Select asset, click asset to select', async ({ page }) => {
- await pageUtils.openPhotosPage(page);
- await thumbnailUtils.withAssetId(page, assets[1].id).hover();
- await thumbnailUtils.selectButton(page, assets[1].id).click();
- await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
- // no need to hover, once selection is active
- await thumbnailUtils.clickAssetId(page, assets[2].id);
- await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(2);
- });
- test('Select asset, click unselect asset', async ({ page }) => {
- await pageUtils.openPhotosPage(page);
- await thumbnailUtils.withAssetId(page, assets[1].id).hover();
- await thumbnailUtils.selectButton(page, assets[1].id).click();
- await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
- await thumbnailUtils.clickAssetId(page, assets[1].id);
- // the hover uses a checked button too, so just move mouse away
- await page.mouse.move(0, 0);
- await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
- });
- test('Select asset, shift-hover candidates, shift-click end', async ({ page }) => {
- await pageUtils.openPhotosPage(page);
- const asset = assets[0];
- await thumbnailUtils.withAssetId(page, asset.id).hover();
- await thumbnailUtils.selectButton(page, asset.id).click();
- await page.keyboard.down('Shift');
- await thumbnailUtils.withAssetId(page, assets[2].id).hover();
- await expect(
- thumbnailUtils.locator(page).locator('.absolute.top-0.h-full.w-full.bg-immich-primary.opacity-40'),
- ).toHaveCount(3);
- await thumbnailUtils.selectButton(page, assets[2].id).click();
- await page.keyboard.up('Shift');
- await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(3);
- });
- test('Add multiple to selection - Select day, shift-click end', async ({ page }) => {
- await pageUtils.openPhotosPage(page);
- await thumbnailUtils.withAssetId(page, assets[0].id).hover();
- await thumbnailUtils.selectButton(page, assets[0].id).click();
- await thumbnailUtils.clickAssetId(page, assets[2].id);
- await page.keyboard.down('Shift');
- await thumbnailUtils.clickAssetId(page, assets[4].id);
- await page.mouse.move(0, 0);
- await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
- });
- });
- test.describe('scroll', () => {
- test('Open /photos, random click scrubber 20x', async ({ page }) => {
- test.slow();
- await pageUtils.openPhotosPage(page);
- const rng = new SeededRandom(6637);
- const selectedMonths = selectRandomMultiple(yearMonths, 20, rng);
- for (const month of selectedMonths) {
- await page.locator(`[data-segment-year-month="${month}"]`).click({ force: true });
- const visibleMockAssetsYearMonths = await poll(page, async () => {
- const assetIds = await thumbnailUtils.getAllInViewport(
- page,
- (assetId: string) => getYearMonth(assets, assetId) === month,
- );
- const visibleMockAssetsYearMonths: string[] = [];
- for (const assetId of assetIds!) {
- const yearMonth = getYearMonth(assets, assetId);
- visibleMockAssetsYearMonths.push(yearMonth);
- if (yearMonth === month) {
- return [yearMonth];
- }
- }
- });
- if (page.isClosed()) {
- return;
- }
- expect(visibleMockAssetsYearMonths).toContain(month);
- }
- });
- test('Deep link to last photo, scroll up', async ({ page }) => {
- const lastAsset = assets.at(-1)!;
- await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
-
- await timelineUtils.locator(page).hover();
- for (let i = 0; i < 100; i++) {
- await page.mouse.wheel(0, -100);
- await page.waitForTimeout(25);
- }
-
- await thumbnailUtils.expectInViewport(page, '14e5901f-fd7f-40c0-b186-4d7e7fc67968');
- });
- test('Deep link to first bucket, scroll down', async ({ page }) => {
- const lastAsset = assets.at(0)!;
- await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
- await timelineUtils.locator(page).hover();
- for (let i = 0; i < 100; i++) {
- await page.mouse.wheel(0, 100);
- await page.waitForTimeout(25);
- }
- await thumbnailUtils.expectInViewport(page, 'b7983a13-4b4e-4950-a731-f2962d9a1555');
- });
- test('Deep link to last photo, drag scrubber to scroll up', async ({ page }) => {
- const lastAsset = assets.at(-1)!;
- await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
- const lastMonth = yearMonths.at(-1);
- const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
- const lastScrubSegment = page.locator(`[data-segment-year-month="${lastMonth}"]`);
- const sourcebox = (await lastScrubSegment.boundingBox())!;
- const targetBox = (await firstScrubSegment.boundingBox())!;
- await firstScrubSegment.hover();
- const currentY = sourcebox.y;
- await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
- await page.mouse.down();
- await page.mouse.move(sourcebox.x + sourcebox?.width / 2, targetBox.y, { steps: 100 });
- await page.mouse.up();
- await thumbnailUtils.expectInViewport(page, assets[0].id);
- });
- test('Deep link to first bucket, drag scrubber to scroll down', async ({ page }) => {
- await pageUtils.deepLinkPhotosPage(page, assets[0].id);
- const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
- const sourcebox = (await firstScrubSegment.boundingBox())!;
- await firstScrubSegment.hover();
- const currentY = sourcebox.y;
- await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
- await page.mouse.down();
- const height = page.viewportSize()?.height;
- expect(height).toBeDefined();
- await page.mouse.move(sourcebox.x + sourcebox?.width / 2, height! - 10, {
- steps: 100,
- });
- await page.mouse.up();
- await thumbnailUtils.expectInViewport(page, assets.at(-1)!.id);
- });
- test('Buckets cancel on scroll', async ({ page }) => {
- await pageUtils.openPhotosPage(page);
- testContext.slowBucket = true;
- const failedUris: string[] = [];
- page.on('requestfailed', (request) => {
- failedUris.push(request.url());
- });
- const offscreenSegment = page.locator(`[data-segment-year-month="${yearMonths[12]}"]`);
- await offscreenSegment.click({ force: true });
- const lastSegment = page.locator(`[data-segment-year-month="${yearMonths.at(-1)!}"]`);
- await lastSegment.click({ force: true });
- const uris = await poll(page, async () => (failedUris.length > 0 ? failedUris : null));
- expect(uris).toEqual(expect.arrayContaining([expect.stringContaining(padYearMonth(yearMonths[12]!))]));
- });
- });
- test.describe('/albums', () => {
- test('Open album', async ({ page }) => {
- const album = timelineRestData.album;
- await pageUtils.openAlbumPage(page, album.id);
- await thumbnailUtils.expectInViewport(page, album.assetIds[0]);
- });
- test('Deep link to last photo', async ({ page }) => {
- const album = timelineRestData.album;
- const lastAsset = album.assetIds.at(-1);
- await pageUtils.deepLinkAlbumPage(page, album.id, lastAsset!);
- await thumbnailUtils.expectInViewport(page, album.assetIds.at(-1)!);
- await thumbnailUtils.expectBottomIsTimelineBottom(page, album.assetIds.at(-1)!);
- });
- test('Add photos to album pre-selects existing', async ({ page }) => {
- const album = timelineRestData.album;
- await pageUtils.openAlbumPage(page, album.id);
- await page.getByLabel('Add photos').click();
- const asset = getAsset(timelineRestData, album.assetIds[0])!;
- await pageUtils.goToAsset(page, asset.fileCreatedAt);
- await thumbnailUtils.expectInViewport(page, asset.id);
- await thumbnailUtils.expectSelectedReadonly(page, asset.id);
- });
- test('Add photos to album', async ({ page }) => {
- const album = timelineRestData.album;
- await pageUtils.openAlbumPage(page, album.id);
- await page.locator('nav button[aria-label="Add photos"]').click();
- const asset = getAsset(timelineRestData, album.assetIds[0])!;
- await pageUtils.goToAsset(page, asset.fileCreatedAt);
- await thumbnailUtils.expectInViewport(page, asset.id);
- await thumbnailUtils.expectSelectedReadonly(page, asset.id);
- await pageUtils.selectDay(page, 'Tue, Feb 27, 2024');
- const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => {
- const requestJson = request.postDataJSON();
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: requestJson.ids.map((id: string) => ({ id, success: true })),
- });
- changes.albumAdditions.push(...requestJson.ids);
- });
- await page.getByText('Add assets').click();
- await expect(put).resolves.toEqual({
- ids: [
- 'c077ea7b-cfa1-45e4-8554-f86c00ee5658',
- '040fd762-dbbc-486d-a51a-2d84115e6229',
- '86af0b5f-79d3-4f75-bab3-3b61f6c72b23',
- ],
- });
- const addedAsset = getAsset(timelineRestData, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658')!;
- await pageUtils.goToAsset(page, addedAsset.fileCreatedAt);
- await thumbnailUtils.expectInViewport(page, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658');
- await thumbnailUtils.expectInViewport(page, '040fd762-dbbc-486d-a51a-2d84115e6229');
- await thumbnailUtils.expectInViewport(page, '86af0b5f-79d3-4f75-bab3-3b61f6c72b23');
- });
- });
- test.describe('/trash', () => {
- test('open /photos, trash photo, open /trash, restore', async ({ page }) => {
- await pageUtils.openPhotosPage(page);
- const assetToTrash = assets[0];
- await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
- await thumbnailUtils.selectButton(page, assetToTrash.id).click();
- await page.getByLabel('Menu').click();
- const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
- const requestJson = request.postDataJSON();
- changes.assetDeletions.push(...requestJson.ids);
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: requestJson.ids.map((id: string) => ({ id, success: true })),
- });
- });
- await page.getByRole('menuitem').getByText('Delete').click();
- await expect(deleteRequest).resolves.toEqual({
- force: false,
- ids: [assetToTrash.id],
- });
- await page.getByText('Trash', { exact: true }).click();
- await thumbnailUtils.expectInViewport(page, assetToTrash.id);
- await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
- await thumbnailUtils.selectButton(page, assetToTrash.id).click();
- const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
- const requestJson = request.postDataJSON();
- changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: { count: requestJson.ids.length },
- });
- });
- await page.getByText('Restore', { exact: true }).click();
- await expect(restoreRequest).resolves.toEqual({
- ids: [assetToTrash.id],
- });
- await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
- await page.getByText('Photos', { exact: true }).click();
- await thumbnailUtils.expectInViewport(page, assetToTrash.id);
- });
- test('open album, trash photo, open /trash, restore', async ({ page }) => {
- const album = timelineRestData.album;
- await pageUtils.openAlbumPage(page, album.id);
- const assetToTrash = getAsset(timelineRestData, album.assetIds[0])!;
- await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
- await thumbnailUtils.selectButton(page, assetToTrash.id).click();
- await page.getByLabel('Menu').click();
- const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
- const requestJson = request.postDataJSON();
- changes.assetDeletions.push(...requestJson.ids);
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: requestJson.ids.map((id: string) => ({ id, success: true })),
- });
- });
- await page.getByRole('menuitem').getByText('Delete').click();
- await expect(deleteRequest).resolves.toEqual({
- force: false,
- ids: [assetToTrash.id],
- });
- await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
- await page.getByText('Trash', { exact: true }).click();
- await timelineUtils.waitForTimelineLoad(page);
- await thumbnailUtils.expectInViewport(page, assetToTrash.id);
- await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
- await thumbnailUtils.selectButton(page, assetToTrash.id).click();
- const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
- const requestJson = request.postDataJSON();
- changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- json: { count: requestJson.ids.length },
- });
- });
- await page.getByText('Restore', { exact: true }).click();
- await expect(restoreRequest).resolves.toEqual({
- ids: [assetToTrash.id],
- });
- await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
- await pageUtils.openAlbumPage(page, album.id);
- await thumbnailUtils.expectInViewport(page, assetToTrash.id);
- });
- });
- test.describe('/archive', () => {
- test('open /photos, archive photo, open /archive, unarchive', async ({ page }) => {
- await pageUtils.openPhotosPage(page);
- const assetToArchive = assets[0];
- await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
- await thumbnailUtils.selectButton(page, assetToArchive.id).click();
- await page.getByLabel('Menu').click();
- const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
- const requestJson = request.postDataJSON();
- if (requestJson.visibility !== 'archive') {
- return await route.continue();
- }
- await route.fulfill({
- status: 204,
- });
- changes.assetArchivals.push(...requestJson.ids);
- });
- await page.getByRole('menuitem').getByText('Archive').click();
- await expect(archive).resolves.toEqual({
- visibility: 'archive',
- ids: [assetToArchive.id],
- });
- await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
- await page.getByRole('link').getByText('Archive').click();
- await thumbnailUtils.expectInViewport(page, assetToArchive.id);
- await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
- await thumbnailUtils.selectButton(page, assetToArchive.id).click();
- const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
- const requestJson = request.postDataJSON();
- if (requestJson.visibility !== 'timeline') {
- return await route.continue();
- }
- changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
- await route.fulfill({
- status: 204,
- });
- });
- await page.getByLabel('Unarchive').click();
- await expect(unarchiveRequest).resolves.toEqual({
- visibility: 'timeline',
- ids: [assetToArchive.id],
- });
- await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
- await page.getByText('Photos', { exact: true }).click();
- await thumbnailUtils.expectInViewport(page, assetToArchive.id);
- });
- test('open /archive, favorite photo, unfavorite', async ({ page }) => {
- const assetToFavorite = assets[0];
- changes.assetArchivals.push(assetToFavorite.id);
- await pageUtils.openArchivePage(page);
- const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
- const requestJson = request.postDataJSON();
- if (requestJson.isFavorite === undefined) {
- return await route.continue();
- }
- const isFavorite = requestJson.isFavorite;
- if (isFavorite) {
- changes.assetFavorites.push(...requestJson.ids);
- }
- await route.fulfill({
- status: 204,
- });
- });
- await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
- await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
- await page.getByLabel('Favorite').click();
- await expect(favorite).resolves.toEqual({
- isFavorite: true,
- ids: [assetToFavorite.id],
- });
- await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
- await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
- await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
- await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
- await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
- const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
- const requestJson = request.postDataJSON();
- if (requestJson.isFavorite === undefined) {
- return await route.continue();
- }
- changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
- await route.fulfill({
- status: 204,
- });
- });
- await page.getByLabel('Remove from favorites').click();
- await expect(unFavoriteRequest).resolves.toEqual({
- isFavorite: false,
- ids: [assetToFavorite.id],
- });
- await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
- await thumbnailUtils.expectThumbnailIsNotFavorite(page, assetToFavorite.id);
- });
- test('open album, archive photo, open album, unarchive', async ({ page }) => {
- const album = timelineRestData.album;
- await pageUtils.openAlbumPage(page, album.id);
- const assetToArchive = getAsset(timelineRestData, album.assetIds[0])!;
- await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
- await thumbnailUtils.selectButton(page, assetToArchive.id).click();
- await page.getByLabel('Menu').click();
- const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
- const requestJson = request.postDataJSON();
- if (requestJson.visibility !== 'archive') {
- return await route.continue();
- }
- changes.assetArchivals.push(...requestJson.ids);
- await route.fulfill({
- status: 204,
- });
- });
- await page.getByRole('menuitem').getByText('Archive').click();
- await expect(archive).resolves.toEqual({
- visibility: 'archive',
- ids: [assetToArchive.id],
- });
- await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
- await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
- await page.getByRole('link').getByText('Archive').click();
- await timelineUtils.waitForTimelineLoad(page);
- await thumbnailUtils.expectInViewport(page, assetToArchive.id);
- await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
- await thumbnailUtils.selectButton(page, assetToArchive.id).click();
- const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
- const requestJson = request.postDataJSON();
- if (requestJson.visibility !== 'timeline') {
- return await route.continue();
- }
- changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
- await route.fulfill({
- status: 204,
- });
- });
- await page.getByLabel('Unarchive').click();
- await expect(unarchiveRequest).resolves.toEqual({
- visibility: 'timeline',
- ids: [assetToArchive.id],
- });
- await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
- await pageUtils.openAlbumPage(page, album.id);
- await thumbnailUtils.expectInViewport(page, assetToArchive.id);
- });
- });
- test.describe('/favorite', () => {
- test('open /photos, favorite photo, open /favorites, remove favorite, open /photos', async ({ page }) => {
- await pageUtils.openPhotosPage(page);
- const assetToFavorite = assets[0];
-
- await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
- await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
- const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
- const requestJson = request.postDataJSON();
- if (requestJson.isFavorite === undefined) {
- return await route.continue();
- }
- const isFavorite = requestJson.isFavorite;
- if (isFavorite) {
- changes.assetFavorites.push(...requestJson.ids);
- }
- await route.fulfill({
- status: 204,
- });
- });
- await page.getByLabel('Favorite').click();
- await expect(favorite).resolves.toEqual({
- isFavorite: true,
- ids: [assetToFavorite.id],
- });
- // ensure thumbnail still exists and has favorite icon
- await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
- await page.getByRole('link').getByText('Favorites').click();
- await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
- await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
- await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
- const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
- const requestJson = request.postDataJSON();
- if (requestJson.isFavorite === undefined) {
- return await route.continue();
- }
- changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
- await route.fulfill({
- status: 204,
- });
- });
- await page.getByLabel('Remove from favorites').click();
- await expect(unFavoriteRequest).resolves.toEqual({
- isFavorite: false,
- ids: [assetToFavorite.id],
- });
- await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(0);
- await page.getByText('Photos', { exact: true }).click();
- await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
- });
- test('open /favorites, archive photo, unarchive photo', async ({ page }) => {
- await pageUtils.openFavorites(page);
- const assetToArchive = getAsset(timelineRestData, 'ad31e29f-2069-4574-b9a9-ad86523c92cb')!;
- await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
- await thumbnailUtils.selectButton(page, assetToArchive.id).click();
- await page.getByLabel('Menu').click();
- const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
- const requestJson = request.postDataJSON();
- if (requestJson.visibility !== 'archive') {
- return await route.continue();
- }
- await route.fulfill({
- status: 204,
- });
- changes.assetArchivals.push(...requestJson.ids);
- });
- await page.getByRole('menuitem').getByText('Archive').click();
- await expect(archive).resolves.toEqual({
- visibility: 'archive',
- ids: [assetToArchive.id],
- });
- await page.getByRole('link').getByText('Archive').click();
- await thumbnailUtils.expectInViewport(page, assetToArchive.id);
- await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
- await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
- await thumbnailUtils.selectButton(page, assetToArchive.id).click();
- const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
- const requestJson = request.postDataJSON();
- if (requestJson.visibility !== 'timeline') {
- return await route.continue();
- }
- changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
- await route.fulfill({
- status: 204,
- });
- });
- await page.getByLabel('Unarchive').click();
- await expect(unarchiveRequest).resolves.toEqual({
- visibility: 'timeline',
- ids: [assetToArchive.id],
- });
- await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
- await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
- });
- test('Open album, favorite photo, open /favorites, remove favorite, Open album', async ({ page }) => {
- const album = timelineRestData.album;
- await pageUtils.openAlbumPage(page, album.id);
- const assetToFavorite = getAsset(timelineRestData, album.assetIds[0])!;
-
- await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
- await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
- const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
- const requestJson = request.postDataJSON();
- if (requestJson.isFavorite === undefined) {
- return await route.continue();
- }
- const isFavorite = requestJson.isFavorite;
- if (isFavorite) {
- changes.assetFavorites.push(...requestJson.ids);
- }
- await route.fulfill({
- status: 204,
- });
- });
- await page.getByLabel('Favorite').click();
- await expect(favorite).resolves.toEqual({
- isFavorite: true,
- ids: [assetToFavorite.id],
- });
- // ensure thumbnail still exists and has favorite icon
- await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
- await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
- await page.getByRole('link').getByText('Favorites').click();
- await timelineUtils.waitForTimelineLoad(page);
- await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
- await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
- await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
- await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
- const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
- const requestJson = request.postDataJSON();
- if (requestJson.isFavorite === undefined) {
- return await route.continue();
- }
- changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
- await route.fulfill({
- status: 204,
- });
- });
- await page.getByLabel('Remove from favorites').click();
- await expect(unFavoriteRequest).resolves.toEqual({
- isFavorite: false,
- ids: [assetToFavorite.id],
- });
- await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(0);
- await pageUtils.openAlbumPage(page, album.id);
- await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
- });
- });
-});
-
-const getYearMonth = (assets: TimelineAssetConfig[], assetId: string) => {
- const mockAsset = assets.find((mockAsset) => mockAsset.id === assetId)!;
- const dateTime = DateTime.fromISO(mockAsset.fileCreatedAt!);
- return dateTime.year + '-' + dateTime.month;
-};
diff --git a/e2e/src/web/specs/timeline/utils.ts b/e2e/src/web/specs/timeline/utils.ts
deleted file mode 100644
index 0f04bf9361..0000000000
--- a/e2e/src/web/specs/timeline/utils.ts
+++ /dev/null
@@ -1,228 +0,0 @@
-import { BrowserContext, expect, Page } from '@playwright/test';
-import { DateTime } from 'luxon';
-import { TimelineAssetConfig } from 'src/generators/timeline';
-
-export const sleep = (ms: number) => {
- return new Promise((resolve) => setTimeout(resolve, ms));
-};
-
-export const padYearMonth = (yearMonth: string) => {
- const [year, month] = yearMonth.split('-');
- return `${year}-${month.padStart(2, '0')}`;
-};
-
-export async function throttlePage(context: BrowserContext, page: Page) {
- const session = await context.newCDPSession(page);
- await session.send('Network.emulateNetworkConditions', {
- offline: false,
- downloadThroughput: (1.5 * 1024 * 1024) / 8,
- uploadThroughput: (750 * 1024) / 8,
- latency: 40,
- connectionType: 'cellular3g',
- });
- await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
-}
-
-export const poll = async (
- page: Page,
- query: () => Promise,
- callback?: (result: Awaited | undefined) => boolean,
-) => {
- let result;
- const timeout = Date.now() + 10_000;
-
- const terminate = callback || ((result: Awaited | undefined) => !!result);
- while (!terminate(result) && Date.now() < timeout) {
- try {
- result = await query();
- } catch {
- // ignore
- }
- if (page.isClosed()) {
- return;
- }
- try {
- await page.waitForTimeout(50);
- } catch {
- return;
- }
- }
- if (!result) {
- // rerun to trigger error if any
- result = await query();
- }
- return result;
-};
-
-export const thumbnailUtils = {
- locator(page: Page) {
- return page.locator('[data-thumbnail-focus-container]');
- },
- withAssetId(page: Page, assetId: string) {
- return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"]`);
- },
- selectButton(page: Page, assetId: string) {
- return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
- },
- selectedAsset(page: Page) {
- return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
- },
- async clickAssetId(page: Page, assetId: string) {
- await thumbnailUtils.withAssetId(page, assetId).click();
- },
- async queryThumbnailInViewport(page: Page, collector: (assetId: string) => boolean) {
- const assetIds: string[] = [];
- for (const thumb of await this.locator(page).all()) {
- const box = await thumb.boundingBox();
- if (box) {
- const assetId = await thumb.evaluate((e) => e.dataset.asset);
- if (collector?.(assetId!)) {
- return [assetId!];
- }
- assetIds.push(assetId!);
- }
- }
- return assetIds;
- },
- async getFirstInViewport(page: Page) {
- return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, () => true));
- },
- async getAllInViewport(page: Page, collector: (assetId: string) => boolean) {
- return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, collector));
- },
- async expectThumbnailIsFavorite(page: Page, assetId: string) {
- await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(1);
- },
- async expectThumbnailIsNotFavorite(page: Page, assetId: string) {
- await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(0);
- },
- async expectThumbnailIsArchive(page: Page, assetId: string) {
- await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(1);
- },
- async expectThumbnailIsNotArchive(page: Page, assetId: string) {
- await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
- },
- async expectSelectedReadonly(page: Page, assetId: string) {
- // todo - need a data attribute for selected
- await expect(
- page.locator(
- `[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
- ),
- ).toBeVisible();
- },
- async expectTimelineHasOnScreenAssets(page: Page) {
- const first = await thumbnailUtils.getFirstInViewport(page);
- if (page.isClosed()) {
- return;
- }
- expect(first).toBeTruthy();
- },
- async expectInViewport(page: Page, assetId: string) {
- const box = await poll(page, () => thumbnailUtils.withAssetId(page, assetId).boundingBox());
- if (page.isClosed()) {
- return;
- }
- expect(box).toBeTruthy();
- },
- async expectBottomIsTimelineBottom(page: Page, assetId: string) {
- const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
- const gridBox = await timelineUtils.locator(page).boundingBox();
- if (page.isClosed()) {
- return;
- }
- expect(box!.y + box!.height).toBeCloseTo(gridBox!.y + gridBox!.height, 0);
- },
- async expectTopIsTimelineTop(page: Page, assetId: string) {
- const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
- const gridBox = await timelineUtils.locator(page).boundingBox();
- if (page.isClosed()) {
- return;
- }
- expect(box!.y).toBeCloseTo(gridBox!.y, 0);
- },
-};
-export const timelineUtils = {
- locator(page: Page) {
- return page.locator('#asset-grid');
- },
- async waitForTimelineLoad(page: Page) {
- await expect(timelineUtils.locator(page)).toBeInViewport();
- await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
- },
- async getScrollTop(page: Page) {
- const queryTop = () =>
- page.evaluate(() => {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- return document.querySelector('#asset-grid').scrollTop;
- });
- await expect.poll(queryTop).toBeGreaterThan(0);
- return await queryTop();
- },
-};
-
-export const assetViewerUtils = {
- locator(page: Page) {
- return page.locator('#immich-asset-viewer');
- },
- async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
- await page
- .locator(
- `img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
- )
- .or(
- page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
- )
- .waitFor();
- },
- async expectActiveAssetToBe(page: Page, assetId: string) {
- const activeElement = () =>
- page.evaluate(() => {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- return document.activeElement?.dataset?.asset;
- });
- await expect(poll(page, activeElement, (result) => result === assetId)).resolves.toBe(assetId);
- },
-};
-export const pageUtils = {
- async deepLinkPhotosPage(page: Page, assetId: string) {
- await page.goto(`/photos?at=${assetId}`);
- await timelineUtils.waitForTimelineLoad(page);
- },
- async openPhotosPage(page: Page) {
- await page.goto(`/photos`);
- await timelineUtils.waitForTimelineLoad(page);
- },
- async openFavorites(page: Page) {
- await page.goto(`/favorites`);
- await timelineUtils.waitForTimelineLoad(page);
- },
- async openAlbumPage(page: Page, albumId: string) {
- await page.goto(`/albums/${albumId}`);
- await timelineUtils.waitForTimelineLoad(page);
- },
- async openArchivePage(page: Page) {
- await page.goto(`/archive`);
- await timelineUtils.waitForTimelineLoad(page);
- },
- async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) {
- await page.goto(`/albums/${albumId}?at=${assetId}`);
- await timelineUtils.waitForTimelineLoad(page);
- },
- async goToAsset(page: Page, assetDate: string) {
- await timelineUtils.locator(page).hover();
- const stringDate = DateTime.fromISO(assetDate).toFormat('MMddyyyy,hh:mm:ss.SSSa');
- await page.keyboard.press('g');
- await page.locator('#datetime').pressSequentially(stringDate);
- await page.getByText('Confirm').click();
- },
- async selectDay(page: Page, day: string) {
- await page.getByTitle(day).hover();
- await page.locator('[data-group] .w-8').click();
- },
- async pauseTestDebug() {
- console.log('NOTE: pausing test indefinately for debug');
- await new Promise(() => void 0);
- },
-};
diff --git a/e2e/src/web/specs/user-admin.e2e-spec.ts b/e2e/src/web/specs/user-admin.e2e-spec.ts
deleted file mode 100644
index 67a537ba9d..0000000000
--- a/e2e/src/web/specs/user-admin.e2e-spec.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { getUserAdmin } from '@immich/sdk';
-import { expect, test } from '@playwright/test';
-import { asBearerAuth, utils } from 'src/utils';
-
-test.describe('User Administration', () => {
- test.beforeAll(() => {
- utils.initSdk();
- });
-
- test.beforeEach(async () => {
- await utils.resetDatabase();
- });
-
- test('validate admin/users link', async ({ context, page }) => {
- const admin = await utils.adminSetup();
- await utils.setAuthCookies(context, admin.accessToken);
-
- // Navigate to user management page and verify title and header
- await page.goto(`/admin/users`);
- await expect(page).toHaveTitle(/User Management/);
- await expect(page.getByText('User Management')).toBeVisible();
- });
-
- test('create user', async ({ context, page }) => {
- const admin = await utils.adminSetup();
- await utils.setAuthCookies(context, admin.accessToken);
-
- // Create a new user
- await page.goto('/admin/users');
- await page.getByRole('button', { name: 'Create user' }).click();
- await page.getByLabel('Email').fill('user@immich.cloud');
- await page.getByLabel('Password', { exact: true }).fill('password');
- await page.getByLabel('Confirm Password').fill('password');
- await page.getByLabel('Name').fill('Immich User');
- await page.getByRole('button', { name: 'Create', exact: true }).click();
-
- // Verify the user exists in the user list
- await page.getByRole('row', { name: 'user@immich.cloud' });
- });
-
- test('promote to admin', async ({ context, page }) => {
- const admin = await utils.adminSetup();
- await utils.setAuthCookies(context, admin.accessToken);
-
- const user = await utils.userSetup(admin.accessToken, {
- name: 'Admin 2',
- email: 'admin2@immich.cloud',
- password: 'password',
- });
-
- expect(user.isAdmin).toBe(false);
-
- await page.goto(`/admin/users/${user.userId}`);
-
- await page.getByRole('button', { name: 'Edit' }).click();
- await expect(page.getByLabel('Admin User')).not.toBeChecked();
- await page.getByLabel('Admin User').click();
- await expect(page.getByLabel('Admin User')).toBeChecked();
- await page.getByRole('button', { name: 'Save' }).click();
-
- await expect
- .poll(async () => {
- const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
- return userAdmin.isAdmin;
- })
- .toBe(true);
- });
-
- test('revoke admin access', async ({ context, page }) => {
- const admin = await utils.adminSetup();
- await utils.setAuthCookies(context, admin.accessToken);
-
- const user = await utils.userSetup(admin.accessToken, {
- name: 'Admin 2',
- email: 'admin2@immich.cloud',
- password: 'password',
- isAdmin: true,
- });
-
- expect(user.isAdmin).toBe(true);
-
- await page.goto(`/admin/users/${user.userId}`);
-
- await page.getByRole('button', { name: 'Edit' }).click();
- await expect(page.getByLabel('Admin User')).toBeChecked();
- await page.getByLabel('Admin User').click();
- await expect(page.getByLabel('Admin User')).not.toBeChecked();
- await page.getByRole('button', { name: 'Save' }).click();
-
- await expect
- .poll(async () => {
- const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
- return userAdmin.isAdmin;
- })
- .toBe(false);
- });
-});
diff --git a/e2e/src/web/specs/websocket.e2e-spec.ts b/e2e/src/web/specs/websocket.e2e-spec.ts
deleted file mode 100644
index a929c6467f..0000000000
--- a/e2e/src/web/specs/websocket.e2e-spec.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { LoginResponseDto } from '@immich/sdk';
-import { expect, test } from '@playwright/test';
-import { utils } from 'src/utils';
-
-test.describe('Websocket', () => {
- let admin: LoginResponseDto;
-
- test.beforeAll(async () => {
- utils.initSdk();
- await utils.resetDatabase();
- admin = await utils.adminSetup();
- });
-
- test('connects using ipv4', async ({ page, context }) => {
- await utils.setAuthCookies(context, admin.accessToken);
- await page.goto('http://127.0.0.1:2285/');
- await expect(page.locator('#sidebar')).toContainText('Server Online');
- });
-
- test('connects using ipv6', async ({ page, context }) => {
- await utils.setAuthCookies(context, admin.accessToken, '[::1]');
- await page.goto('http://[::1]:2285/');
- await expect(page.locator('#sidebar')).toContainText('Server Online');
- });
-});
diff --git a/i18n/af.json b/i18n/af.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/af.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/ar.json b/i18n/ar.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/ar.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/az.json b/i18n/az.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/az.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/be.json b/i18n/be.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/be.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/bg.json b/i18n/bg.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/bg.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/bi.json b/i18n/bi.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/bi.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/bn.json b/i18n/bn.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/bn.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/br.json b/i18n/br.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/br.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/ca.json b/i18n/ca.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/ca.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/cs.json b/i18n/cs.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/cs.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/cv.json b/i18n/cv.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/cv.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/da.json b/i18n/da.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/da.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/de.json b/i18n/de.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/de.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/de_CH.json b/i18n/de_CH.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/de_CH.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/el.json b/i18n/el.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/el.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/en.json b/i18n/en.json
index a51467ab6d..dedbea1bfe 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -1,4 +1,2410 @@
{
+ "about": "About",
+ "account": "Account",
+ "account_settings": "Account Settings",
+ "acknowledge": "Acknowledge",
+ "action": "Action",
+ "action_common_update": "Update",
+ "action_description": "A set of action to perform on the filtered assets",
+ "actions": "Actions",
+ "active": "Active",
+ "active_count": "Active: {count}",
+ "activity": "Activity",
+ "activity_changed": "Activity is {enabled, select, true {enabled} other {disabled}}",
+ "add": "Add",
+ "add_a_description": "Add a description",
+ "add_a_location": "Add a location",
+ "add_a_name": "Add a name",
+ "add_a_title": "Add a title",
+ "add_action": "Add action",
+ "add_action_description": "Click to add an action to perform",
+ "add_assets": "Add assets",
+ "add_birthday": "Add a birthday",
+ "add_endpoint": "Add endpoint",
+ "add_exclusion_pattern": "Add exclusion pattern",
+ "add_filter": "Add filter",
+ "add_filter_description": "Click to add a filter condition",
+ "add_location": "Add location",
+ "add_more_users": "Add more users",
+ "add_partner": "Add partner",
+ "add_path": "Add path",
+ "add_photos": "Add photos",
+ "add_tag": "Add tag",
+ "add_to": "Add toโฆ",
+ "add_to_album": "Add to album",
+ "add_to_album_bottom_sheet_added": "Added to {album}",
+ "add_to_album_bottom_sheet_already_exists": "Already in {album}",
+ "add_to_album_bottom_sheet_some_local_assets": "Some local assets could not be added to album",
+ "add_to_album_toggle": "Toggle selection for {album}",
+ "add_to_albums": "Add to albums",
+ "add_to_albums_count": "Add to albums ({count})",
+ "add_to_bottom_bar": "Add to",
+ "add_to_shared_album": "Add to shared album",
+ "add_upload_to_stack": "Add upload to stack",
+ "add_url": "Add URL",
+ "add_workflow_step": "Add workflow step",
+ "added_to_archive": "Added to archive",
+ "added_to_favorites": "Added to favorites",
+ "added_to_favorites_count": "Added {count, number} to favorites",
+ "admin": {
+ "add_exclusion_pattern_description": "Add exclusion patterns. Globbing using *, **, and ? is supported. To ignore all files in any directory named \"Raw\", use \"**/Raw/**\". To ignore all files ending in \".tif\", use \"**/*.tif\". To ignore an absolute path, use \"/path/to/ignore/**\".",
+ "admin_user": "Admin User",
+ "asset_offline_description": "This external library asset is no longer found on disk and has been moved to trash. If the file was moved within the library, check your timeline for the new corresponding asset. To restore this asset, please ensure that the file path below can be accessed by Immich and scan the library.",
+ "authentication_settings": "Authentication Settings",
+ "authentication_settings_description": "Manage password, OAuth, and other authentication settings",
+ "authentication_settings_disable_all": "Are you sure you want to disable all login methods? Login will be completely disabled.",
+ "authentication_settings_reenable": "To re-enable, use a Server Command.",
+ "background_task_job": "Background Tasks",
+ "backup_database": "Create Database Dump",
+ "backup_database_enable_description": "Enable database dumps",
+ "backup_keep_last_amount": "Amount of previous dumps to keep",
+ "backup_onboarding_1_description": "offsite copy in the cloud or at another physical location.",
+ "backup_onboarding_2_description": "local copies on different devices. This includes the main files and a backup of those files locally.",
+ "backup_onboarding_3_description": "total copies of your data, including the original files. This includes 1 offsite copy and 2 local copies.",
+ "backup_onboarding_description": "A 3-2-1 backup strategy is recommended to protect your data. You should keep copies of your uploaded photos/videos as well as the Immich database for a comprehensive backup solution.",
+ "backup_onboarding_footer": "For more information about backing up Immich, please refer to the documentation.",
+ "backup_onboarding_parts_title": "A 3-2-1 backup includes:",
+ "backup_onboarding_title": "Backups",
+ "backup_settings": "Database Dump Settings",
+ "backup_settings_description": "Manage database dump settings.",
+ "cleared_jobs": "Cleared jobs for: {job}",
+ "config_set_by_file": "Config is currently set by a config file",
+ "confirm_delete_library": "Are you sure you want to delete {library} library?",
+ "confirm_delete_library_assets": "Are you sure you want to delete this library? This will delete {count, plural, one {# contained asset} other {all # contained assets}} from Immich and cannot be undone. Files will remain on disk.",
+ "confirm_email_below": "To confirm, type \"{email}\" below",
+ "confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.",
+ "confirm_user_password_reset": "Are you sure you want to reset {user}'s password?",
+ "confirm_user_pin_code_reset": "Are you sure you want to reset {user}'s PIN code?",
+ "copy_config_to_clipboard_description": "Copy the current system config as a JSON object to the clipboard",
+ "create_job": "Create job",
+ "cron_expression": "Cron expression",
+ "cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. Crontab Guru",
+ "cron_expression_presets": "Cron expression presets",
+ "disable_login": "Disable login",
+ "duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search",
+ "exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
+ "export_config_as_json_description": "Download the current system config as a JSON file",
+ "external_libraries_page_description": "Admin external library page",
+ "face_detection": "Face detection",
+ "face_detection_description": "Detect the faces in assets using machine learning. For videos, only the thumbnail is considered. \"Refresh\" (re-)processes all assets. \"Reset\" additionally clears all current face data. \"Missing\" queues assets that haven't been processed yet. Detected faces will be queued for Facial Recognition after Face Detection is complete, grouping them into existing or new people.",
+ "facial_recognition_job_description": "Group detected faces into people. This step runs after Face Detection is complete. \"Reset\" (re-)clusters all faces. \"Missing\" queues faces that don't have a person assigned.",
+ "failed_job_command": "Command {command} failed for job: {job}",
+ "force_delete_user_warning": "WARNING: This will immediately remove the user and all assets. This cannot be undone and the files cannot be recovered.",
+ "image_format": "Format",
+ "image_format_description": "WebP produces smaller files than JPEG, but is slower to encode.",
+ "image_fullsize_description": "Full-size image with stripped metadata, used when zoomed in",
+ "image_fullsize_enabled": "Enable full-size image generation",
+ "image_fullsize_enabled_description": "Generate full-size image for non-web-friendly formats. When \"Prefer embedded preview\" is enabled, embedded previews are used directly without conversion. Does not affect web-friendly formats like JPEG.",
+ "image_fullsize_quality_description": "Full-size image quality from 1-100. Higher is better, but produces larger files.",
+ "image_fullsize_title": "Full-size Image Settings",
+ "image_prefer_embedded_preview": "Prefer embedded preview",
+ "image_prefer_embedded_preview_setting_description": "Use embedded previews in RAW photos as the input to image processing and when available. This can produce more accurate colors for some images, but the quality of the preview is camera-dependent and the image may have more compression artifacts.",
+ "image_prefer_wide_gamut": "Prefer wide gamut",
+ "image_prefer_wide_gamut_setting_description": "Use Display P3 for thumbnails. This better preserves the vibrance of images with wide colorspaces, but images may appear differently on old devices with an old browser version. sRGB images are kept as sRGB to avoid color shifts.",
+ "image_preview_description": "Medium-size image with stripped metadata, used when viewing a single asset and for machine learning",
+ "image_preview_quality_description": "Preview quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness. Setting a low value may affect machine learning quality.",
+ "image_preview_title": "Preview Settings",
+ "image_progressive": "Progressive",
+ "image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on WebP images.",
+ "image_quality": "Quality",
+ "image_resolution": "Resolution",
+ "image_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes and can reduce app responsiveness.",
+ "image_settings": "Image Settings",
+ "image_settings_description": "Manage the quality and resolution of generated images",
+ "image_thumbnail_description": "Small thumbnail with stripped metadata, used when viewing groups of photos like the main timeline",
+ "image_thumbnail_quality_description": "Thumbnail quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness.",
+ "image_thumbnail_title": "Thumbnail Settings",
+ "import_config_from_json_description": "Import system config by uploading a JSON config file",
+ "job_concurrency": "{job} concurrency",
+ "job_created": "Job created",
+ "job_not_concurrency_safe": "This job is not concurrency-safe.",
+ "job_settings": "Job Settings",
+ "job_settings_description": "Manage job concurrency",
+ "jobs_delayed": "{jobCount, plural, other {# delayed}}",
+ "jobs_failed": "{jobCount, plural, other {# failed}}",
+ "jobs_over_time": "Jobs over time",
+ "library_created": "Created library: {library}",
+ "library_deleted": "Library deleted",
+ "library_details": "Library details",
+ "library_folder_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.",
+ "library_remove_exclusion_pattern_prompt": "Are you sure you want to remove this exclusion pattern?",
+ "library_remove_folder_prompt": "Are you sure you want to remove this import folder?",
+ "library_scanning": "Periodic Scanning",
+ "library_scanning_description": "Configure periodic library scanning",
+ "library_scanning_enable_description": "Enable periodic library scanning",
+ "library_settings": "External Library",
+ "library_settings_description": "Manage external library settings",
+ "library_tasks_description": "Scan external libraries for new and/or changed assets",
+ "library_updated": "Updated library",
+ "library_watching_enable_description": "Watch external libraries for file changes",
+ "library_watching_settings": "Library watching [EXPERIMENTAL]",
+ "library_watching_settings_description": "Automatically watch for changed files",
+ "logging_enable_description": "Enable logging",
+ "logging_level_description": "When enabled, what log level to use.",
+ "logging_settings": "Logging",
+ "machine_learning_availability_checks": "Availability checks",
+ "machine_learning_availability_checks_description": "Automatically detect and prefer available machine learning servers",
+ "machine_learning_availability_checks_enabled": "Enable availability checks",
+ "machine_learning_availability_checks_interval": "Check interval",
+ "machine_learning_availability_checks_interval_description": "Interval in milliseconds between availability checks",
+ "machine_learning_availability_checks_timeout": "Request timeout",
+ "machine_learning_availability_checks_timeout_description": "Timeout in milliseconds for availability checks",
+ "machine_learning_clip_model": "CLIP model",
+ "machine_learning_clip_model_description": "The name of a CLIP model listed here. Note that you must re-run the 'Smart Search' job for all images upon changing a model.",
+ "machine_learning_duplicate_detection": "Duplicate Detection",
+ "machine_learning_duplicate_detection_enabled": "Enable duplicate detection",
+ "machine_learning_duplicate_detection_enabled_description": "If disabled, exactly identical assets will still be de-duplicated.",
+ "machine_learning_duplicate_detection_setting_description": "Use CLIP embeddings to find likely duplicates",
+ "machine_learning_enabled": "Enable machine learning",
+ "machine_learning_enabled_description": "If disabled, all ML features will be disabled regardless of the below settings.",
+ "machine_learning_facial_recognition": "Facial Recognition",
+ "machine_learning_facial_recognition_description": "Detect, recognize and group faces in images",
+ "machine_learning_facial_recognition_model": "Facial recognition model",
+ "machine_learning_facial_recognition_model_description": "Models are listed in descending order of size. Larger models are slower and use more memory, but produce better results. Note that you must re-run the Face Detection job for all images upon changing a model.",
+ "machine_learning_facial_recognition_setting": "Enable facial recognition",
+ "machine_learning_facial_recognition_setting_description": "If disabled, images will not be encoded for facial recognition and will not populate the People section in the Explore page.",
+ "machine_learning_max_detection_distance": "Maximum detection distance",
+ "machine_learning_max_detection_distance_description": "Maximum distance between two images to consider them duplicates, ranging from 0.001-0.1. Higher values will detect more duplicates, but may result in false positives.",
+ "machine_learning_max_recognition_distance": "Maximum recognition distance",
+ "machine_learning_max_recognition_distance_description": "Maximum distance between two faces to be considered the same person, ranging from 0-2. Lowering this can prevent labeling two people as the same person, while raising it can prevent labeling the same person as two different people. Note that it is easier to merge two people than to split one person in two, so err on the side of a lower threshold when possible.",
+ "machine_learning_min_detection_score": "Minimum detection score",
+ "machine_learning_min_detection_score_description": "Minimum confidence score for a face to be detected from 0-1. Lower values will detect more faces but may result in false positives.",
+ "machine_learning_min_recognized_faces": "Minimum recognized faces",
+ "machine_learning_min_recognized_faces_description": "The minimum number of recognized faces for a person to be created. Increasing this makes Facial Recognition more precise at the cost of increasing the chance that a face is not assigned to a person.",
+ "machine_learning_ocr": "OCR",
+ "machine_learning_ocr_description": "Use machine learning to recognize text in images",
+ "machine_learning_ocr_enabled": "Enable OCR",
+ "machine_learning_ocr_enabled_description": "If disabled, images will not undergo text recognition.",
+ "machine_learning_ocr_max_resolution": "Maximum resolution",
+ "machine_learning_ocr_max_resolution_description": "Previews above this resolution will be resized while preserving aspect ratio. Higher values are more accurate, but take longer to process and use more memory.",
+ "machine_learning_ocr_min_detection_score": "Minimum detection score",
+ "machine_learning_ocr_min_detection_score_description": "Minimum confidence score for text to be detected from 0-1. Lower values will detect more text but may result in false positives.",
+ "machine_learning_ocr_min_recognition_score": "Minimum recognition score",
+ "machine_learning_ocr_min_score_recognition_description": "Minimum confidence score for detected text to be recognized from 0-1. Lower values will recognize more text but may result in false positives.",
+ "machine_learning_ocr_model": "OCR model",
+ "machine_learning_ocr_model_description": "Server models are more accurate than mobile models, but take longer to process and use more memory.",
+ "machine_learning_settings": "Machine Learning Settings",
+ "machine_learning_settings_description": "Manage machine learning features and settings",
+ "machine_learning_smart_search": "Smart Search",
+ "machine_learning_smart_search_description": "Search for images semantically using CLIP embeddings",
+ "machine_learning_smart_search_enabled": "Enable smart search",
+ "machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
+ "machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
+ "maintenance_delete_backup": "Delete Backup",
+ "maintenance_delete_backup_description": "This file will be irrevocably deleted.",
+ "maintenance_delete_error": "Failed to delete backup.",
+ "maintenance_restore_backup": "Restore Backup",
+ "maintenance_restore_backup_description": "Immich will be wiped and restored from the chosen backup. A backup will be created before continuing.",
+ "maintenance_restore_backup_different_version": "This backup was created with a different version of Immich!",
+ "maintenance_restore_backup_unknown_version": "Couldn't determine backup version.",
+ "maintenance_restore_database_backup": "Restore database backup",
+ "maintenance_restore_database_backup_description": "Rollback to an earlier database state using a backup file",
+ "maintenance_settings": "Maintenance",
+ "maintenance_settings_description": "Put Immich into maintenance mode.",
+ "maintenance_start": "Switch to maintenance mode",
+ "maintenance_start_error": "Failed to start maintenance mode.",
+ "maintenance_upload_backup": "Upload database backup file",
+ "maintenance_upload_backup_error": "Could not upload backup, is it an .sql/.sql.gz file?",
+ "manage_concurrency": "Manage Concurrency",
+ "manage_concurrency_description": "Navigate to the jobs page to manage job concurrency",
+ "manage_log_settings": "Manage log settings",
+ "map_dark_style": "Dark style",
+ "map_enable_description": "Enable map features",
+ "map_gps_settings": "Map & GPS Settings",
+ "map_gps_settings_description": "Manage Map & GPS (Reverse Geocoding) Settings",
+ "map_implications": "The map feature relies on an external tile service (tiles.immich.cloud)",
+ "map_light_style": "Light style",
+ "map_manage_reverse_geocoding_settings": "Manage Reverse Geocoding settings",
+ "map_reverse_geocoding": "Reverse Geocoding",
+ "map_reverse_geocoding_enable_description": "Enable reverse geocoding",
+ "map_reverse_geocoding_settings": "Reverse Geocoding Settings",
+ "map_settings": "Map",
+ "map_settings_description": "Manage map settings",
+ "map_style_description": "URL to a style.json map theme",
+ "memory_cleanup_job": "Memory cleanup",
+ "memory_generate_job": "Memory generation",
+ "metadata_extraction_job": "Extract metadata",
+ "metadata_extraction_job_description": "Extract metadata information from each asset, such as GPS, faces and resolution",
+ "metadata_faces_import_setting": "Enable face import",
+ "metadata_faces_import_setting_description": "Import faces from image EXIF data and sidecar files",
+ "metadata_settings": "Metadata Settings",
+ "metadata_settings_description": "Manage metadata settings",
+ "migration_job": "Migration",
+ "migration_job_description": "Migrate thumbnails for assets and faces to the latest folder structure",
+ "nightly_tasks_cluster_faces_setting_description": "Run facial recognition on newly detected faces",
+ "nightly_tasks_cluster_new_faces_setting": "Cluster new faces",
+ "nightly_tasks_database_cleanup_setting": "Database cleanup tasks",
+ "nightly_tasks_database_cleanup_setting_description": "Clean up old, expired data from the database",
+ "nightly_tasks_generate_memories_setting": "Generate memories",
+ "nightly_tasks_generate_memories_setting_description": "Create new memories from assets",
+ "nightly_tasks_missing_thumbnails_setting": "Generate missing thumbnails",
+ "nightly_tasks_missing_thumbnails_setting_description": "Queue assets without thumbnails for thumbnail generation",
+ "nightly_tasks_settings": "Nightly Tasks Settings",
+ "nightly_tasks_settings_description": "Manage nightly tasks",
+ "nightly_tasks_start_time_setting": "Start time",
+ "nightly_tasks_start_time_setting_description": "The time at which the server starts running the nightly tasks",
+ "nightly_tasks_sync_quota_usage_setting": "Sync quota usage",
+ "nightly_tasks_sync_quota_usage_setting_description": "Update user storage quota, based on current usage",
+ "no_paths_added": "No paths added",
+ "no_pattern_added": "No pattern added",
+ "note_apply_storage_label_previous_assets": "Note: To apply the Storage Label to previously uploaded assets, run the",
+ "note_cannot_be_changed_later": "NOTE: This cannot be changed later!",
+ "notification_email_from_address": "From address",
+ "notification_email_from_address_description": "Sender email address, for example: \"Immich Photo Server \". Make sure to use an address you're allowed to send emails from.",
+ "notification_email_host_description": "Host of the email server (e.g. smtp.immich.app)",
+ "notification_email_ignore_certificate_errors": "Ignore certificate errors",
+ "notification_email_ignore_certificate_errors_description": "Ignore TLS certificate validation errors (not recommended)",
+ "notification_email_password_description": "Password to use when authenticating with the email server",
+ "notification_email_port_description": "Port of the email server (e.g 25, 465, or 587)",
+ "notification_email_secure": "SMTPS",
+ "notification_email_secure_description": "Use SMTPS (SMTP over TLS)",
+ "notification_email_sent_test_email_button": "Send test email and save",
+ "notification_email_setting_description": "Settings for sending email notifications",
+ "notification_email_test_email": "Send test email",
+ "notification_email_test_email_failed": "Failed to send test email, check your values",
+ "notification_email_test_email_sent": "A test email has been sent to {email}. Please check your inbox.",
+ "notification_email_username_description": "Username to use when authenticating with the email server",
+ "notification_enable_email_notifications": "Enable email notifications",
+ "notification_settings": "Notification Settings",
+ "notification_settings_description": "Manage notification settings, including email",
+ "oauth_auto_launch": "Auto launch",
+ "oauth_auto_launch_description": "Start the OAuth login flow automatically upon navigating to the login page",
+ "oauth_auto_register": "Auto register",
+ "oauth_auto_register_description": "Automatically register new users after signing in with OAuth",
+ "oauth_button_text": "Button text",
+ "oauth_client_secret_description": "Required for confidential client, or if PKCE (Proof Key for Code Exchange) is not supported for public client.",
+ "oauth_enable_description": "Login with OAuth",
+ "oauth_mobile_redirect_uri": "Mobile redirect URI",
+ "oauth_mobile_redirect_uri_override": "Mobile redirect URI override",
+ "oauth_mobile_redirect_uri_override_description": "Enable when OAuth provider does not allow a mobile URI, like ''{callback}''",
+ "oauth_role_claim": "Role Claim",
+ "oauth_role_claim_description": "Automatically grant admin access based on the presence of this claim. The claim may have either 'user' or 'admin'.",
+ "oauth_settings": "OAuth",
+ "oauth_settings_description": "Manage OAuth login settings",
+ "oauth_settings_more_details": "For more details about this feature, refer to the docs.",
+ "oauth_storage_label_claim": "Storage label claim",
+ "oauth_storage_label_claim_description": "Automatically set the user's storage label to the value of this claim.",
+ "oauth_storage_quota_claim": "Storage quota claim",
+ "oauth_storage_quota_claim_description": "Automatically set the user's storage quota to the value of this claim.",
+ "oauth_storage_quota_default": "Default storage quota (GiB)",
+ "oauth_storage_quota_default_description": "Quota in GiB to be used when no claim is provided.",
+ "oauth_timeout": "Request Timeout",
+ "oauth_timeout_description": "Timeout for requests in milliseconds",
+ "ocr_job_description": "Use machine learning to recognize text in images",
+ "password_enable_description": "Login with email and password",
+ "password_settings": "Password Login",
+ "password_settings_description": "Manage password login settings",
+ "paths_validated_successfully": "All paths validated successfully",
+ "person_cleanup_job": "Person cleanup",
+ "queue_details": "Queue Details",
+ "queues": "Job Queues",
+ "queues_page_description": "Admin job queues page",
+ "quota_size_gib": "Quota Size (GiB)",
+ "refreshing_all_libraries": "Refreshing all libraries",
+ "registration": "Admin Registration",
+ "registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.",
+ "remove_failed_jobs": "Remove failed jobs",
+ "require_password_change_on_login": "Require user to change password on first login",
+ "reset_settings_to_default": "Reset settings to default",
+ "reset_settings_to_recent_saved": "Reset settings to the recent saved settings",
+ "scanning_library": "Scanning library",
+ "search_jobs": "Search jobsโฆ",
+ "send_welcome_email": "Send welcome email",
+ "server_external_domain_settings": "External domain",
+ "server_external_domain_settings_description": "Domain for public shared links, including http(s)://",
+ "server_public_users": "Public Users",
+ "server_public_users_description": "All users (name and email) are listed when adding a user to shared albums. When disabled, the user list will only be available to admin users.",
+ "server_settings": "Server Settings",
+ "server_settings_description": "Manage server settings",
+ "server_stats_page_description": "Admin server statistics page",
+ "server_welcome_message": "Welcome message",
+ "server_welcome_message_description": "A message that is displayed on the login page.",
+ "settings_page_description": "Admin settings page",
+ "sidecar_job": "Sidecar metadata",
+ "sidecar_job_description": "Discover or synchronize sidecar metadata from the filesystem",
+ "slideshow_duration_description": "Number of seconds to display each image",
+ "smart_search_job_description": "Run machine learning on assets to support smart search",
+ "storage_template_date_time_description": "Asset's creation timestamp is used for the datetime information",
+ "storage_template_date_time_sample": "Sample time {date}",
+ "storage_template_enable_description": "Enable storage template engine",
+ "storage_template_hash_verification_enabled": "Hash verification enabled",
+ "storage_template_hash_verification_enabled_description": "Enables hash verification, don't disable this unless you're certain of the implications",
+ "storage_template_migration": "Storage template migration",
+ "storage_template_migration_description": "Apply the current {template} to previously uploaded assets",
+ "storage_template_migration_info": "The storage template will convert all extensions to lowercase. Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the {job}.",
+ "storage_template_migration_job": "Storage Template Migration Job",
+ "storage_template_more_details": "For more details about this feature, refer to the Storage Template and its implications",
+ "storage_template_onboarding_description_v2": "When enabled, this feature will auto-organize files based on a user-defined template. For more information, please see the documentation.",
+ "storage_template_path_length": "Approximate path length limit: {length, number}/{limit, number}",
+ "storage_template_settings": "Storage Template",
+ "storage_template_settings_description": "Manage the folder structure and file name of the upload asset",
+ "storage_template_user_label": "{label} is the user's Storage Label",
+ "system_settings": "System Settings",
+ "tag_cleanup_job": "Tag cleanup",
+ "template_email_available_tags": "You can use the following variables in your template: {tags}",
+ "template_email_if_empty": "If the template is empty, the default email will be used.",
+ "template_email_invite_album": "Invite Album Template",
+ "template_email_preview": "Preview",
+ "template_email_settings": "Email Templates",
+ "template_email_update_album": "Update Album Template",
+ "template_email_welcome": "Welcome email template",
+ "template_settings": "Notification Templates",
+ "template_settings_description": "Manage custom templates for notifications",
+ "theme_custom_css_settings": "Custom CSS",
+ "theme_custom_css_settings_description": "Cascading Style Sheets allow the design of Immich to be customized.",
+ "theme_settings": "Theme Settings",
+ "theme_settings_description": "Manage customization of the Immich web interface",
+ "thumbnail_generation_job": "Generate Thumbnails",
+ "thumbnail_generation_job_description": "Generate large, small and blurred thumbnails for each asset, as well as thumbnails for each person",
+ "transcoding_acceleration_api": "Acceleration API",
+ "transcoding_acceleration_api_description": "The API that will interact with your device to accelerate transcoding. This setting is 'best effort': it will fallback to software transcoding on failure. VP9 may or may not work depending on your hardware.",
+ "transcoding_acceleration_nvenc": "NVENC (requires NVIDIA GPU)",
+ "transcoding_acceleration_qsv": "Quick Sync (requires 7th gen Intel CPU or later)",
+ "transcoding_acceleration_rkmpp": "RKMPP (only on Rockchip SOCs)",
+ "transcoding_acceleration_vaapi": "VAAPI",
+ "transcoding_accepted_audio_codecs": "Accepted audio codecs",
+ "transcoding_accepted_audio_codecs_description": "Select which audio codecs do not need to be transcoded. Only used for certain transcode policies.",
+ "transcoding_accepted_containers": "Accepted containers",
+ "transcoding_accepted_containers_description": "Select which container formats do not need to be remuxed to MP4. Only used for certain transcode policies.",
+ "transcoding_accepted_video_codecs": "Accepted video codecs",
+ "transcoding_accepted_video_codecs_description": "Select which video codecs do not need to be transcoded. Only used for certain transcode policies.",
+ "transcoding_advanced_options_description": "Options most users should not need to change",
+ "transcoding_audio_codec": "Audio codec",
+ "transcoding_audio_codec_description": "Opus is the highest quality option, but has lower compatibility with old devices or software.",
+ "transcoding_bitrate_description": "Videos higher than max bitrate or not in an accepted format",
+ "transcoding_codecs_learn_more": "To learn more about the terminology used here, refer to FFmpeg documentation for H.264 codec, HEVC codec and VP9 codec.",
+ "transcoding_constant_quality_mode": "Constant quality mode",
+ "transcoding_constant_quality_mode_description": "ICQ is better than CQP, but some hardware acceleration devices do not support this mode. Setting this option will prefer the specified mode when using quality-based encoding. Ignored by NVENC as it does not support ICQ.",
+ "transcoding_constant_rate_factor": "Constant rate factor (-crf)",
+ "transcoding_constant_rate_factor_description": "Video quality level. Typical values are 23 for H.264, 28 for HEVC, 31 for VP9, and 35 for AV1. Lower is better, but produces larger files.",
+ "transcoding_disabled_description": "Don't transcode any videos, may break playback on some clients",
+ "transcoding_encoding_options": "Encoding Options",
+ "transcoding_encoding_options_description": "Set codecs, resolution, quality and other options for the encoded videos",
+ "transcoding_hardware_acceleration": "Hardware Acceleration",
+ "transcoding_hardware_acceleration_description": "Experimental: faster transcoding but may reduce quality at same bitrate",
+ "transcoding_hardware_decoding": "Hardware decoding",
+ "transcoding_hardware_decoding_setting_description": "Enables end-to-end acceleration instead of only accelerating encoding. May not work on all videos.",
+ "transcoding_max_b_frames": "Maximum B-frames",
+ "transcoding_max_b_frames_description": "Higher values improve compression efficiency, but slow down encoding. May not be compatible with hardware acceleration on older devices. 0 disables B-frames, while -1 sets this value automatically.",
+ "transcoding_max_bitrate": "Maximum bitrate",
+ "transcoding_max_bitrate_description": "Setting a max bitrate can make file sizes more predictable at a minor cost to quality. At 720p, typical values are 2600 kbit/s for VP9 or HEVC, or 4500 kbit/s for H.264. Disabled if set to 0. When no unit is specified, k (for kbit/s) is assumed; therefore 5000, 5000k, and 5M (for Mbit/s) are equivalent.",
+ "transcoding_max_keyframe_interval": "Maximum keyframe interval",
+ "transcoding_max_keyframe_interval_description": "Sets the maximum frame distance between keyframes. Lower values worsen compression efficiency, but improve seek times and may improve quality in scenes with fast movement. 0 sets this value automatically.",
+ "transcoding_optimal_description": "Videos higher than target resolution or not in an accepted format",
+ "transcoding_policy": "Transcode Policy",
+ "transcoding_policy_description": "Set when a video will be transcoded",
+ "transcoding_preferred_hardware_device": "Preferred hardware device",
+ "transcoding_preferred_hardware_device_description": "Applies only to VAAPI and QSV. Sets the dri node used for hardware transcoding.",
+ "transcoding_preset_preset": "Preset (-preset)",
+ "transcoding_preset_preset_description": "Compression speed. Slower presets produce smaller files, and increase quality when targeting a certain bitrate. VP9 ignores speeds above 'faster'.",
+ "transcoding_reference_frames": "Reference frames",
+ "transcoding_reference_frames_description": "The number of frames to reference when compressing a given frame. Higher values improve compression efficiency, but slow down encoding. 0 sets this value automatically.",
+ "transcoding_required_description": "Only videos not in an accepted format",
+ "transcoding_settings": "Video Transcoding Settings",
+ "transcoding_settings_description": "Manage which videos to transcode and how to process them",
+ "transcoding_target_resolution": "Target resolution",
+ "transcoding_target_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.",
+ "transcoding_temporal_aq": "Temporal AQ",
+ "transcoding_temporal_aq_description": "Applies only to NVENC. Temporal Adaptive Quantization increases quality of high-detail, low-motion scenes. May not be compatible with older devices.",
+ "transcoding_threads": "Threads",
+ "transcoding_threads_description": "Higher values lead to faster encoding, but leave less room for the server to process other tasks while active. This value should not be more than the number of CPU cores. Maximizes utilization if set to 0.",
+ "transcoding_tone_mapping": "Tone-mapping",
+ "transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.",
+ "transcoding_transcode_policy": "Transcode policy",
+ "transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled).",
+ "transcoding_two_pass_encoding": "Two-pass encoding",
+ "transcoding_two_pass_encoding_setting_description": "Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled.",
+ "transcoding_video_codec": "Video codec",
+ "transcoding_video_codec_description": "VP9 has high efficiency and web compatibility, but takes longer to transcode. HEVC performs similarly, but has lower web compatibility. H.264 is widely compatible and quick to transcode, but produces much larger files. AV1 is the most efficient codec but lacks support on older devices.",
+ "trash_enabled_description": "Enable Trash features",
+ "trash_number_of_days": "Number of days",
+ "trash_number_of_days_description": "Number of days to keep the assets in trash before permanently removing them",
+ "trash_settings": "Trash Settings",
+ "trash_settings_description": "Manage trash settings",
+ "unlink_all_oauth_accounts": "Unlink all OAuth accounts",
+ "unlink_all_oauth_accounts_description": "Remember to unlink all OAuth accounts before migrating to a new provider.",
+ "unlink_all_oauth_accounts_prompt": "Are you sure you want to unlink all OAuth accounts? This will reset the OAuth ID for each user and cannot be undone.",
+ "user_cleanup_job": "User cleanup",
+ "user_delete_delay": "{user}'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.",
+ "user_delete_delay_settings": "Delete delay",
+ "user_delete_delay_settings_description": "Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.",
+ "user_delete_immediately": "{user}'s account and assets will be queued for permanent deletion immediately.",
+ "user_delete_immediately_checkbox": "Queue user and assets for immediate deletion",
+ "user_details": "User Details",
+ "user_management": "User Management",
+ "user_password_has_been_reset": "The user's password has been reset:",
+ "user_password_reset_description": "Please provide the temporary password to the user and inform them they will need to change the password at their next login.",
+ "user_restore_description": "{user}'s account will be restored.",
+ "user_restore_scheduled_removal": "Restore user - scheduled removal on {date, date, long}",
+ "user_settings": "User Settings",
+ "user_settings_description": "Manage user settings",
+ "user_successfully_removed": "User {email} has been successfully removed.",
+ "users_page_description": "Admin users page",
+ "version_check_enabled_description": "Enable version check",
+ "version_check_implications": "The version check feature relies on periodic communication with github.com",
+ "version_check_settings": "Version Check",
+ "version_check_settings_description": "Enable/disable the new version notification",
+ "video_conversion_job": "Transcode videos",
+ "video_conversion_job_description": "Transcode videos for wider compatibility with browsers and devices"
+ },
+ "admin_email": "Admin Email",
+ "admin_password": "Admin Password",
+ "administration": "Administration",
+ "advanced": "Advanced",
+ "advanced_settings_clear_image_cache": "Clear Image Cache",
+ "advanced_settings_clear_image_cache_error": "Failed to clear image cache",
+ "advanced_settings_clear_image_cache_success": "Successfully cleared {size}",
+ "advanced_settings_enable_alternate_media_filter_subtitle": "Use this option to filter media during sync based on alternate criteria. Only try this if you have issues with the app detecting all albums.",
+ "advanced_settings_enable_alternate_media_filter_title": "[EXPERIMENTAL] Use alternate device album sync filter",
+ "advanced_settings_log_level_title": "Log level: {level}",
+ "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from local assets. Activate this setting to load remote images instead.",
+ "advanced_settings_prefer_remote_title": "Prefer remote images",
+ "advanced_settings_proxy_headers_subtitle": "Define proxy headers Immich should send with each network request",
+ "advanced_settings_proxy_headers_title": "Custom proxy headers [EXPERIMENTAL]",
+ "advanced_settings_readonly_mode_subtitle": "Enables the read-only mode where the photos can be only viewed, things like selecting multiple images, sharing, casting, delete are all disabled. Enable/Disable read-only via user avatar from the main screen",
+ "advanced_settings_readonly_mode_title": "Read-only mode",
+ "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.",
+ "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates [EXPERIMENTAL]",
+ "advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web",
+ "advanced_settings_sync_remote_deletions_title": "Sync remote deletions [EXPERIMENTAL]",
+ "advanced_settings_tile_subtitle": "Advanced user's settings",
+ "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
+ "advanced_settings_troubleshooting_title": "Troubleshooting",
+ "age_months": "Age {months, plural, one {# month} other {# months}}",
+ "age_year_months": "Age 1 year, {months, plural, one {# month} other {# months}}",
+ "age_years": "{years, plural, other {Age #}}",
+ "album": "Album",
+ "album_added": "Album added",
+ "album_added_notification_setting_description": "Receive an email notification when you are added to a shared album",
+ "album_cover_updated": "Album cover updated",
+ "album_delete_confirmation": "Are you sure you want to delete the album {album}?",
+ "album_delete_confirmation_description": "If this album is shared, other users will not be able to access it anymore.",
+ "album_deleted": "Album deleted",
+ "album_info_card_backup_album_excluded": "EXCLUDED",
+ "album_info_card_backup_album_included": "INCLUDED",
+ "album_info_updated": "Album info updated",
+ "album_leave": "Leave album?",
+ "album_leave_confirmation": "Are you sure you want to leave {album}?",
+ "album_name": "Album Name",
+ "album_options": "Album options",
+ "album_remove_user": "Remove user?",
+ "album_remove_user_confirmation": "Are you sure you want to remove {user}?",
+ "album_search_not_found": "No albums found matching your search",
+ "album_selected": "Album selected",
+ "album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.",
+ "album_summary": "Album summary",
+ "album_updated": "Album updated",
+ "album_updated_setting_description": "Receive an email notification when a shared album has new assets",
+ "album_upload_assets": "Upload assets from your computer and add to album",
+ "album_user_left": "Left {album}",
+ "album_user_removed": "Removed {user}",
+ "album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?",
+ "album_viewer_appbar_share_err_delete": "Failed to delete album",
+ "album_viewer_appbar_share_err_leave": "Failed to leave album",
+ "album_viewer_appbar_share_err_remove": "There are problems in removing assets from album",
+ "album_viewer_appbar_share_err_title": "Failed to change album title",
+ "album_viewer_appbar_share_leave": "Leave album",
+ "album_viewer_appbar_share_to": "Share To",
+ "album_viewer_page_share_add_users": "Add users",
+ "album_with_link_access": "Let anyone with the link see photos and people in this album.",
+ "albums": "Albums",
+ "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albums}}",
+ "albums_default_sort_order": "Default album sort order",
+ "albums_default_sort_order_description": "Initial asset sort order when creating new albums.",
+ "albums_feature_description": "Collections of assets that can be shared with other users.",
+ "albums_on_device_count": "Albums on device ({count})",
+ "albums_selected": "{count, plural, one {# album selected} other {# albums selected}}",
+ "all": "All",
+ "all_albums": "All albums",
+ "all_people": "All people",
+ "all_photos": "All photos",
+ "all_videos": "All videos",
+ "allow_dark_mode": "Allow dark mode",
+ "allow_edits": "Allow edits",
+ "allow_public_user_to_download": "Allow public user to download",
+ "allow_public_user_to_upload": "Allow public user to upload",
+ "allowed": "Allowed",
+ "alt_text_qr_code": "QR code image",
+ "always_keep": "Always keep",
+ "always_keep_photos_hint": "Free Up Space will keep all photos on this device.",
+ "always_keep_videos_hint": "Free Up Space will keep all videos on this device.",
+ "anti_clockwise": "Anti-clockwise",
+ "api_key": "API Key",
+ "api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.",
+ "api_key_empty": "Your API Key name shouldn't be empty",
+ "api_keys": "API Keys",
+ "app_architecture_variant": "Variant (Architecture)",
+ "app_bar_signout_dialog_content": "Are you sure you want to sign out?",
+ "app_bar_signout_dialog_ok": "Yes",
+ "app_bar_signout_dialog_title": "Sign out",
+ "app_download_links": "App Download Links",
+ "app_settings": "App Settings",
+ "app_stores": "App Stores",
+ "app_update_available": "App update is available",
+ "appears_in": "Appears in",
+ "apply_count": "Apply ({count, number})",
+ "archive": "Archive",
+ "archive_action_prompt": "{count} added to Archive",
+ "archive_or_unarchive_photo": "Archive or unarchive photo",
+ "archive_page_no_archived_assets": "No archived assets found",
+ "archive_page_title": "Archive ({count})",
+ "archive_size": "Archive size",
+ "archive_size_description": "Configure the archive size for downloads (in GiB)",
+ "archived": "Archived",
+ "archived_count": "{count, plural, other {Archived #}}",
+ "are_these_the_same_person": "Are these the same person?",
+ "are_you_sure_to_do_this": "Are you sure you want to do this?",
+ "array_field_not_fully_supported": "Array fields require manual JSON editing",
+ "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping",
+ "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping",
+ "asset_added_to_album": "Added to album",
+ "asset_adding_to_album": "Adding to albumโฆ",
+ "asset_created": "Asset created",
+ "asset_description_updated": "Asset description has been updated",
+ "asset_filename_is_offline": "Asset {filename} is offline",
+ "asset_has_unassigned_faces": "Asset has unassigned faces",
+ "asset_hashing": "Hashingโฆ",
+ "asset_list_group_by_sub_title": "Group by",
+ "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout",
+ "asset_list_layout_settings_group_automatically": "Automatic",
+ "asset_list_layout_settings_group_by": "Group assets by",
+ "asset_list_layout_settings_group_by_month_day": "Month + day",
+ "asset_list_layout_sub_title": "Layout",
+ "asset_list_settings_subtitle": "Photo grid layout settings",
+ "asset_list_settings_title": "Photo Grid",
+ "asset_not_found_on_device_android": "Asset not found on device",
+ "asset_not_found_on_device_ios": "Asset not found on device. If you are using iCloud, the asset may be inaccessible due to bad file stored on iCloud",
+ "asset_not_found_on_icloud": "Asset not found on iCloud. the asset may be inaccessible due to bad file stored on iCloud",
+ "asset_offline": "Asset Offline",
+ "asset_offline_description": "This external asset is no longer found on disk. Please contact your Immich administrator for help.",
+ "asset_restored_successfully": "Asset restored successfully",
+ "asset_skipped": "Skipped",
+ "asset_skipped_in_trash": "In trash",
+ "asset_trashed": "Asset trashed",
+ "asset_troubleshoot": "Asset Troubleshoot",
+ "asset_uploaded": "Uploaded",
+ "asset_uploading": "Uploadingโฆ",
+ "asset_viewer_settings_subtitle": "Manage your gallery viewer settings",
+ "asset_viewer_settings_title": "Asset Viewer",
+ "assets": "Assets",
+ "assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
+ "assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
+ "assets_added_to_albums_count": "Added {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal, plural, one {# album} other {# albums}}",
+ "assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
+ "assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
+ "assets_count": "{count, plural, one {# asset} other {# assets}}",
+ "assets_deleted_permanently": "{count} asset(s) deleted permanently",
+ "assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server",
+ "assets_downloaded_failed": "{count, plural, one {Downloaded # file - {error} file failed} other {Downloaded # files - {error} files failed}}",
+ "assets_downloaded_successfully": "{count, plural, one {Downloaded # file successfully} other {Downloaded # files successfully}}",
+ "assets_moved_to_trash_count": "Moved {count, plural, one {# asset} other {# assets}} to trash",
+ "assets_permanently_deleted_count": "Permanently deleted {count, plural, one {# asset} other {# assets}}",
+ "assets_removed_count": "Removed {count, plural, one {# asset} other {# assets}}",
+ "assets_removed_permanently_from_device": "{count} asset(s) removed permanently from your device",
+ "assets_restore_confirmation": "Are you sure you want to restore all your trashed assets? You cannot undo this action! Note that any offline assets cannot be restored this way.",
+ "assets_restored_count": "Restored {count, plural, one {# asset} other {# assets}}",
+ "assets_restored_successfully": "{count} asset(s) restored successfully",
+ "assets_trashed": "{count} asset(s) trashed",
+ "assets_trashed_count": "Trashed {count, plural, one {# asset} other {# assets}}",
+ "assets_trashed_from_server": "{count} asset(s) trashed from the Immich server",
+ "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} already part of the album",
+ "assets_were_part_of_albums_count": "{count, plural, one {Asset was} other {Assets were}} already part of the albums",
+ "authorized_devices": "Authorized Devices",
+ "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere",
+ "automatic_endpoint_switching_title": "Automatic URL switching",
+ "autoplay_slideshow": "Autoplay slideshow",
+ "back": "Back",
+ "back_close_deselect": "Back, close, or deselect",
+ "background_backup_running_error": "Background backup is currently running, cannot start manual backup",
+ "background_location_permission": "Background location permission",
+ "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name",
+ "background_options": "Background Options",
+ "backup": "Backup",
+ "backup_album_selection_page_albums_device": "Albums on device ({count})",
+ "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
+ "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.",
+ "backup_album_selection_page_select_albums": "Select albums",
+ "backup_album_selection_page_selection_info": "Selection Info",
+ "backup_album_selection_page_total_assets": "Total unique assets",
+ "backup_albums_sync": "Backup Albums Synchronization",
+ "backup_all": "All",
+ "backup_background_service_backup_failed_message": "Failed to backup assets. Retryingโฆ",
+ "backup_background_service_complete_notification": "Asset backup complete",
+ "backup_background_service_connection_failed_message": "Failed to connect to the server. Retryingโฆ",
+ "backup_background_service_current_upload_notification": "Uploading {filename}",
+ "backup_background_service_default_notification": "Checking for new assetsโฆ",
+ "backup_background_service_error_title": "Backup error",
+ "backup_background_service_in_progress_notification": "Backing up your assetsโฆ",
+ "backup_background_service_upload_failure_notification": "Failed to upload {filename}",
+ "backup_controller_page_albums": "Backup Albums",
+ "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.",
+ "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled",
+ "backup_controller_page_background_app_refresh_enable_button_text": "Go to settings",
+ "backup_controller_page_background_battery_info_link": "Show me how",
+ "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.",
+ "backup_controller_page_background_battery_info_ok": "OK",
+ "backup_controller_page_background_battery_info_title": "Battery optimizations",
+ "backup_controller_page_background_charging": "Only while charging",
+ "backup_controller_page_background_configure_error": "Failed to configure the background service",
+ "backup_controller_page_background_delay": "Delay new assets backup: {duration}",
+ "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app",
+ "backup_controller_page_background_is_off": "Automatic background backup is off",
+ "backup_controller_page_background_is_on": "Automatic background backup is on",
+ "backup_controller_page_background_turn_off": "Turn off background service",
+ "backup_controller_page_background_turn_on": "Turn on background service",
+ "backup_controller_page_background_wifi": "Only on Wi-Fi",
+ "backup_controller_page_backup": "Backup",
+ "backup_controller_page_backup_selected": "Selected: ",
+ "backup_controller_page_backup_sub": "Backed up photos and videos",
+ "backup_controller_page_created": "Created on: {date}",
+ "backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.",
+ "backup_controller_page_excluded": "Excluded: ",
+ "backup_controller_page_failed": "Failed ({count})",
+ "backup_controller_page_filename": "File name: {filename} [{size}]",
+ "backup_controller_page_id": "ID: {id}",
+ "backup_controller_page_info": "Backup Information",
+ "backup_controller_page_none_selected": "None selected",
+ "backup_controller_page_remainder": "Remainder",
+ "backup_controller_page_remainder_sub": "Remaining photos and videos to back up from selection",
+ "backup_controller_page_server_storage": "Server Storage",
+ "backup_controller_page_start_backup": "Start Backup",
+ "backup_controller_page_status_off": "Automatic foreground backup is off",
+ "backup_controller_page_status_on": "Automatic foreground backup is on",
+ "backup_controller_page_storage_format": "{used} of {total} used",
+ "backup_controller_page_to_backup": "Albums to be backed up",
+ "backup_controller_page_total_sub": "All unique photos and videos from selected albums",
+ "backup_controller_page_turn_off": "Turn off foreground backup",
+ "backup_controller_page_turn_on": "Turn on foreground backup",
+ "backup_controller_page_uploading_file_info": "Uploading file info",
+ "backup_err_only_album": "Cannot remove the only album",
+ "backup_error_sync_failed": "Sync failed. Cannot process backup.",
+ "backup_info_card_assets": "assets",
+ "backup_manual_cancelled": "Cancelled",
+ "backup_manual_in_progress": "Upload already in progress. Try after sometime",
+ "backup_manual_success": "Success",
+ "backup_manual_title": "Upload status",
+ "backup_options": "Backup Options",
+ "backup_options_page_title": "Backup options",
+ "backup_setting_subtitle": "Manage background and foreground upload settings",
+ "backup_settings_subtitle": "Manage upload settings",
+ "backup_upload_details_page_more_details": "Tap for more details",
+ "backward": "Backward",
+ "biometric_auth_enabled": "Biometric authentication enabled",
+ "biometric_locked_out": "You are locked out of biometric authentication",
+ "biometric_no_options": "No biometric options available",
+ "biometric_not_available": "Biometric authentication is not available on this device",
+ "birthdate_saved": "Date of birth saved successfully",
+ "birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.",
+ "blurred_background": "Blurred background",
+ "bugs_and_feature_requests": "Bugs & Feature Requests",
+ "build": "Build",
+ "build_image": "Build Image",
+ "bulk_delete_duplicates_confirmation": "Are you sure you want to bulk delete {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and permanently delete all other duplicates. You cannot undo this action!",
+ "bulk_keep_duplicates_confirmation": "Are you sure you want to keep {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will resolve all duplicate groups without deleting anything.",
+ "bulk_trash_duplicates_confirmation": "Are you sure you want to bulk trash {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and trash all other duplicates.",
+ "buy": "Purchase Immich",
+ "cache_settings_clear_cache_button": "Clear cache",
+ "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.",
+ "cache_settings_duplicated_assets_clear_button": "CLEAR",
+ "cache_settings_duplicated_assets_subtitle": "Photos and videos that are ignore listed by the app",
+ "cache_settings_duplicated_assets_title": "Duplicated Assets ({count})",
+ "cache_settings_statistics_album": "Library thumbnails",
+ "cache_settings_statistics_full": "Full images",
+ "cache_settings_statistics_shared": "Shared album thumbnails",
+ "cache_settings_statistics_thumbnail": "Thumbnails",
+ "cache_settings_statistics_title": "Cache usage",
+ "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application",
+ "cache_settings_tile_subtitle": "Control the local storage behaviour",
+ "cache_settings_tile_title": "Local Storage",
+ "cache_settings_title": "Caching Settings",
+ "camera": "Camera",
+ "camera_brand": "Camera brand",
+ "camera_model": "Camera model",
+ "cancel": "Cancel",
+ "cancel_search": "Cancel search",
+ "canceled": "Canceled",
+ "canceling": "Canceling",
+ "cannot_merge_people": "Cannot merge people",
+ "cannot_undo_this_action": "You cannot undo this action!",
+ "cannot_update_the_description": "Cannot update the description",
+ "cast": "Cast",
+ "cast_description": "Configure available cast destinations",
+ "change_date": "Change date",
+ "change_description": "Change description",
+ "change_display_order": "Change display order",
+ "change_expiration_time": "Change expiration time",
+ "change_location": "Change location",
+ "change_name": "Change name",
+ "change_name_successfully": "Changed name successfully",
+ "change_password": "Change Password",
+ "change_password_description": "This is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.",
+ "change_password_form_confirm_password": "Confirm Password",
+ "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.",
+ "change_password_form_log_out": "Log out all other devices",
+ "change_password_form_log_out_description": "It is recommended to log out of all other devices",
+ "change_password_form_new_password": "New Password",
+ "change_password_form_password_mismatch": "Passwords do not match",
+ "change_password_form_reenter_new_password": "Re-enter New Password",
+ "change_pin_code": "Change PIN code",
+ "change_trigger": "Change trigger",
+ "change_trigger_prompt": "Are you sure you want to change the trigger? This will remove all existing actions and filters.",
+ "change_your_password": "Change your password",
+ "changed_visibility_successfully": "Changed visibility successfully",
+ "charging": "Charging",
+ "charging_requirement_mobile_backup": "Background backup requires the device to be charging",
+ "check_corrupt_asset_backup": "Check for corrupt asset backups",
+ "check_corrupt_asset_backup_button": "Perform check",
+ "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
+ "check_logs": "Check Logs",
+ "checksum": "Checksum",
+ "choose_matching_people_to_merge": "Choose matching people to merge",
+ "city": "City",
+ "cleanup_confirm_description": "Immich found {count} assets (created before {date}) safely backed up to the server. Remove the local copies from this device?",
+ "cleanup_confirm_prompt_title": "Remove from this device?",
+ "cleanup_deleted_assets": "Moved {count} assets to device trash",
+ "cleanup_deleting": "Moving to trash...",
+ "cleanup_found_assets": "Found {count} backed up assets",
+ "cleanup_found_assets_with_size": "Found {count} backed up assets ({size})",
+ "cleanup_icloud_shared_albums_excluded": "iCloud Shared Albums are excluded from the scan",
+ "cleanup_no_assets_found": "No assets found matching the criteria above. Free Up Space can only remove assets that have been backed up to the server",
+ "cleanup_preview_title": "Assets to remove ({count})",
+ "cleanup_step3_description": "Scan for backed up assets matching your date and keep settings.",
+ "cleanup_step4_summary": "{count} assets (created before {date}) to remove from your local device. Photos will remain accessible from the Immich app.",
+ "cleanup_trash_hint": "To fully reclaim storage space, open the system gallery app and empty the trash",
+ "clear": "Clear",
+ "clear_all": "Clear all",
+ "clear_all_recent_searches": "Clear all recent searches",
+ "clear_file_cache": "Clear File Cache",
+ "clear_message": "Clear message",
+ "clear_value": "Clear value",
+ "client_cert_dialog_msg_confirm": "OK",
+ "client_cert_enter_password": "Enter Password",
+ "client_cert_import": "Import",
+ "client_cert_import_success_msg": "Client certificate is imported",
+ "client_cert_invalid_msg": "Invalid certificate file or wrong password",
+ "client_cert_password_message": "Enter the password for this certificate",
+ "client_cert_password_title": "Certificate Password",
+ "client_cert_remove_msg": "Client certificate is removed",
+ "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate import/removal is available only before login",
+ "client_cert_title": "SSL client certificate [EXPERIMENTAL]",
+ "clockwise": "ะกlockwise",
+ "close": "Close",
+ "collapse": "Collapse",
+ "collapse_all": "Collapse all",
+ "color": "Color",
+ "color_theme": "Color theme",
+ "command": "Command",
+ "comment_deleted": "Comment deleted",
+ "comment_options": "Comment options",
+ "comments_and_likes": "Comments & likes",
+ "comments_are_disabled": "Comments are disabled",
+ "common_create_new_album": "Create new album",
+ "completed": "Completed",
+ "confirm": "Confirm",
+ "confirm_admin_password": "Confirm Admin Password",
+ "confirm_delete_face": "Are you sure you want to delete {name} face from the asset?",
+ "confirm_delete_shared_link": "Are you sure you want to delete this shared link?",
+ "confirm_keep_this_delete_others": "All other assets in the stack will be deleted except for this asset. Are you sure you want to continue?",
+ "confirm_new_pin_code": "Confirm new PIN code",
+ "confirm_password": "Confirm password",
+ "confirm_tag_face": "Do you want to tag this face as {name}?",
+ "confirm_tag_face_unnamed": "Do you want to tag this face?",
+ "connected_device": "Connected device",
+ "connected_to": "Connected to",
+ "contain": "Contain",
+ "context": "Context",
+ "continue": "Continue",
+ "control_bottom_app_bar_create_new_album": "Create new album",
+ "control_bottom_app_bar_delete_from_immich": "Delete from Immich",
+ "control_bottom_app_bar_delete_from_local": "Delete from device",
+ "control_bottom_app_bar_edit_location": "Edit Location",
+ "control_bottom_app_bar_edit_time": "Edit Date & Time",
+ "control_bottom_app_bar_share_link": "Share Link",
+ "control_bottom_app_bar_share_to": "Share To",
+ "control_bottom_app_bar_trash_from_immich": "Move to Trash",
+ "copied_image_to_clipboard": "Copied image to clipboard.",
+ "copied_to_clipboard": "Copied to clipboard!",
+ "copy_error": "Copy error",
+ "copy_file_path": "Copy file path",
+ "copy_image": "Copy Image",
+ "copy_link": "Copy link",
+ "copy_link_to_clipboard": "Copy link to clipboard",
+ "copy_password": "Copy password",
+ "copy_to_clipboard": "Copy to Clipboard",
+ "country": "Country",
+ "cover": "Cover",
+ "covers": "Covers",
+ "create": "Create",
+ "create_album": "Create album",
+ "create_album_page_untitled": "Untitled",
+ "create_api_key": "Create API key",
+ "create_first_workflow": "Create first workflow",
+ "create_library": "Create Library",
+ "create_link": "Create link",
+ "create_link_to_share": "Create link to share",
+ "create_link_to_share_description": "Let anyone with the link see the selected photo(s)",
+ "create_new": "CREATE NEW",
+ "create_new_person": "Create new person",
+ "create_new_person_hint": "Assign selected assets to a new person",
+ "create_new_user": "Create new user",
+ "create_shared_album_page_share_add_assets": "ADD ASSETS",
+ "create_shared_album_page_share_select_photos": "Select Photos",
+ "create_shared_link": "Create shared link",
+ "create_tag": "Create tag",
+ "create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.",
+ "create_user": "Create user",
+ "create_workflow": "Create workflow",
+ "created": "Created",
+ "created_at": "Created",
+ "creating_linked_albums": "Creating linked albums...",
+ "crop": "Crop",
+ "crop_aspect_ratio_fixed": "Fixed",
+ "crop_aspect_ratio_free": "Free",
+ "crop_aspect_ratio_original": "Original",
+ "curated_object_page_title": "Things",
+ "current_device": "Current device",
+ "current_pin_code": "Current PIN code",
+ "current_server_address": "Current server address",
+ "custom_date": "Custom date",
+ "custom_locale": "Custom Locale",
+ "custom_locale_description": "Format dates and numbers based on the language and the region",
+ "custom_url": "Custom URL",
+ "cutoff_date_description": "Keep photos from the lastโฆ",
+ "cutoff_day": "{count, plural, one {day} other {days}}",
+ "cutoff_year": "{count, plural, one {year} other {years}}",
+ "daily_title_text_date": "E, MMM dd",
+ "daily_title_text_date_year": "E, MMM dd, yyyy",
+ "dark": "Dark",
+ "dark_theme": "Toggle dark theme",
+ "date": "Date",
+ "date_after": "Date after",
+ "date_and_time": "Date and Time",
+ "date_before": "Date before",
+ "date_format": "E, LLL d, y โข h:mm a",
+ "date_of_birth_saved": "Date of birth saved successfully",
+ "date_range": "Date range",
+ "day": "Day",
+ "days": "Days",
+ "deduplicate_all": "Deduplicate All",
+ "deduplication_criteria_1": "Image size in bytes",
+ "deduplication_criteria_2": "Count of EXIF data",
+ "deduplication_info": "Deduplication Info",
+ "deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:",
+ "default_locale": "Default Locale",
+ "default_locale_description": "Format dates and numbers based on your browser locale",
+ "delete": "Delete",
+ "delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
+ "delete_action_prompt": "{count} deleted",
+ "delete_album": "Delete album",
+ "delete_api_key_prompt": "Are you sure you want to delete this API key?",
+ "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device",
+ "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server",
+ "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device",
+ "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server",
+ "delete_dialog_ok_force": "Delete Anyway",
+ "delete_dialog_title": "Delete Permanently",
+ "delete_duplicates_confirmation": "Are you sure you want to permanently delete these duplicates?",
+ "delete_face": "Delete face",
+ "delete_key": "Delete key",
+ "delete_library": "Delete Library",
+ "delete_link": "Delete link",
+ "delete_local_action_prompt": "{count} deleted locally",
+ "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only",
+ "delete_local_dialog_ok_force": "Delete Anyway",
+ "delete_others": "Delete others",
+ "delete_permanently": "Delete permanently",
+ "delete_permanently_action_prompt": "{count} deleted permanently",
+ "delete_shared_link": "Delete shared link",
+ "delete_shared_link_dialog_title": "Delete Shared Link",
+ "delete_tag": "Delete tag",
+ "delete_tag_confirmation_prompt": "Are you sure you want to delete {tagName} tag?",
+ "delete_user": "Delete user",
+ "deleted_shared_link": "Deleted shared link",
+ "deletes_missing_assets": "Deletes assets missing from disk",
+ "description": "Description",
+ "description_input_hint_text": "Add description...",
+ "description_input_submit_error": "Error updating description, check the log for more details",
+ "deselect_all": "Deselect All",
+ "details": "Details",
+ "direction": "Direction",
+ "disable": "Disable",
+ "disabled": "Disabled",
+ "disallow_edits": "Disallow edits",
+ "discord": "Discord",
+ "discover": "Discover",
+ "discovered_devices": "Discovered devices",
+ "dismiss_all_errors": "Dismiss all errors",
+ "dismiss_error": "Dismiss error",
+ "display_options": "Display options",
+ "display_order": "Display order",
+ "display_original_photos": "Display original photos",
+ "display_original_photos_setting_description": "Prefer to display the original photo when viewing an asset rather than thumbnails when the original asset is web-compatible. This may result in slower photo display speeds.",
+ "do_not_show_again": "Do not show this message again",
+ "documentation": "Documentation",
+ "done": "Done",
+ "download": "Download",
+ "download_action_prompt": "Downloading {count} assets",
+ "download_canceled": "Download canceled",
+ "download_complete": "Download complete",
+ "download_enqueue": "Download enqueued",
+ "download_error": "Download Error",
+ "download_failed": "Download failed",
+ "download_finished": "Download finished",
+ "download_include_embedded_motion_videos": "Embedded videos",
+ "download_include_embedded_motion_videos_description": "Include videos embedded in motion photos as a separate file",
+ "download_notfound": "Download not found",
+ "download_original": "Download original",
+ "download_paused": "Download paused",
+ "download_settings": "Download",
+ "download_settings_description": "Manage settings related to asset download",
+ "download_started": "Download started",
+ "download_sucess": "Download success",
+ "download_sucess_android": "The media has been downloaded to DCIM/Immich",
+ "download_waiting_to_retry": "Waiting to retry",
+ "downloading": "Downloading",
+ "downloading_asset_filename": "Downloading asset {filename}",
+ "downloading_from_icloud": "Downloading from iCloud",
+ "downloading_media": "Downloading media",
+ "drop_files_to_upload": "Drop files anywhere to upload",
+ "duplicates": "Duplicates",
+ "duplicates_description": "Resolve each group by indicating which, if any, are duplicates",
+ "duration": "Duration",
+ "edit": "Edit",
+ "edit_album": "Edit album",
+ "edit_avatar": "Edit avatar",
+ "edit_birthday": "Edit birthday",
+ "edit_date": "Edit date",
+ "edit_date_and_time": "Edit date and time",
+ "edit_date_and_time_action_prompt": "{count} date and time edited",
+ "edit_date_and_time_by_offset": "Change date by offset",
+ "edit_date_and_time_by_offset_interval": "New date range: {from} - {to}",
+ "edit_description": "Edit description",
+ "edit_description_prompt": "Please select a new description:",
+ "edit_exclusion_pattern": "Edit exclusion pattern",
+ "edit_faces": "Edit faces",
+ "edit_key": "Edit key",
+ "edit_link": "Edit link",
+ "edit_location": "Edit location",
+ "edit_location_action_prompt": "{count} location edited",
+ "edit_location_dialog_title": "Location",
+ "edit_name": "Edit name",
+ "edit_people": "Edit people",
+ "edit_tag": "Edit tag",
+ "edit_title": "Edit Title",
+ "edit_user": "Edit user",
+ "edit_workflow": "Edit workflow",
+ "editor": "Editor",
+ "editor_close_without_save_prompt": "The changes will not be saved",
+ "editor_close_without_save_title": "Close editor?",
+ "editor_confirm_reset_all_changes": "Are you sure you want to reset all changes?",
+ "editor_discard_edits_confirm": "Discard edits",
+ "editor_discard_edits_prompt": "You have unsaved edits. Are you sure you want to discard them?",
+ "editor_discard_edits_title": "Discard edits?",
+ "editor_edits_applied_error": "Failed to apply edits",
+ "editor_edits_applied_success": "Edits applied successfully",
+ "editor_flip_horizontal": "Flip horizontal",
+ "editor_flip_vertical": "Flip vertical",
+ "editor_orientation": "Orientation",
+ "editor_reset_all_changes": "Reset changes",
+ "editor_rotate_left": "Rotate 90ยฐ counterclockwise",
+ "editor_rotate_right": "Rotate 90ยฐ clockwise",
+ "email": "Email",
+ "email_notifications": "Email notifications",
+ "empty_folder": "This folder is empty",
+ "empty_trash": "Empty trash",
+ "empty_trash_confirmation": "Are you sure you want to empty the trash? This will remove all the assets in trash permanently from Immich.\nYou cannot undo this action!",
+ "enable": "Enable",
+ "enable_backup": "Enable Backup",
+ "enable_biometric_auth_description": "Enter your PIN code to enable biometric authentication",
+ "enabled": "Enabled",
+ "end_date": "End date",
+ "enqueued": "Enqueued",
+ "enter_wifi_name": "Enter Wi-Fi name",
+ "enter_your_pin_code": "Enter your PIN code",
+ "enter_your_pin_code_subtitle": "Enter your PIN code to access the locked folder",
+ "error": "Error",
+ "error_change_sort_album": "Failed to change album sort order",
+ "error_delete_face": "Error deleting face from asset",
+ "error_getting_places": "Error getting places",
+ "error_loading_albums": "Error loading albums",
+ "error_loading_image": "Error loading image",
+ "error_loading_partners": "Error loading partners: {error}",
+ "error_retrieving_asset_information": "Error retrieving asset information",
+ "error_saving_image": "Error: {error}",
+ "error_tag_face_bounding_box": "Error tagging face - cannot get bounding box coordinates",
+ "error_title": "Error - Something went wrong",
+ "error_while_navigating": "Error while navigating to asset",
+ "errors": {
+ "cannot_navigate_next_asset": "Cannot navigate to the next asset",
+ "cannot_navigate_previous_asset": "Cannot navigate to previous asset",
+ "cant_apply_changes": "Can't apply changes",
+ "cant_change_activity": "Can't {enabled, select, true {disable} other {enable}} activity",
+ "cant_change_asset_favorite": "Can't change favorite for asset",
+ "cant_change_metadata_assets_count": "Can't change metadata of {count, plural, one {# asset} other {# assets}}",
+ "cant_get_faces": "Can't get faces",
+ "cant_get_number_of_comments": "Can't get number of comments",
+ "cant_search_people": "Can't search people",
+ "cant_search_places": "Can't search places",
+ "error_adding_assets_to_album": "Error adding assets to album",
+ "error_adding_users_to_album": "Error adding users to album",
+ "error_deleting_shared_user": "Error deleting shared user",
+ "error_downloading": "Error downloading {filename}",
+ "error_hiding_buy_button": "Error hiding buy button",
+ "error_removing_assets_from_album": "Error removing assets from album, check console for more details",
+ "error_selecting_all_assets": "Error selecting all assets",
+ "exclusion_pattern_already_exists": "This exclusion pattern already exists.",
+ "failed_to_create_album": "Failed to create album",
+ "failed_to_create_shared_link": "Failed to create shared link",
+ "failed_to_edit_shared_link": "Failed to edit shared link",
+ "failed_to_get_people": "Failed to get people",
+ "failed_to_keep_this_delete_others": "Failed to keep this asset and delete the other assets",
+ "failed_to_load_asset": "Failed to load asset",
+ "failed_to_load_assets": "Failed to load assets",
+ "failed_to_load_notifications": "Failed to load notifications",
+ "failed_to_load_people": "Failed to load people",
+ "failed_to_remove_product_key": "Failed to remove product key",
+ "failed_to_reset_pin_code": "Failed to reset PIN code",
+ "failed_to_stack_assets": "Failed to stack assets",
+ "failed_to_unstack_assets": "Failed to un-stack assets",
+ "failed_to_update_notification_status": "Failed to update notification status",
+ "incorrect_email_or_password": "Incorrect email or password",
+ "library_folder_already_exists": "This import path already exists.",
+ "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
+ "profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
+ "quota_higher_than_disk_size": "You set a quota higher than the disk size",
+ "something_went_wrong": "Something went wrong",
+ "unable_to_add_album_users": "Unable to add users to album",
+ "unable_to_add_assets_to_shared_link": "Unable to add assets to shared link",
+ "unable_to_add_comment": "Unable to add comment",
+ "unable_to_add_exclusion_pattern": "Unable to add exclusion pattern",
+ "unable_to_add_partners": "Unable to add partners",
+ "unable_to_add_remove_archive": "Unable to {archived, select, true {remove asset from} other {add asset to}} archive",
+ "unable_to_add_remove_favorites": "Unable to {favorite, select, true {add asset to} other {remove asset from}} favorites",
+ "unable_to_archive_unarchive": "Unable to {archived, select, true {archive} other {unarchive}}",
+ "unable_to_change_album_user_role": "Unable to change the album user's role",
+ "unable_to_change_date": "Unable to change date",
+ "unable_to_change_description": "Unable to change description",
+ "unable_to_change_favorite": "Unable to change favorite for asset",
+ "unable_to_change_location": "Unable to change location",
+ "unable_to_change_password": "Unable to change password",
+ "unable_to_change_visibility": "Unable to change the visibility for {count, plural, one {# person} other {# people}}",
+ "unable_to_complete_oauth_login": "Unable to complete OAuth login",
+ "unable_to_connect": "Unable to connect",
+ "unable_to_copy_to_clipboard": "Cannot copy to clipboard, make sure you are accessing the page through https",
+ "unable_to_create": "Unable to create workflow",
+ "unable_to_create_admin_account": "Unable to create admin account",
+ "unable_to_create_api_key": "Unable to create a new API Key",
+ "unable_to_create_library": "Unable to create library",
+ "unable_to_create_user": "Unable to create user",
+ "unable_to_delete_album": "Unable to delete album",
+ "unable_to_delete_asset": "Unable to delete asset",
+ "unable_to_delete_assets": "Error deleting assets",
+ "unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
+ "unable_to_delete_shared_link": "Unable to delete shared link",
+ "unable_to_delete_user": "Unable to delete user",
+ "unable_to_delete_workflow": "Unable to delete workflow",
+ "unable_to_download_files": "Unable to download files",
+ "unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern",
+ "unable_to_empty_trash": "Unable to empty trash",
+ "unable_to_enter_fullscreen": "Unable to enter fullscreen",
+ "unable_to_exit_fullscreen": "Unable to exit fullscreen",
+ "unable_to_get_comments_number": "Unable to get number of comments",
+ "unable_to_get_shared_link": "Failed to get shared link",
+ "unable_to_hide_person": "Unable to hide person",
+ "unable_to_link_motion_video": "Unable to link motion video",
+ "unable_to_link_oauth_account": "Unable to link OAuth account",
+ "unable_to_log_out_all_devices": "Unable to log out all devices",
+ "unable_to_log_out_device": "Unable to log out device",
+ "unable_to_login_with_oauth": "Unable to login with OAuth",
+ "unable_to_play_video": "Unable to play video",
+ "unable_to_reassign_assets_existing_person": "Unable to reassign assets to {name, select, null {an existing person} other {{name}}}",
+ "unable_to_reassign_assets_new_person": "Unable to reassign assets to a new person",
+ "unable_to_refresh_user": "Unable to refresh user",
+ "unable_to_remove_album_users": "Unable to remove users from album",
+ "unable_to_remove_api_key": "Unable to remove API Key",
+ "unable_to_remove_assets_from_shared_link": "Unable to remove assets from shared link",
+ "unable_to_remove_library": "Unable to remove library",
+ "unable_to_remove_partner": "Unable to remove partner",
+ "unable_to_remove_reaction": "Unable to remove reaction",
+ "unable_to_reset_password": "Unable to reset password",
+ "unable_to_reset_pin_code": "Unable to reset PIN code",
+ "unable_to_resolve_duplicate": "Unable to resolve duplicate",
+ "unable_to_restore_assets": "Unable to restore assets",
+ "unable_to_restore_trash": "Unable to restore trash",
+ "unable_to_restore_user": "Unable to restore user",
+ "unable_to_save_album": "Unable to save album",
+ "unable_to_save_api_key": "Unable to save API Key",
+ "unable_to_save_date_of_birth": "Unable to save date of birth",
+ "unable_to_save_name": "Unable to save name",
+ "unable_to_save_profile": "Unable to save profile",
+ "unable_to_save_settings": "Unable to save settings",
+ "unable_to_scan_libraries": "Unable to scan libraries",
+ "unable_to_scan_library": "Unable to scan library",
+ "unable_to_set_feature_photo": "Unable to set feature photo",
+ "unable_to_set_profile_picture": "Unable to set profile picture",
+ "unable_to_set_rating": "Unable to set rating",
+ "unable_to_submit_job": "Unable to submit job",
+ "unable_to_trash_asset": "Unable to trash asset",
+ "unable_to_unlink_account": "Unable to unlink account",
+ "unable_to_unlink_motion_video": "Unable to unlink motion video",
+ "unable_to_update_album_cover": "Unable to update album cover",
+ "unable_to_update_album_info": "Unable to update album info",
+ "unable_to_update_library": "Unable to update library",
+ "unable_to_update_location": "Unable to update location",
+ "unable_to_update_settings": "Unable to update settings",
+ "unable_to_update_timeline_display_status": "Unable to update timeline display status",
+ "unable_to_update_user": "Unable to update user",
+ "unable_to_update_workflow": "Unable to update workflow",
+ "unable_to_upload_file": "Unable to upload file"
+ },
+ "errors_text": "Errors",
+ "exclusion_pattern": "Exclusion pattern",
+ "exif": "Exif",
+ "exif_bottom_sheet_description": "Add Description...",
+ "exif_bottom_sheet_description_error": "Error updating description",
+ "exif_bottom_sheet_details": "DETAILS",
+ "exif_bottom_sheet_location": "LOCATION",
+ "exif_bottom_sheet_no_description": "No description",
+ "exif_bottom_sheet_people": "PEOPLE",
+ "exif_bottom_sheet_person_add_person": "Add name",
+ "exit_slideshow": "Exit Slideshow",
+ "expand_all": "Expand all",
+ "experimental_settings_new_asset_list_subtitle": "Work in progress",
+ "experimental_settings_new_asset_list_title": "Enable experimental photo grid",
+ "experimental_settings_subtitle": "Use at your own risk!",
+ "experimental_settings_title": "Experimental",
+ "expire_after": "Expire after",
+ "expired": "Expired",
+ "expires_date": "Expires {date}",
+ "explore": "Explore",
+ "explorer": "Explorer",
+ "export": "Export",
+ "export_as_json": "Export as JSON",
+ "export_database": "Export Database",
+ "export_database_description": "Export the SQLite database",
+ "extension": "Extension",
+ "external": "External",
+ "external_libraries": "External Libraries",
+ "external_network": "External network",
+ "external_network_sheet_info": "When not on the preferred Wi-Fi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom",
+ "face_unassigned": "Unassigned",
+ "failed": "Failed",
+ "failed_count": "Failed: {count}",
+ "failed_to_authenticate": "Failed to authenticate",
+ "failed_to_load_assets": "Failed to load assets",
+ "failed_to_load_folder": "Failed to load folder",
+ "favorite": "Favorite",
+ "favorite_action_prompt": "{count} added to Favorites",
+ "favorite_or_unfavorite_photo": "Favorite or unfavorite photo",
+ "favorites": "Favorites",
+ "favorites_page_no_favorites": "No favorite assets found",
+ "feature_photo_updated": "Feature photo updated",
+ "features": "Features",
+ "features_in_development": "Features in Development",
+ "features_setting_description": "Manage the app features",
+ "file_name_or_extension": "File name or extension",
+ "file_name_text": "File name",
+ "file_name_with_value": "File name: {file_name}",
+ "file_size": "File size",
+ "filename": "Filename",
+ "filetype": "Filetype",
+ "filter": "Filter",
+ "filter_description": "Conditions to filter the target assets",
+ "filter_people": "Filter people",
+ "filter_places": "Filter places",
+ "filters": "Filters",
+ "find_them_fast": "Find them fast by name with search",
+ "first": "First",
+ "fix_incorrect_match": "Fix incorrect match",
+ "folder": "Folder",
+ "folder_not_found": "Folder not found",
+ "folders": "Folders",
+ "folders_feature_description": "Browsing the folder view for the photos and videos on the file system",
+ "forgot_pin_code_question": "Forgot your PIN?",
+ "forward": "Forward",
+ "free_up_space": "Free Up Space",
+ "free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe.",
+ "free_up_space_settings_subtitle": "Free up device storage",
+ "full_path": "Full path: {path}",
+ "gcast_enabled": "Google Cast",
+ "gcast_enabled_description": "This feature loads external resources from Google in order to work.",
+ "general": "General",
+ "geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map",
+ "get_help": "Get Help",
+ "get_people_error": "Error getting people",
+ "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
"getting_started": "Getting Started",
- "welcome": "Welcome"
+ "go_back": "Go back",
+ "go_to_folder": "Go to folder",
+ "go_to_search": "Go to search",
+ "gps": "GPS",
+ "gps_missing": "No GPS",
+ "grant_permission": "Grant permission",
+ "group_albums_by": "Group albums by...",
+ "group_country": "Group by country",
+ "group_no": "No grouping",
+ "group_owner": "Group by owner",
+ "group_places_by": "Group places by...",
+ "group_year": "Group by year",
+ "haptic_feedback_switch": "Enable haptic feedback",
+ "haptic_feedback_title": "Haptic Feedback",
+ "has_quota": "Has quota",
+ "hash_asset": "Hash asset",
+ "hashed_assets": "Hashed assets",
+ "hashing": "Hashing",
+ "header_settings_add_header_tip": "Add header",
+ "header_settings_field_validator_msg": "Value cannot be empty",
+ "header_settings_header_name_input": "Header name",
+ "header_settings_header_value_input": "Header value",
+ "headers_settings_tile_title": "Custom proxy headers",
+ "height": "Height",
+ "hi_user": "Hi {name} ({email})",
+ "hide_all_people": "Hide all people",
+ "hide_gallery": "Hide gallery",
+ "hide_named_person": "Hide person {name}",
+ "hide_password": "Hide password",
+ "hide_person": "Hide person",
+ "hide_schema": "Hide schema",
+ "hide_text_recognition": "Hide text recognition",
+ "hide_unnamed_people": "Hide unnamed people",
+ "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
+ "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
+ "home_page_add_to_album_success": "Added {added} assets to album {album}.",
+ "home_page_album_err_partner": "Can not add partner assets to an album yet, skipping",
+ "home_page_archive_err_local": "Can not archive local assets yet, skipping",
+ "home_page_archive_err_partner": "Can not archive partner assets, skipping",
+ "home_page_building_timeline": "Building the timeline",
+ "home_page_delete_err_partner": "Can not delete partner assets, skipping",
+ "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping",
+ "home_page_favorite_err_local": "Can not favorite local assets yet, skipping",
+ "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping",
+ "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album so that the timeline can populate photos and videos in it",
+ "home_page_locked_error_local": "Can not move local assets to locked folder, skipping",
+ "home_page_locked_error_partner": "Can not move partner assets to locked folder, skipping",
+ "home_page_share_err_local": "Can not share local assets via link, skipping",
+ "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping",
+ "host": "Host",
+ "hour": "Hour",
+ "hours": "Hours",
+ "id": "ID",
+ "idle": "Idle",
+ "ignore_icloud_photos": "Ignore iCloud photos",
+ "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server",
+ "image": "Image",
+ "image_alt_text_date": "{isVideo, select, true {Video} other {Image}} taken on {date}",
+ "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} taken with {person1} on {date}",
+ "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Image}} taken with {person1} and {person2} on {date}",
+ "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Image}} taken with {person1}, {person2}, and {person3} on {date}",
+ "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Image}} taken with {person1}, {person2}, and {additionalCount, number} others on {date}",
+ "image_alt_text_date_place": "{isVideo, select, true {Video} other {Image}} taken in {city}, {country} on {date}",
+ "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Image}} taken in {city}, {country} with {person1} on {date}",
+ "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} taken in {city}, {country} with {person1} and {person2} on {date}",
+ "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} taken in {city}, {country} with {person1}, {person2}, and {person3} on {date}",
+ "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} taken in {city}, {country} with {person1}, {person2}, and {additionalCount, number} others on {date}",
+ "image_saved_successfully": "Image saved",
+ "image_viewer_page_state_provider_download_started": "Download Started",
+ "image_viewer_page_state_provider_download_success": "Download Success",
+ "image_viewer_page_state_provider_share_error": "Share Error",
+ "immich_logo": "Immich Logo",
+ "immich_web_interface": "Immich Web Interface",
+ "import_from_json": "Import from JSON",
+ "import_path": "Import path",
+ "in_albums": "In {count, plural, one {# album} other {# albums}}",
+ "in_archive": "In archive",
+ "in_year": "In {year}",
+ "in_year_selector": "In",
+ "include_archived": "Include archived",
+ "include_shared_albums": "Include shared albums",
+ "include_shared_partner_assets": "Include shared partner assets",
+ "individual_share": "Individual share",
+ "individual_shares": "Individual shares",
+ "info": "Info",
+ "interval": {
+ "day_at_onepm": "Every day at 1pm",
+ "hours": "Every {hours, plural, one {hour} other {{hours, number} hours}}",
+ "night_at_midnight": "Every night at midnight",
+ "night_at_twoam": "Every night at 2am"
+ },
+ "invalid_date": "Invalid date",
+ "invalid_date_format": "Invalid date format",
+ "invite_people": "Invite People",
+ "invite_to_album": "Invite to album",
+ "ios_debug_info_fetch_ran_at": "Fetch ran {dateTime}",
+ "ios_debug_info_last_sync_at": "Last sync {dateTime}",
+ "ios_debug_info_no_processes_queued": "No background processes queued",
+ "ios_debug_info_no_sync_yet": "No background sync job has run yet",
+ "ios_debug_info_processes_queued": "{count, plural, one {{count} background process queued} other {{count} background processes queued}}",
+ "ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
+ "items_count": "{count, plural, one {# item} other {# items}}",
+ "jobs": "Jobs",
+ "json_editor": "JSON editor",
+ "json_error": "JSON error",
+ "keep": "Keep",
+ "keep_albums": "Keep albums",
+ "keep_albums_count": "Keeping {count} {count, plural, one {album} other {albums}}",
+ "keep_all": "Keep All",
+ "keep_description": "Choose what stays on your device when freeing up space.",
+ "keep_favorites": "Keep favorites",
+ "keep_on_device": "Keep on device",
+ "keep_on_device_hint": "Select items to keep on this device",
+ "keep_this_delete_others": "Keep this, delete others",
+ "keeping": "Keeping: {items}",
+ "kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}",
+ "keyboard_shortcuts": "Keyboard shortcuts",
+ "language": "Language",
+ "language_no_results_subtitle": "Try adjusting your search term",
+ "language_no_results_title": "No languages found",
+ "language_search_hint": "Search languages...",
+ "language_setting_description": "Select your preferred language",
+ "large_files": "Large Files",
+ "last": "Last",
+ "last_months": "{count, plural, one {Last month} other {Last # months}}",
+ "last_seen": "Last seen",
+ "latest_version": "Latest Version",
+ "latitude": "Latitude",
+ "leave": "Leave",
+ "leave_album": "Leave album",
+ "lens_model": "Lens model",
+ "let_others_respond": "Let others respond",
+ "level": "Level",
+ "library": "Library",
+ "library_add_folder": "Add folder",
+ "library_edit_folder": "Edit folder",
+ "library_options": "Library options",
+ "library_page_device_albums": "Albums on Device",
+ "library_page_new_album": "New album",
+ "library_page_sort_asset_count": "Number of assets",
+ "library_page_sort_created": "Created date",
+ "library_page_sort_last_modified": "Last modified",
+ "library_page_sort_title": "Album title",
+ "licenses": "Licenses",
+ "light": "Light",
+ "like": "Like",
+ "like_deleted": "Like deleted",
+ "link_motion_video": "Link motion video",
+ "link_to_oauth": "Link to OAuth",
+ "linked_oauth_account": "Linked OAuth account",
+ "list": "List",
+ "loading": "Loading",
+ "loading_search_results_failed": "Loading search results failed",
+ "local": "Local",
+ "local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server",
+ "local_assets": "Local Assets",
+ "local_id": "Local ID",
+ "local_media_summary": "Local Media Summary",
+ "local_network": "Local network",
+ "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
+ "location": "Location",
+ "location_permission": "Location permission",
+ "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current Wi-Fi network's name",
+ "location_picker_choose_on_map": "Choose on map",
+ "location_picker_latitude_error": "Enter a valid latitude",
+ "location_picker_latitude_hint": "Enter your latitude here",
+ "location_picker_longitude_error": "Enter a valid longitude",
+ "location_picker_longitude_hint": "Enter your longitude here",
+ "lock": "Lock",
+ "locked_folder": "Locked Folder",
+ "log_detail_title": "Log Detail",
+ "log_out": "Log out",
+ "log_out_all_devices": "Log Out All Devices",
+ "logged_in_as": "Logged in as {user}",
+ "logged_out_all_devices": "Logged out all devices",
+ "logged_out_device": "Logged out device",
+ "login": "Login",
+ "login_disabled": "Login has been disabled",
+ "login_form_api_exception": "API exception. Please check the server URL and try again.",
+ "login_form_back_button_text": "Back",
+ "login_form_email_hint": "youremail@email.com",
+ "login_form_endpoint_hint": "http://your-server-ip:port",
+ "login_form_endpoint_url": "Server Endpoint URL",
+ "login_form_err_http": "Please specify http:// or https://",
+ "login_form_err_invalid_email": "Invalid Email",
+ "login_form_err_invalid_url": "Invalid URL",
+ "login_form_err_leading_whitespace": "Leading whitespace",
+ "login_form_err_trailing_whitespace": "Trailing whitespace",
+ "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL",
+ "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server",
+ "login_form_failed_login": "Error logging you in, check server URL, email and password",
+ "login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.",
+ "login_form_password_hint": "password",
+ "login_form_save_login": "Stay logged in",
+ "login_form_server_empty": "Enter a server URL.",
+ "login_form_server_error": "Could not connect to server.",
+ "login_has_been_disabled": "Login has been disabled.",
+ "login_password_changed_error": "There was an error updating your password",
+ "login_password_changed_success": "Password updated successfully",
+ "logout_all_device_confirmation": "Are you sure you want to log out all devices?",
+ "logout_this_device_confirmation": "Are you sure you want to log out this device?",
+ "logs": "Logs",
+ "longitude": "Longitude",
+ "look": "Look",
+ "loop_videos": "Loop videos",
+ "loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
+ "main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
+ "main_menu": "Main menu",
+ "maintenance_action_restore": "Restoring Database",
+ "maintenance_description": "Immich has been put into maintenance mode.",
+ "maintenance_end": "End maintenance mode",
+ "maintenance_end_error": "Failed to end maintenance mode.",
+ "maintenance_logged_in_as": "Currently logged in as {user}",
+ "maintenance_restore_from_backup": "Restore From Backup",
+ "maintenance_restore_library": "Restore Your Library",
+ "maintenance_restore_library_confirm": "If this looks correct, continue to restoring a backup!",
+ "maintenance_restore_library_description": "Restoring Database",
+ "maintenance_restore_library_folder_has_files": "{folder} has {count} folder(s)",
+ "maintenance_restore_library_folder_no_files": "{folder} is missing files!",
+ "maintenance_restore_library_folder_pass": "readable and writable",
+ "maintenance_restore_library_folder_read_fail": "not readable",
+ "maintenance_restore_library_folder_write_fail": "not writable",
+ "maintenance_restore_library_hint_missing_files": "You may be missing important files",
+ "maintenance_restore_library_hint_regenerate_later": "You can regenerate these later in settings",
+ "maintenance_restore_library_hint_storage_template_missing_files": "Using storage template? You may be missing files",
+ "maintenance_restore_library_loading": "Loading integrity checks and heuristicsโฆ",
+ "maintenance_task_backup": "Creating a backup of the existing databaseโฆ",
+ "maintenance_task_migrations": "Running database migrationsโฆ",
+ "maintenance_task_restore": "Restoring the chosen backupโฆ",
+ "maintenance_task_rollback": "Restore failed, rolling back to restore pointโฆ",
+ "maintenance_title": "Temporarily Unavailable",
+ "make": "Make",
+ "manage_geolocation": "Manage location",
+ "manage_media_access_rationale": "This permission is required for proper handling of moving assets to the trash and restoring them from it.",
+ "manage_media_access_settings": "Open settings",
+ "manage_media_access_subtitle": "Allow the Immich app to manage and move media files.",
+ "manage_media_access_title": "Media Management Access",
+ "manage_shared_links": "Manage shared links",
+ "manage_sharing_with_partners": "Manage sharing with partners",
+ "manage_the_app_settings": "Manage the app settings",
+ "manage_your_account": "Manage your account",
+ "manage_your_api_keys": "Manage your API keys",
+ "manage_your_devices": "Manage your logged-in devices",
+ "manage_your_oauth_connection": "Manage your OAuth connection",
+ "map": "Map",
+ "map_assets_in_bounds": "{count, plural, =0 {No photos in this area} one {# photo} other {# photos}}",
+ "map_cannot_get_user_location": "Cannot get user's location",
+ "map_location_dialog_yes": "Yes",
+ "map_location_picker_page_use_location": "Use this location",
+ "map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?",
+ "map_location_service_disabled_title": "Location Service disabled",
+ "map_marker_for_images": "Map marker for images taken in {city}, {country}",
+ "map_marker_with_image": "Map marker with image",
+ "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
+ "map_no_location_permission_title": "Location Permission denied",
+ "map_settings": "Map settings",
+ "map_settings_dark_mode": "Dark mode",
+ "map_settings_date_range_option_day": "Past 24 hours",
+ "map_settings_date_range_option_days": "Past {days} days",
+ "map_settings_date_range_option_year": "Past year",
+ "map_settings_date_range_option_years": "Past {years} years",
+ "map_settings_dialog_title": "Map Settings",
+ "map_settings_include_show_archived": "Include Archived",
+ "map_settings_include_show_partners": "Include Partners",
+ "map_settings_only_show_favorites": "Show Favorite Only",
+ "map_settings_theme_settings": "Map Theme",
+ "map_zoom_to_see_photos": "Zoom out to see photos",
+ "mark_all_as_read": "Mark all as read",
+ "mark_as_read": "Mark as read",
+ "marked_all_as_read": "Marked all as read",
+ "matches": "Matches",
+ "matching_assets": "Matching Assets",
+ "media_type": "Media type",
+ "memories": "Memories",
+ "memories_all_caught_up": "All caught up",
+ "memories_check_back_tomorrow": "Check back tomorrow for more memories",
+ "memories_setting_description": "Manage what you see in your memories",
+ "memories_start_over": "Start Over",
+ "memories_swipe_to_close": "Swipe up to close",
+ "memory": "Memory",
+ "memory_lane_title": "Memory Lane {title}",
+ "menu": "Menu",
+ "merge": "Merge",
+ "merge_people": "Merge people",
+ "merge_people_limit": "You can only merge up to 5 faces at a time",
+ "merge_people_prompt": "Do you want to merge these people? This action is irreversible.",
+ "merge_people_successfully": "Merge people successfully",
+ "merged_people_count": "Merged {count, plural, one {# person} other {# people}}",
+ "minimize": "Minimize",
+ "minute": "Minute",
+ "minutes": "Minutes",
+ "mirror_horizontal": "Horizontal",
+ "mirror_vertical": "Vertical",
+ "missing": "Missing",
+ "mobile_app": "Mobile App",
+ "mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
+ "model": "Model",
+ "month": "Month",
+ "monthly_title_text_date_format": "MMMM y",
+ "more": "More",
+ "move": "Move",
+ "move_down": "Move down",
+ "move_off_locked_folder": "Move out of locked folder",
+ "move_to": "Move to",
+ "move_to_device_trash": "Move to device trash",
+ "move_to_lock_folder_action_prompt": "{count} added to the locked folder",
+ "move_to_locked_folder": "Move to locked folder",
+ "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
+ "move_up": "Move up",
+ "moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive",
+ "moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library",
+ "moved_to_trash": "Moved to trash",
+ "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping",
+ "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping",
+ "mute_memories": "Mute Memories",
+ "my_albums": "My albums",
+ "name": "Name",
+ "name_or_nickname": "Name or nickname",
+ "name_required": "Name is required",
+ "navigate": "Navigate",
+ "navigate_to_time": "Navigate to Time",
+ "network_requirement_photos_upload": "Use cellular data to backup photos",
+ "network_requirement_videos_upload": "Use cellular data to backup videos",
+ "network_requirements": "Network Requirements",
+ "network_requirements_updated": "Network requirements changed, resetting backup queue",
+ "networking_settings": "Networking",
+ "networking_subtitle": "Manage the server endpoint settings",
+ "never": "Never",
+ "new_album": "New Album",
+ "new_api_key": "New API Key",
+ "new_date_range": "New date range",
+ "new_password": "New password",
+ "new_person": "New person",
+ "new_pin_code": "New PIN code",
+ "new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page",
+ "new_timeline": "New Timeline",
+ "new_update": "New update",
+ "new_user_created": "New user created",
+ "new_version_available": "NEW VERSION AVAILABLE",
+ "newest_first": "Newest first",
+ "next": "Next",
+ "next_memory": "Next memory",
+ "no": "No",
+ "no_actions_added": "No actions added yet",
+ "no_albums_found": "No albums found",
+ "no_albums_message": "Create an album to organize your photos and videos",
+ "no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
+ "no_albums_yet": "It looks like you do not have any albums yet.",
+ "no_archived_assets_message": "Archive photos and videos to hide them from your Photos view",
+ "no_assets_message": "Click to upload your first photo",
+ "no_assets_to_show": "No assets to show",
+ "no_cast_devices_found": "No cast devices found",
+ "no_checksum_local": "No checksum available - cannot fetch local assets",
+ "no_checksum_remote": "No checksum available - cannot fetch remote asset",
+ "no_configuration_needed": "No configuration needed",
+ "no_devices": "No authorized devices",
+ "no_duplicates_found": "No duplicates were found.",
+ "no_exif_info_available": "No exif info available",
+ "no_explore_results_message": "Upload more photos to explore your collection.",
+ "no_favorites_message": "Add favorites to quickly find your best pictures and videos",
+ "no_filters_added": "No filters added yet",
+ "no_libraries_message": "Create an external library to view your photos and videos",
+ "no_local_assets_found": "No local assets found with this checksum",
+ "no_location_set": "No location set",
+ "no_locked_photos_message": "Photos and videos in the locked folder are hidden and won't show up as you browse or search your library.",
+ "no_name": "No Name",
+ "no_notifications": "No notifications",
+ "no_people_found": "No matching people found",
+ "no_places": "No places",
+ "no_remote_assets_found": "No remote assets found with this checksum",
+ "no_results": "No results",
+ "no_results_description": "Try a synonym or more general keyword",
+ "no_shared_albums_message": "Create an album to share photos and videos with people in your network",
+ "no_uploads_in_progress": "No uploads in progress",
+ "none": "None",
+ "not_allowed": "Not allowed",
+ "not_available": "N/A",
+ "not_in_any_album": "Not in any album",
+ "not_selected": "Not selected",
+ "note_apply_storage_label_to_previously_uploaded assets": "Note: To apply the Storage Label to previously uploaded assets, run the",
+ "notes": "Notes",
+ "nothing_here_yet": "Nothing here yet",
+ "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
+ "notification_permission_list_tile_content": "Grant permission to enable notifications.",
+ "notification_permission_list_tile_enable_button": "Enable Notifications",
+ "notification_permission_list_tile_title": "Notification Permission",
+ "notification_toggle_setting_description": "Enable email notifications",
+ "notifications": "Notifications",
+ "notifications_setting_description": "Manage notifications",
+ "oauth": "OAuth",
+ "obtainium_configurator": "Obtainium Configurator",
+ "obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link",
+ "ocr": "OCR",
+ "official_immich_resources": "Official Immich Resources",
+ "offline": "Offline",
+ "offset": "Offset",
+ "ok": "Ok",
+ "oldest_first": "Oldest first",
+ "on_this_device": "On this device",
+ "onboarding": "Onboarding",
+ "onboarding_locale_description": "Select your preferred language. You can change this later in your settings.",
+ "onboarding_privacy_description": "The following (optional) features rely on external services, and can be disabled at any time in settings.",
+ "onboarding_server_welcome_description": "Let's get your instance set up with some common settings.",
+ "onboarding_theme_description": "Choose a color theme for your instance. You can change this later in your settings.",
+ "onboarding_user_welcome_description": "Let's get you started!",
+ "onboarding_welcome_user": "Welcome, {user}",
+ "online": "Online",
+ "only_favorites": "Only favorites",
+ "open": "Open",
+ "open_in_map_view": "Open in map view",
+ "open_in_openstreetmap": "Open in OpenStreetMap",
+ "open_the_search_filters": "Open the search filters",
+ "options": "Options",
+ "or": "or",
+ "organize_into_albums": "Organize into albums",
+ "organize_into_albums_description": "Put existing photos into albums using current sync settings",
+ "organize_your_library": "Organize your library",
+ "original": "original",
+ "other": "Other",
+ "other_devices": "Other devices",
+ "other_entities": "Other entities",
+ "other_variables": "Other variables",
+ "owned": "Owned",
+ "owner": "Owner",
+ "page": "Page",
+ "partner": "Partner",
+ "partner_can_access": "{partner} can access",
+ "partner_can_access_assets": "All your photos and videos except those in Archived and Deleted",
+ "partner_can_access_location": "The location where your photos were taken",
+ "partner_list_user_photos": "{user}'s photos",
+ "partner_list_view_all": "View all",
+ "partner_page_empty_message": "Your photos are not yet shared with any partner.",
+ "partner_page_no_more_users": "No more users to add",
+ "partner_page_partner_add_failed": "Failed to add partner",
+ "partner_page_select_partner": "Select partner",
+ "partner_page_shared_to_title": "Shared to",
+ "partner_page_stop_sharing_content": "{partner} will no longer be able to access your photos.",
+ "partner_sharing": "Partner Sharing",
+ "partners": "Partners",
+ "password": "Password",
+ "password_does_not_match": "Password does not match",
+ "password_required": "Password Required",
+ "password_reset_success": "Password reset success",
+ "past_durations": {
+ "days": "Past {days, plural, one {day} other {# days}}",
+ "hours": "Past {hours, plural, one {hour} other {# hours}}",
+ "years": "Past {years, plural, one {year} other {# years}}"
+ },
+ "path": "Path",
+ "pattern": "Pattern",
+ "pause": "Pause",
+ "pause_memories": "Pause memories",
+ "paused": "Paused",
+ "pending": "Pending",
+ "people": "People",
+ "people_edits_count": "Edited {count, plural, one {# person} other {# people}}",
+ "people_feature_description": "Browsing photos and videos grouped by people",
+ "people_selected": "{count, plural, one {# person selected} other {# people selected}}",
+ "people_sidebar_description": "Display a link to People in the sidebar",
+ "permanent_deletion_warning": "Permanent deletion warning",
+ "permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets",
+ "permanently_delete": "Permanently delete",
+ "permanently_delete_assets_count": "Permanently delete {count, plural, one {asset} other {assets}}",
+ "permanently_delete_assets_prompt": "Are you sure you want to permanently delete {count, plural, one {this asset?} other {these # assets?}} This will also remove {count, plural, one {it from its} other {them from their}} album(s).",
+ "permanently_deleted_asset": "Permanently deleted asset",
+ "permanently_deleted_assets_count": "Permanently deleted {count, plural, one {# asset} other {# assets}}",
+ "permission": "Permission",
+ "permission_empty": "Your permission shouldn't be empty",
+ "permission_onboarding_back": "Back",
+ "permission_onboarding_continue_anyway": "Continue anyway",
+ "permission_onboarding_get_started": "Get started",
+ "permission_onboarding_go_to_settings": "Go to settings",
+ "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.",
+ "permission_onboarding_permission_granted": "Permission granted! You are all set.",
+ "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.",
+ "permission_onboarding_request": "Immich requires permission to view your photos and videos.",
+ "person": "Person",
+ "person_age_months": "{months, plural, one {# month} other {# months}} old",
+ "person_age_year_months": "1 year, {months, plural, one {# month} other {# months}} old",
+ "person_age_years": "{years, plural, other {# years}} old",
+ "person_birthdate": "Born on {date}",
+ "person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
+ "person_recognized": "Person recognized",
+ "person_selected": "Person selected",
+ "photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
+ "photos": "Photos",
+ "photos_and_videos": "Photos & Videos",
+ "photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}",
+ "photos_from_previous_years": "Photos from previous years",
+ "photos_only": "Photos only",
+ "pick_a_location": "Pick a location",
+ "pick_custom_range": "Custom range",
+ "pick_date_range": "Select a date range",
+ "pin_code_changed_successfully": "Successfully changed PIN code",
+ "pin_code_reset_successfully": "Successfully reset PIN code",
+ "pin_code_setup_successfully": "Successfully setup a PIN code",
+ "pin_verification": "PIN code verification",
+ "place": "Place",
+ "places": "Places",
+ "places_count": "{count, plural, one {{count, number} Place} other {{count, number} Places}}",
+ "play": "Play",
+ "play_memories": "Play memories",
+ "play_motion_photo": "Play Motion Photo",
+ "play_or_pause_video": "Play or pause video",
+ "play_original_video": "Play original video",
+ "play_original_video_setting_description": "Prefer playback of original videos rather than transcoded videos. If original asset is not compatible it may not playback correctly.",
+ "play_transcoded_video": "Play transcoded video",
+ "please_auth_to_access": "Please authenticate to access",
+ "port": "Port",
+ "preferences_settings_subtitle": "Manage the app's preferences",
+ "preferences_settings_title": "Preferences",
+ "preparing": "Preparing",
+ "preset": "Preset",
+ "preview": "Preview",
+ "previous": "Previous",
+ "previous_memory": "Previous memory",
+ "previous_or_next_day": "Day forward/back",
+ "previous_or_next_month": "Month forward/back",
+ "previous_or_next_photo": "Photo forward/back",
+ "previous_or_next_year": "Year forward/back",
+ "primary": "Primary",
+ "privacy": "Privacy",
+ "profile": "Profile",
+ "profile_drawer_app_logs": "Logs",
+ "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
+ "profile_drawer_github": "GitHub",
+ "profile_drawer_readonly_mode": "Read-only mode enabled. Long-press the user avatar icon to exit.",
+ "profile_image_of_user": "Profile image of {user}",
+ "profile_picture_set": "Profile picture set.",
+ "public_album": "Public album",
+ "public_share": "Public Share",
+ "purchase_account_info": "Supporter",
+ "purchase_activated_subtitle": "Thank you for supporting Immich and open-source software",
+ "purchase_activated_time": "Activated on {date}",
+ "purchase_activated_title": "Your key has been successfully activated",
+ "purchase_button_activate": "Activate",
+ "purchase_button_buy": "Buy",
+ "purchase_button_buy_immich": "Buy Immich",
+ "purchase_button_never_show_again": "Never show again",
+ "purchase_button_reminder": "Remind me in 30 days",
+ "purchase_button_remove_key": "Remove key",
+ "purchase_button_select": "Select",
+ "purchase_failed_activation": "Failed to activate! Please check your email for the correct product key!",
+ "purchase_individual_description_1": "For an individual",
+ "purchase_individual_description_2": "Supporter status",
+ "purchase_individual_title": "Individual",
+ "purchase_input_suggestion": "Have a product key? Enter the key below",
+ "purchase_license_subtitle": "Buy Immich to support the continued development of the service",
+ "purchase_lifetime_description": "Lifetime purchase",
+ "purchase_option_title": "PURCHASE OPTIONS",
+ "purchase_panel_info_1": "Building Immich takes a lot of time and effort, and we have full-time engineers working on it to make it as good as we possibly can. Our mission is for open-source software and ethical business practices to become a sustainable income source for developers and to create a privacy-respecting ecosystem with real alternatives to exploitative cloud services.",
+ "purchase_panel_info_2": "As we're committed not to add paywalls, this purchase will not grant you any additional features in Immich. We rely on users like you to support Immich's ongoing development.",
+ "purchase_panel_title": "Support the project",
+ "purchase_per_server": "Per server",
+ "purchase_per_user": "Per user",
+ "purchase_remove_product_key": "Remove Product Key",
+ "purchase_remove_product_key_prompt": "Are you sure you want to remove the product key?",
+ "purchase_remove_server_product_key": "Remove Server product key",
+ "purchase_remove_server_product_key_prompt": "Are you sure you want to remove the Server product key?",
+ "purchase_server_description_1": "For the whole server",
+ "purchase_server_description_2": "Supporter status",
+ "purchase_server_title": "Server",
+ "purchase_settings_server_activated": "The server product key is managed by the admin",
+ "query_asset_id": "Query Asset ID",
+ "queue_status": "Queuing {count}/{total}",
+ "rate_asset": "Rate Asset",
+ "rating": "Star rating",
+ "rating_clear": "Clear rating",
+ "rating_count": "{count, plural, one {# star} other {# stars}}",
+ "rating_description": "Display the EXIF rating in the info panel",
+ "rating_set": "Rating set to {rating, plural, one {# star} other {# stars}}",
+ "reaction_options": "Reaction options",
+ "read_changelog": "Read Changelog",
+ "readonly_mode_disabled": "Read-only mode disabled",
+ "readonly_mode_enabled": "Read-only mode enabled",
+ "ready_for_upload": "Ready for upload",
+ "reassign": "Reassign",
+ "reassigned_assets_to_existing_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to {name, select, null {an existing person} other {{name}}}",
+ "reassigned_assets_to_new_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to a new person",
+ "reassing_hint": "Assign selected assets to an existing person",
+ "recent": "Recent",
+ "recent-albums": "Recent albums",
+ "recent_searches": "Recent searches",
+ "recently_added": "Recently added",
+ "recently_added_page_title": "Recently Added",
+ "recently_taken": "Recently taken",
+ "recently_taken_page_title": "Recently Taken",
+ "refresh": "Refresh",
+ "refresh_encoded_videos": "Refresh encoded videos",
+ "refresh_faces": "Refresh faces",
+ "refresh_metadata": "Refresh metadata",
+ "refresh_thumbnails": "Refresh thumbnails",
+ "refreshed": "Refreshed",
+ "refreshes_every_file": "Re-reads all existing and new files",
+ "refreshing_encoded_video": "Refreshing encoded video",
+ "refreshing_faces": "Refreshing faces",
+ "refreshing_metadata": "Refreshing metadata",
+ "regenerating_thumbnails": "Regenerating thumbnails",
+ "remote": "Remote",
+ "remote_assets": "Remote Assets",
+ "remote_media_summary": "Remote Media Summary",
+ "remove": "Remove",
+ "remove_assets_album_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from the album?",
+ "remove_assets_shared_link_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from this shared link?",
+ "remove_assets_title": "Remove assets?",
+ "remove_custom_date_range": "Remove custom date range",
+ "remove_deleted_assets": "Remove Deleted Assets",
+ "remove_from_album": "Remove from album",
+ "remove_from_album_action_prompt": "{count} removed from the album",
+ "remove_from_favorites": "Remove from favorites",
+ "remove_from_lock_folder_action_prompt": "{count} removed from the locked folder",
+ "remove_from_locked_folder": "Remove from locked folder",
+ "remove_from_locked_folder_confirmation": "Are you sure you want to move these photos and videos out of the locked folder? They will be visible in your library.",
+ "remove_from_shared_link": "Remove from shared link",
+ "remove_memory": "Remove memory",
+ "remove_photo_from_memory": "Remove photo from this memory",
+ "remove_tag": "Remove tag",
+ "remove_url": "Remove URL",
+ "remove_user": "Remove user",
+ "removed_api_key": "Removed API Key: {name}",
+ "removed_from_archive": "Removed from archive",
+ "removed_from_favorites": "Removed from favorites",
+ "removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",
+ "removed_memory": "Removed memory",
+ "removed_photo_from_memory": "Removed photo from memory",
+ "removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
+ "rename": "Rename",
+ "repair": "Repair",
+ "repair_no_results_message": "Untracked and missing files will show up here",
+ "replace_with_upload": "Replace with upload",
+ "repository": "Repository",
+ "require_password": "Require password",
+ "require_user_to_change_password_on_first_login": "Require user to change password on first login",
+ "rescan": "Rescan",
+ "reset": "Reset",
+ "reset_password": "Reset password",
+ "reset_people_visibility": "Reset people visibility",
+ "reset_pin_code": "Reset PIN code",
+ "reset_pin_code_description": "If you forgot your PIN code, you can contact the server administrator to reset it",
+ "reset_pin_code_success": "Successfully reset PIN code",
+ "reset_pin_code_with_password": "You can always reset your PIN code with your password",
+ "reset_sqlite": "Reset SQLite Database",
+ "reset_sqlite_confirmation": "Are you sure you want to reset the SQLite database? You will need to log out and log in again to resync the data",
+ "reset_sqlite_success": "Successfully reset the SQLite database",
+ "reset_to_default": "Reset to default",
+ "resolution": "Resolution",
+ "resolve_duplicates": "Resolve duplicates",
+ "resolved_all_duplicates": "Resolved all duplicates",
+ "restore": "Restore",
+ "restore_all": "Restore all",
+ "restore_trash_action_prompt": "{count} restored from trash",
+ "restore_user": "Restore user",
+ "restored_asset": "Restored asset",
+ "resume": "Resume",
+ "resume_paused_jobs": "Resume {count, plural, one {# paused job} other {# paused jobs}}",
+ "retry_upload": "Retry upload",
+ "review_duplicates": "Review duplicates",
+ "review_large_files": "Review large files",
+ "role": "Role",
+ "role_editor": "Editor",
+ "role_viewer": "Viewer",
+ "running": "Running",
+ "save": "Save",
+ "save_to_gallery": "Save to gallery",
+ "saved": "Saved",
+ "saved_api_key": "Saved API Key",
+ "saved_profile": "Saved profile",
+ "saved_settings": "Saved settings",
+ "say_something": "Say something",
+ "scaffold_body_error_occurred": "Error occurred",
+ "scan": "Scan",
+ "scan_all_libraries": "Scan All Libraries",
+ "scan_library": "Scan",
+ "scan_settings": "Scan Settings",
+ "scanning": "Scanning",
+ "scanning_for_album": "Scanning for album...",
+ "search": "Search",
+ "search_albums": "Search albums",
+ "search_by_context": "Search by context",
+ "search_by_description": "Search by description",
+ "search_by_description_example": "Hiking day in Sapa",
+ "search_by_filename": "Search by file name or extension",
+ "search_by_filename_example": "i.e. IMG_1234.JPG or PNG",
+ "search_by_ocr": "Search by OCR",
+ "search_by_ocr_example": "Latte",
+ "search_camera_lens_model": "Search lens model...",
+ "search_camera_make": "Search camera make...",
+ "search_camera_model": "Search camera model...",
+ "search_city": "Search city...",
+ "search_country": "Search country...",
+ "search_filter_apply": "Apply filter",
+ "search_filter_camera_title": "Select camera type",
+ "search_filter_date": "Date",
+ "search_filter_date_interval": "{start} to {end}",
+ "search_filter_date_title": "Select a date range",
+ "search_filter_display_option_not_in_album": "Not in album",
+ "search_filter_display_options": "Display Options",
+ "search_filter_filename": "Search by file name",
+ "search_filter_location": "Location",
+ "search_filter_location_title": "Select location",
+ "search_filter_media_type": "Media Type",
+ "search_filter_media_type_title": "Select media type",
+ "search_filter_ocr": "Search by OCR",
+ "search_filter_people_title": "Select people",
+ "search_filter_star_rating": "Star Rating",
+ "search_for": "Search for",
+ "search_for_existing_person": "Search for existing person",
+ "search_no_more_result": "No more results",
+ "search_no_people": "No people",
+ "search_no_people_named": "No people named \"{name}\"",
+ "search_no_result": "No results found, try a different search term or combination",
+ "search_options": "Search options",
+ "search_page_categories": "Categories",
+ "search_page_motion_photos": "Motion Photos",
+ "search_page_no_objects": "No Objects Info Available",
+ "search_page_no_places": "No Places Info Available",
+ "search_page_screenshots": "Screenshots",
+ "search_page_search_photos_videos": "Search for your photos and videos",
+ "search_page_selfies": "Selfies",
+ "search_page_things": "Things",
+ "search_page_view_all_button": "View all",
+ "search_page_your_activity": "Your activity",
+ "search_page_your_map": "Your Map",
+ "search_people": "Search people",
+ "search_places": "Search places",
+ "search_rating": "Search by rating...",
+ "search_result_page_new_search_hint": "New Search",
+ "search_settings": "Search settings",
+ "search_state": "Search state...",
+ "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ",
+ "search_suggestion_list_smart_search_hint_2": "m:your-search-term",
+ "search_tags": "Search tags...",
+ "search_timezone": "Search timezone...",
+ "search_type": "Search type",
+ "search_your_photos": "Search your photos",
+ "searching_locales": "Searching locales...",
+ "second": "Second",
+ "see_all_people": "See all people",
+ "select": "Select",
+ "select_album": "Select album",
+ "select_album_cover": "Select album cover",
+ "select_albums": "Select albums",
+ "select_all": "Select all",
+ "select_all_duplicates": "Select all duplicates",
+ "select_all_in": "Select all in {group}",
+ "select_avatar_color": "Select avatar color",
+ "select_count": "{count, plural, one {Select #} other {Select #}}",
+ "select_cutoff_date": "Select cutoff date",
+ "select_face": "Select face",
+ "select_featured_photo": "Select featured photo",
+ "select_from_computer": "Select from computer",
+ "select_keep_all": "Select keep all",
+ "select_library_owner": "Select library owner",
+ "select_new_face": "Select new face",
+ "select_people": "Select people",
+ "select_person": "Select person",
+ "select_person_to_tag": "Select a person to tag",
+ "select_photos": "Select photos",
+ "select_trash_all": "Select trash all",
+ "select_user_for_sharing_page_err_album": "Failed to create album",
+ "selected": "Selected",
+ "selected_count": "{count, plural, other {# selected}}",
+ "selected_gps_coordinates": "Selected GPS Coordinates",
+ "send_message": "Send message",
+ "send_welcome_email": "Send welcome email",
+ "server_endpoint": "Server Endpoint",
+ "server_info_box_app_version": "App Version",
+ "server_info_box_server_url": "Server URL",
+ "server_offline": "Server Offline",
+ "server_online": "Server Online",
+ "server_privacy": "Server Privacy",
+ "server_restarting_description": "This page will refresh momentarily.",
+ "server_restarting_title": "Server is restarting",
+ "server_stats": "Server Stats",
+ "server_update_available": "Server update is available",
+ "server_version": "Server Version",
+ "set": "Set",
+ "set_as_album_cover": "Set as album cover",
+ "set_as_featured_photo": "Set as featured photo",
+ "set_as_profile_picture": "Set as profile picture",
+ "set_date_of_birth": "Set date of birth",
+ "set_profile_picture": "Set profile picture",
+ "set_slideshow_to_fullscreen": "Set Slideshow to fullscreen",
+ "set_stack_primary_asset": "Set as primary asset",
+ "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).",
+ "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).",
+ "setting_image_viewer_original_title": "Load original image",
+ "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.",
+ "setting_image_viewer_preview_title": "Load preview image",
+ "setting_image_viewer_title": "Images",
+ "setting_languages_apply": "Apply",
+ "setting_languages_subtitle": "Change the app's language",
+ "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {duration}",
+ "setting_notifications_notify_hours": "{count} hours",
+ "setting_notifications_notify_immediately": "immediately",
+ "setting_notifications_notify_minutes": "{count} minutes",
+ "setting_notifications_notify_never": "never",
+ "setting_notifications_notify_seconds": "{count} seconds",
+ "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset",
+ "setting_notifications_single_progress_title": "Show background backup detail progress",
+ "setting_notifications_subtitle": "Adjust your notification preferences",
+ "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)",
+ "setting_notifications_total_progress_title": "Show background backup total progress",
+ "setting_video_viewer_auto_play_subtitle": "Automatically start playing videos when they are opened",
+ "setting_video_viewer_auto_play_title": "Auto play videos",
+ "setting_video_viewer_looping_title": "Looping",
+ "setting_video_viewer_original_video_subtitle": "When streaming a video from the server, play the original even when a transcode is available. May lead to buffering. Videos available locally are played in original quality regardless of this setting.",
+ "setting_video_viewer_original_video_title": "Force original video",
+ "settings": "Settings",
+ "settings_require_restart": "Please restart Immich to apply this setting",
+ "settings_saved": "Settings saved",
+ "setup_pin_code": "Setup a PIN code",
+ "share": "Share",
+ "share_action_prompt": "Shared {count} assets",
+ "share_add_photos": "Add photos",
+ "share_assets_selected": "{count} selected",
+ "share_dialog_preparing": "Preparing...",
+ "share_link": "Share Link",
+ "shared": "Shared",
+ "shared_album_activities_input_disable": "Comment is disabled",
+ "shared_album_activity_remove_content": "Do you want to delete this activity?",
+ "shared_album_activity_remove_title": "Delete Activity",
+ "shared_album_section_people_action_error": "Error leaving/removing from album",
+ "shared_album_section_people_action_leave": "Remove user from album",
+ "shared_album_section_people_action_remove_user": "Remove user from album",
+ "shared_album_section_people_title": "PEOPLE",
+ "shared_by": "Shared by",
+ "shared_by_user": "Shared by {user}",
+ "shared_by_you": "Shared by you",
+ "shared_from_partner": "Photos from {partner}",
+ "shared_intent_upload_button_progress_text": "{current} / {total} Uploaded",
+ "shared_link_app_bar_title": "Shared Links",
+ "shared_link_clipboard_copied_massage": "Copied to clipboard",
+ "shared_link_clipboard_text": "Link: {link}\nPassword: {password}",
+ "shared_link_create_error": "Error while creating shared link",
+ "shared_link_custom_url_description": "Access this shared link with a custom URL",
+ "shared_link_edit_description_hint": "Enter the share description",
+ "shared_link_edit_expire_after_option_day": "1 day",
+ "shared_link_edit_expire_after_option_days": "{count} days",
+ "shared_link_edit_expire_after_option_hour": "1 hour",
+ "shared_link_edit_expire_after_option_hours": "{count} hours",
+ "shared_link_edit_expire_after_option_minute": "1 minute",
+ "shared_link_edit_expire_after_option_minutes": "{count} minutes",
+ "shared_link_edit_expire_after_option_months": "{count} months",
+ "shared_link_edit_expire_after_option_year": "{count} year",
+ "shared_link_edit_password_hint": "Enter the share password",
+ "shared_link_edit_submit_button": "Update link",
+ "shared_link_error_server_url_fetch": "Cannot fetch the server url",
+ "shared_link_expires_day": "Expires in {count} day",
+ "shared_link_expires_days": "Expires in {count} days",
+ "shared_link_expires_hour": "Expires in {count} hour",
+ "shared_link_expires_hours": "Expires in {count} hours",
+ "shared_link_expires_minute": "Expires in {count} minute",
+ "shared_link_expires_minutes": "Expires in {count} minutes",
+ "shared_link_expires_never": "Expires โ",
+ "shared_link_expires_second": "Expires in {count} second",
+ "shared_link_expires_seconds": "Expires in {count} seconds",
+ "shared_link_individual_shared": "Individual shared",
+ "shared_link_info_chip_metadata": "EXIF",
+ "shared_link_manage_links": "Manage Shared links",
+ "shared_link_options": "Shared link options",
+ "shared_link_password_description": "Require a password to access this shared link",
+ "shared_links": "Shared links",
+ "shared_links_description": "Share photos and videos with a link",
+ "shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}",
+ "shared_with_me": "Shared with me",
+ "shared_with_partner": "Shared with {partner}",
+ "sharing": "Sharing",
+ "sharing_enter_password": "Please enter the password to view this page.",
+ "sharing_page_album": "Shared albums",
+ "sharing_page_description": "Create shared albums to share photos and videos with people in your network.",
+ "sharing_page_empty_list": "EMPTY LIST",
+ "sharing_sidebar_description": "Display a link to Sharing in the sidebar",
+ "sharing_silver_appbar_create_shared_album": "New shared album",
+ "sharing_silver_appbar_share_partner": "Share with partner",
+ "shift_to_permanent_delete": "press โง to permanently delete asset",
+ "show_album_options": "Show album options",
+ "show_albums": "Show albums",
+ "show_all_people": "Show all people",
+ "show_and_hide_people": "Show & hide people",
+ "show_file_location": "Show file location",
+ "show_gallery": "Show gallery",
+ "show_hidden_people": "Show hidden people",
+ "show_in_timeline": "Show in timeline",
+ "show_in_timeline_setting_description": "Show photos and videos from this user in your timeline",
+ "show_keyboard_shortcuts": "Show keyboard shortcuts",
+ "show_metadata": "Show metadata",
+ "show_or_hide_info": "Show or hide info",
+ "show_password": "Show password",
+ "show_person_options": "Show person options",
+ "show_progress_bar": "Show Progress Bar",
+ "show_schema": "Show schema",
+ "show_search_options": "Show search options",
+ "show_shared_links": "Show shared links",
+ "show_slideshow_transition": "Show slideshow transition",
+ "show_supporter_badge": "Supporter badge",
+ "show_supporter_badge_description": "Show a supporter badge",
+ "show_text_recognition": "Show text recognition",
+ "show_text_search_menu": "Show text search menu",
+ "shuffle": "Shuffle",
+ "sidebar": "Sidebar",
+ "sidebar_display_description": "Display a link to the view in the sidebar",
+ "sign_out": "Sign Out",
+ "sign_up": "Sign up",
+ "size": "Size",
+ "skip_to_content": "Skip to content",
+ "skip_to_folders": "Skip to folders",
+ "skip_to_tags": "Skip to tags",
+ "slideshow": "Slideshow",
+ "slideshow_repeat": "Repeat slideshow",
+ "slideshow_repeat_description": "Loop back to beginning when slideshow ends",
+ "slideshow_settings": "Slideshow settings",
+ "sort_albums_by": "Sort albums by...",
+ "sort_created": "Date created",
+ "sort_items": "Number of items",
+ "sort_modified": "Date modified",
+ "sort_newest": "Newest photo",
+ "sort_oldest": "Oldest photo",
+ "sort_people_by_similarity": "Sort people by similarity",
+ "sort_recent": "Most recent photo",
+ "sort_title": "Title",
+ "source": "Source",
+ "stack": "Stack",
+ "stack_action_prompt": "{count} stacked",
+ "stack_duplicates": "Stack duplicates",
+ "stack_select_one_photo": "Select one main photo for the stack",
+ "stack_selected_photos": "Stack selected photos",
+ "stacked_assets_count": "Stacked {count, plural, one {# asset} other {# assets}}",
+ "stacktrace": "Stacktrace",
+ "start": "Start",
+ "start_date": "Start date",
+ "start_date_before_end_date": "Start date must be before end date",
+ "state": "State",
+ "status": "Status",
+ "stop_casting": "Stop casting",
+ "stop_motion_photo": "Stop Motion Photo",
+ "stop_photo_sharing": "Stop sharing your photos?",
+ "stop_photo_sharing_description": "{partner} will no longer be able to access your photos.",
+ "stop_sharing_photos_with_user": "Stop sharing your photos with this user",
+ "storage": "Storage space",
+ "storage_label": "Storage label",
+ "storage_quota": "Storage Quota",
+ "storage_usage": "{used} of {available} used",
+ "submit": "Submit",
+ "success": "Success",
+ "suggestions": "Suggestions",
+ "sunrise_on_the_beach": "Sunrise on the beach",
+ "support": "Support",
+ "support_and_feedback": "Support & Feedback",
+ "support_third_party_description": "Your Immich installation was packaged by a third-party. Issues you experience may be caused by that package, so please raise issues with them in the first instance using the links below.",
+ "swap_merge_direction": "Swap merge direction",
+ "sync": "Sync",
+ "sync_albums": "Sync albums",
+ "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums",
+ "sync_local": "Sync Local",
+ "sync_remote": "Sync Remote",
+ "sync_status": "Sync Status",
+ "sync_status_subtitle": "View and manage the sync system",
+ "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich",
+ "tag": "Tag",
+ "tag_assets": "Tag assets",
+ "tag_created": "Created tag: {tag}",
+ "tag_feature_description": "Browsing photos and videos grouped by logical tag topics",
+ "tag_not_found_question": "Cannot find a tag? Create a new tag.",
+ "tag_people": "Tag People",
+ "tag_updated": "Updated tag: {tag}",
+ "tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}",
+ "tags": "Tags",
+ "tap_to_run_job": "Tap to run job",
+ "template": "Template",
+ "text_recognition": "Text recognition",
+ "theme": "Theme",
+ "theme_selection": "Theme selection",
+ "theme_selection_description": "Automatically set the theme to light or dark based on your browser's system preference",
+ "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles",
+ "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({count})",
+ "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.",
+ "theme_setting_colorful_interface_title": "Colorful interface",
+ "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer",
+ "theme_setting_image_viewer_quality_title": "Image viewer quality",
+ "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.",
+ "theme_setting_primary_color_title": "Primary color",
+ "theme_setting_system_primary_color_title": "Use system color",
+ "theme_setting_system_theme_switch": "Automatic (Follow system setting)",
+ "theme_setting_theme_subtitle": "Choose the app's theme setting",
+ "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
+ "theme_setting_three_stage_loading_title": "Enable three-stage loading",
+ "then": "Then",
+ "they_will_be_merged_together": "They will be merged together",
+ "third_party_resources": "Third-Party Resources",
+ "time": "Time",
+ "time_based_memories": "Time-based memories",
+ "time_based_memories_duration": "Number of seconds to display each image.",
+ "timeline": "Timeline",
+ "timezone": "Timezone",
+ "to_archive": "Archive",
+ "to_change_password": "Change password",
+ "to_favorite": "Favorite",
+ "to_login": "Login",
+ "to_multi_select": "to multi-select",
+ "to_parent": "Go to parent",
+ "to_select": "to select",
+ "to_trash": "Trash",
+ "toggle_settings": "Toggle settings",
+ "toggle_theme_description": "Toggle theme",
+ "total": "Total",
+ "total_usage": "Total usage",
+ "trash": "Trash",
+ "trash_action_prompt": "{count} moved to trash",
+ "trash_all": "Trash All",
+ "trash_count": "Trash {count, number}",
+ "trash_delete_asset": "Trash/Delete Asset",
+ "trash_emptied": "Emptied trash",
+ "trash_no_results_message": "Trashed photos and videos will show up here.",
+ "trash_page_delete_all": "Delete All",
+ "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich",
+ "trash_page_info": "Trashed items will be permanently deleted after {days} days",
+ "trash_page_no_assets": "No trashed assets",
+ "trash_page_restore_all": "Restore All",
+ "trash_page_select_assets_btn": "Select assets",
+ "trash_page_title": "Trash ({count})",
+ "trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
+ "trigger": "Trigger",
+ "trigger_asset_uploaded": "Asset Uploaded",
+ "trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
+ "trigger_description": "An event that kicks off the workflow",
+ "trigger_person_recognized": "Person Recognized",
+ "trigger_person_recognized_description": "Triggered when a person is detected",
+ "trigger_type": "Trigger type",
+ "troubleshoot": "Troubleshoot",
+ "type": "Type",
+ "unable_to_change_pin_code": "Unable to change PIN code",
+ "unable_to_check_version": "Unable to check app or server version",
+ "unable_to_setup_pin_code": "Unable to setup PIN code",
+ "unarchive": "Unarchive",
+ "unarchive_action_prompt": "{count} removed from Archive",
+ "unarchived_count": "{count, plural, other {Unarchived #}}",
+ "undo": "Undo",
+ "unfavorite": "Unfavorite",
+ "unfavorite_action_prompt": "{count} removed from Favorites",
+ "unhide_person": "Unhide person",
+ "unknown": "Unknown",
+ "unknown_country": "Unknown Country",
+ "unknown_date": "Unknown date",
+ "unknown_year": "Unknown Year",
+ "unlimited": "Unlimited",
+ "unlink_motion_video": "Unlink motion video",
+ "unlink_oauth": "Unlink OAuth",
+ "unlinked_oauth_account": "Unlinked OAuth account",
+ "unmute_memories": "Unmute Memories",
+ "unnamed_album": "Unnamed Album",
+ "unnamed_album_delete_confirmation": "Are you sure you want to delete this album?",
+ "unnamed_share": "Unnamed Share",
+ "unsaved_change": "Unsaved change",
+ "unselect_all": "Unselect all",
+ "unselect_all_duplicates": "Unselect all duplicates",
+ "unselect_all_in": "Unselect all in {group}",
+ "unstack": "Un-stack",
+ "unstack_action_prompt": "{count} unstacked",
+ "unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
+ "unsupported_field_type": "Unsupported field type",
+ "untagged": "Untagged",
+ "untitled_workflow": "Untitled workflow",
+ "up_next": "Up next",
+ "update_location_action_prompt": "Update the location of {count} selected assets with:",
+ "updated_at": "Updated",
+ "updated_password": "Updated password",
+ "upload": "Upload",
+ "upload_concurrency": "Upload concurrency",
+ "upload_details": "Upload Details",
+ "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
+ "upload_dialog_title": "Upload Asset",
+ "upload_error_with_count": "Upload error for {count, plural, one {# asset} other {# assets}}",
+ "upload_errors": "Upload completed with {count, plural, one {# error} other {# errors}}, refresh the page to see new upload assets.",
+ "upload_finished": "Upload finished",
+ "upload_progress": "Remaining {remaining, number} - Processed {processed, number}/{total, number}",
+ "upload_skipped_duplicates": "Skipped {count, plural, one {# duplicate asset} other {# duplicate assets}}",
+ "upload_status_duplicates": "Duplicates",
+ "upload_status_errors": "Errors",
+ "upload_status_uploaded": "Uploaded",
+ "upload_success": "Upload success, refresh the page to see new upload assets.",
+ "upload_to_immich": "Upload to Immich ({count})",
+ "uploading": "Uploading",
+ "uploading_media": "Uploading media",
+ "url": "URL",
+ "usage": "Usage",
+ "use_biometric": "Use biometric",
+ "use_current_connection": "Use current connection",
+ "use_custom_date_range": "Use custom date range instead",
+ "user": "User",
+ "user_has_been_deleted": "This user has been deleted.",
+ "user_id": "User ID",
+ "user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}",
+ "user_pin_code_settings": "PIN Code",
+ "user_pin_code_settings_description": "Manage your PIN code",
+ "user_privacy": "User Privacy",
+ "user_purchase_settings": "Purchase",
+ "user_purchase_settings_description": "Manage your purchase",
+ "user_role_set": "Set {user} as {role}",
+ "user_usage_detail": "User usage detail",
+ "user_usage_stats": "Account usage statistics",
+ "user_usage_stats_description": "View account usage statistics",
+ "username": "Username",
+ "users": "Users",
+ "users_added_to_album_count": "Added {count, plural, one {# user} other {# users}} to the album",
+ "utilities": "Utilities",
+ "validate": "Validate",
+ "validate_endpoint_error": "Please enter a valid URL",
+ "validation_error": "Validation error",
+ "variables": "Variables",
+ "version": "Version",
+ "version_announcement_closing": "Your friend, Alex",
+ "version_announcement_message": "Hi there! A new version of Immich is available. Please take some time to read the release notes to ensure your setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your Immich instance automatically.",
+ "version_history": "Version History",
+ "version_history_item": "Installed {version} on {date}",
+ "video": "Video",
+ "video_hover_setting": "Play video thumbnail on hover",
+ "video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.",
+ "videos": "Videos",
+ "videos_count": "{count, plural, one {# Video} other {# Videos}}",
+ "videos_only": "Videos only",
+ "view": "View",
+ "view_album": "View Album",
+ "view_all": "View All",
+ "view_all_users": "View all users",
+ "view_asset_owners": "View asset owners",
+ "view_details": "View Details",
+ "view_in_timeline": "View in timeline",
+ "view_link": "View link",
+ "view_links": "View links",
+ "view_name": "View",
+ "view_next_asset": "View next asset",
+ "view_previous_asset": "View previous asset",
+ "view_qr_code": "View QR code",
+ "view_similar_photos": "View similar photos",
+ "view_stack": "View Stack",
+ "view_user": "View User",
+ "viewer_remove_from_stack": "Remove from Stack",
+ "viewer_stack_use_as_main_asset": "Use as Main Asset",
+ "viewer_unstack": "Un-Stack",
+ "visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
+ "visual": "Visual",
+ "visual_builder": "Visual builder",
+ "waiting": "Waiting",
+ "waiting_count": "Waiting: {count}",
+ "warning": "Warning",
+ "week": "Week",
+ "welcome": "Welcome",
+ "welcome_to_immich": "Welcome to Immich",
+ "width": "Width",
+ "wifi_name": "Wi-Fi Name",
+ "workflow_delete_prompt": "Are you sure you want to delete this workflow?",
+ "workflow_deleted": "Workflow deleted",
+ "workflow_description": "Workflow description",
+ "workflow_info": "Workflow info",
+ "workflow_json": "Workflow JSON",
+ "workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.",
+ "workflow_name": "Workflow name",
+ "workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
+ "workflow_summary": "Workflow summary",
+ "workflow_update_success": "Workflow updated successfully",
+ "workflow_updated": "Workflow updated",
+ "workflows": "Workflows",
+ "workflows_help_text": "Workflows automate actions on your assets based on triggers and filters",
+ "wrong_pin_code": "Wrong PIN code",
+ "year": "Year",
+ "years_ago": "{years, plural, one {# year} other {# years}} ago",
+ "yes": "Yes",
+ "you_dont_have_any_shared_links": "You don't have any shared links",
+ "your_wifi_name": "Your Wi-Fi name",
+ "zero_to_clear_rating": "press 0 to clear asset rating",
+ "zoom_image": "Zoom Image",
+ "zoom_to_bounds": "Zoom to bounds"
}
diff --git a/i18n/eo.json b/i18n/eo.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/eo.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/es.json b/i18n/es.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/es.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/et.json b/i18n/et.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/et.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/eu.json b/i18n/eu.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/eu.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/fa.json b/i18n/fa.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/fa.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/fi.json b/i18n/fi.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/fi.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/fil.json b/i18n/fil.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/fil.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/fr.json b/i18n/fr.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/fr.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/ga.json b/i18n/ga.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/ga.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/gl.json b/i18n/gl.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/gl.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/gsw.json b/i18n/gsw.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/gsw.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/gu.json b/i18n/gu.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/gu.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/he.json b/i18n/he.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/he.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/hi.json b/i18n/hi.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/hi.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/hr.json b/i18n/hr.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/hr.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/hu.json b/i18n/hu.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/hu.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/hy.json b/i18n/hy.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/hy.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/id.json b/i18n/id.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/id.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/is.json b/i18n/is.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/is.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/it.json b/i18n/it.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/it.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/ja.json b/i18n/ja.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/ja.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/ka.json b/i18n/ka.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/ka.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/kk.json b/i18n/kk.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/kk.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/km.json b/i18n/km.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/km.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/kmr.json b/i18n/kmr.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/kmr.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/kn.json b/i18n/kn.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/kn.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/ko.json b/i18n/ko.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/ko.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/lb.json b/i18n/lb.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/lb.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/lt.json b/i18n/lt.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/lt.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/lv.json b/i18n/lv.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/lv.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/mfa.json b/i18n/mfa.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/mfa.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/mk.json b/i18n/mk.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/mk.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/ml.json b/i18n/ml.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/ml.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/mn.json b/i18n/mn.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/mn.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/mr.json b/i18n/mr.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/mr.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/ms.json b/i18n/ms.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/ms.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/nb_NO.json b/i18n/nb_NO.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/nb_NO.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/nl.json b/i18n/nl.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/nl.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/nn.json b/i18n/nn.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/nn.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/pa.json b/i18n/pa.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/pa.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/package.json b/i18n/package.json
index cb3560bea1..fbec4e558c 100644
--- a/i18n/package.json
+++ b/i18n/package.json
@@ -1,6 +1,6 @@
{
- "name": "immich-i18n",
- "version": "2.5.5",
+ "name": "i18n",
+ "version": "0.1.0",
"private": true,
"scripts": {
"format": "prettier --check .",
diff --git a/i18n/pl.json b/i18n/pl.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/pl.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/pt.json b/i18n/pt.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/pt.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/pt_BR.json b/i18n/pt_BR.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/pt_BR.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/ro.json b/i18n/ro.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/ro.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/ru.json b/i18n/ru.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/ru.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/si.json b/i18n/si.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/si.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/sk.json b/i18n/sk.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/sk.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/sl.json b/i18n/sl.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/sl.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/sq.json b/i18n/sq.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/sq.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/sr_Cyrl.json b/i18n/sr_Cyrl.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/sr_Cyrl.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/sr_Latn.json b/i18n/sr_Latn.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/sr_Latn.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/sv.json b/i18n/sv.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/sv.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/ta.json b/i18n/ta.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/ta.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/te.json b/i18n/te.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/te.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/th.json b/i18n/th.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/th.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/tr.json b/i18n/tr.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/tr.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/uk.json b/i18n/uk.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/uk.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/ur.json b/i18n/ur.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/ur.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/uz.json b/i18n/uz.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/uz.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/vi.json b/i18n/vi.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/vi.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/yue_Hant.json b/i18n/yue_Hant.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/yue_Hant.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/zh_Hant.json b/i18n/zh_Hant.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/zh_Hant.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/i18n/zh_SIMPLIFIED.json b/i18n/zh_SIMPLIFIED.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/i18n/zh_SIMPLIFIED.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 80c1a1c868..dfad45a709 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -1,9 +1,9 @@
# openapi
-Immich API
+App API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
-- API version: 2.5.5
+- API version: 0.1.0
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
@@ -79,251 +79,24 @@ Class | Method | HTTP request | Description
*APIKeysApi* | [**getApiKeys**](doc//APIKeysApi.md#getapikeys) | **GET** /api-keys | List all API keys
*APIKeysApi* | [**getMyApiKey**](doc//APIKeysApi.md#getmyapikey) | **GET** /api-keys/me | Retrieve the current API key
*APIKeysApi* | [**updateApiKey**](doc//APIKeysApi.md#updateapikey) | **PUT** /api-keys/{id} | Update an API key
-*ActivitiesApi* | [**createActivity**](doc//ActivitiesApi.md#createactivity) | **POST** /activities | Create an activity
-*ActivitiesApi* | [**deleteActivity**](doc//ActivitiesApi.md#deleteactivity) | **DELETE** /activities/{id} | Delete an activity
-*ActivitiesApi* | [**getActivities**](doc//ActivitiesApi.md#getactivities) | **GET** /activities | List all activities
-*ActivitiesApi* | [**getActivityStatistics**](doc//ActivitiesApi.md#getactivitystatistics) | **GET** /activities/statistics | Retrieve activity statistics
-*AlbumsApi* | [**addAssetsToAlbum**](doc//AlbumsApi.md#addassetstoalbum) | **PUT** /albums/{id}/assets | Add assets to an album
-*AlbumsApi* | [**addAssetsToAlbums**](doc//AlbumsApi.md#addassetstoalbums) | **PUT** /albums/assets | Add assets to albums
-*AlbumsApi* | [**addUsersToAlbum**](doc//AlbumsApi.md#adduserstoalbum) | **PUT** /albums/{id}/users | Share album with users
-*AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums | Create an album
-*AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} | Delete an album
-*AlbumsApi* | [**getAlbumInfo**](doc//AlbumsApi.md#getalbuminfo) | **GET** /albums/{id} | Retrieve an album
-*AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics | Retrieve album statistics
-*AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums | List all albums
-*AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets | Remove assets from an album
-*AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | Remove user from album
-*AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} | Update an album
-*AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | Update user role
-*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | Check bulk upload
-*AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist | Check existing assets
-*AssetsApi* | [**copyAsset**](doc//AssetsApi.md#copyasset) | **PUT** /assets/copy | Copy asset
-*AssetsApi* | [**deleteAssetMetadata**](doc//AssetsApi.md#deleteassetmetadata) | **DELETE** /assets/{id}/metadata/{key} | Delete asset metadata by key
-*AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets | Delete assets
-*AssetsApi* | [**deleteBulkAssetMetadata**](doc//AssetsApi.md#deletebulkassetmetadata) | **DELETE** /assets/metadata | Delete asset metadata
-*AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | Download original asset
-*AssetsApi* | [**editAsset**](doc//AssetsApi.md#editasset) | **PUT** /assets/{id}/edits | Apply edits to an existing asset
-*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID
-*AssetsApi* | [**getAssetEdits**](doc//AssetsApi.md#getassetedits) | **GET** /assets/{id}/edits | Retrieve edits for an existing asset
-*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | Retrieve an asset
-*AssetsApi* | [**getAssetMetadata**](doc//AssetsApi.md#getassetmetadata) | **GET** /assets/{id}/metadata | Get asset metadata
-*AssetsApi* | [**getAssetMetadataByKey**](doc//AssetsApi.md#getassetmetadatabykey) | **GET** /assets/{id}/metadata/{key} | Retrieve asset metadata by key
-*AssetsApi* | [**getAssetOcr**](doc//AssetsApi.md#getassetocr) | **GET** /assets/{id}/ocr | Retrieve asset OCR data
-*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | Get asset statistics
-*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | Get random assets
-*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | Play asset video
-*AssetsApi* | [**removeAssetEdits**](doc//AssetsApi.md#removeassetedits) | **DELETE** /assets/{id}/edits | Remove edits from an existing asset
-*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset
-*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | Run an asset job
-*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | Update an asset
-*AssetsApi* | [**updateAssetMetadata**](doc//AssetsApi.md#updateassetmetadata) | **PUT** /assets/{id}/metadata | Update asset metadata
-*AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets | Update assets
-*AssetsApi* | [**updateBulkAssetMetadata**](doc//AssetsApi.md#updatebulkassetmetadata) | **PUT** /assets/metadata | Upsert asset metadata
-*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | Upload asset
-*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | View asset thumbnail
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | Change password
-*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | Change pin code
-*AuthenticationApi* | [**finishOAuth**](doc//AuthenticationApi.md#finishoauth) | **POST** /oauth/callback | Finish OAuth
-*AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status | Retrieve auth status
-*AuthenticationApi* | [**linkOAuthAccount**](doc//AuthenticationApi.md#linkoauthaccount) | **POST** /oauth/link | Link OAuth account
-*AuthenticationApi* | [**lockAuthSession**](doc//AuthenticationApi.md#lockauthsession) | **POST** /auth/session/lock | Lock auth session
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | Login
*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | Logout
-*AuthenticationApi* | [**redirectOAuthToMobile**](doc//AuthenticationApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect | Redirect OAuth to mobile
-*AuthenticationApi* | [**resetPinCode**](doc//AuthenticationApi.md#resetpincode) | **DELETE** /auth/pin-code | Reset pin code
-*AuthenticationApi* | [**setupPinCode**](doc//AuthenticationApi.md#setuppincode) | **POST** /auth/pin-code | Setup pin code
*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | Register admin
-*AuthenticationApi* | [**startOAuth**](doc//AuthenticationApi.md#startoauth) | **POST** /oauth/authorize | Start OAuth
-*AuthenticationApi* | [**unlinkOAuthAccount**](doc//AuthenticationApi.md#unlinkoauthaccount) | **POST** /oauth/unlink | Unlink OAuth account
-*AuthenticationApi* | [**unlockAuthSession**](doc//AuthenticationApi.md#unlockauthsession) | **POST** /auth/session/unlock | Unlock auth session
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | Validate access token
-*AuthenticationAdminApi* | [**unlinkAllOAuthAccountsAdmin**](doc//AuthenticationAdminApi.md#unlinkalloauthaccountsadmin) | **POST** /admin/auth/unlink-all | Unlink all OAuth accounts
-*DatabaseBackupsAdminApi* | [**deleteDatabaseBackup**](doc//DatabaseBackupsAdminApi.md#deletedatabasebackup) | **DELETE** /admin/database-backups | Delete database backup
-*DatabaseBackupsAdminApi* | [**downloadDatabaseBackup**](doc//DatabaseBackupsAdminApi.md#downloaddatabasebackup) | **GET** /admin/database-backups/{filename} | Download database backup
-*DatabaseBackupsAdminApi* | [**listDatabaseBackups**](doc//DatabaseBackupsAdminApi.md#listdatabasebackups) | **GET** /admin/database-backups | List database backups
-*DatabaseBackupsAdminApi* | [**startDatabaseRestoreFlow**](doc//DatabaseBackupsAdminApi.md#startdatabaserestoreflow) | **POST** /admin/database-backups/start-restore | Start database backup restore flow
-*DatabaseBackupsAdminApi* | [**uploadDatabaseBackup**](doc//DatabaseBackupsAdminApi.md#uploaddatabasebackup) | **POST** /admin/database-backups/upload | Upload database backup
-*DeprecatedApi* | [**createPartnerDeprecated**](doc//DeprecatedApi.md#createpartnerdeprecated) | **POST** /partners/{id} | Create a partner
-*DeprecatedApi* | [**getAllUserAssetsByDeviceId**](doc//DeprecatedApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID
-*DeprecatedApi* | [**getDeltaSync**](doc//DeprecatedApi.md#getdeltasync) | **POST** /sync/delta-sync | Get delta sync for user
-*DeprecatedApi* | [**getFullSyncForUser**](doc//DeprecatedApi.md#getfullsyncforuser) | **POST** /sync/full-sync | Get full sync for user
-*DeprecatedApi* | [**getQueuesLegacy**](doc//DeprecatedApi.md#getqueueslegacy) | **GET** /jobs | Retrieve queue counts and status
-*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | Get random assets
-*DeprecatedApi* | [**replaceAsset**](doc//DeprecatedApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset
-*DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
-*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive
-*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information
-*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Delete a duplicate
-*DuplicatesApi* | [**deleteDuplicates**](doc//DuplicatesApi.md#deleteduplicates) | **DELETE** /duplicates | Delete duplicates
-*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | Retrieve duplicates
-*FacesApi* | [**createFace**](doc//FacesApi.md#createface) | **POST** /faces | Create a face
-*FacesApi* | [**deleteFace**](doc//FacesApi.md#deleteface) | **DELETE** /faces/{id} | Delete a face
-*FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces | Retrieve faces for asset
-*FacesApi* | [**reassignFacesById**](doc//FacesApi.md#reassignfacesbyid) | **PUT** /faces/{id} | Re-assign a face to another person
-*JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs | Create a manual job
-*JobsApi* | [**getQueuesLegacy**](doc//JobsApi.md#getqueueslegacy) | **GET** /jobs | Retrieve queue counts and status
-*JobsApi* | [**runQueueCommandLegacy**](doc//JobsApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
-*LibrariesApi* | [**createLibrary**](doc//LibrariesApi.md#createlibrary) | **POST** /libraries | Create a library
-*LibrariesApi* | [**deleteLibrary**](doc//LibrariesApi.md#deletelibrary) | **DELETE** /libraries/{id} | Delete a library
-*LibrariesApi* | [**getAllLibraries**](doc//LibrariesApi.md#getalllibraries) | **GET** /libraries | Retrieve libraries
-*LibrariesApi* | [**getLibrary**](doc//LibrariesApi.md#getlibrary) | **GET** /libraries/{id} | Retrieve a library
-*LibrariesApi* | [**getLibraryStatistics**](doc//LibrariesApi.md#getlibrarystatistics) | **GET** /libraries/{id}/statistics | Retrieve library statistics
-*LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | Scan a library
-*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | Update a library
-*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | Validate library settings
-*MaintenanceAdminApi* | [**detectPriorInstall**](doc//MaintenanceAdminApi.md#detectpriorinstall) | **GET** /admin/maintenance/detect-install | Detect existing install
-*MaintenanceAdminApi* | [**getMaintenanceStatus**](doc//MaintenanceAdminApi.md#getmaintenancestatus) | **GET** /admin/maintenance/status | Get maintenance mode status
-*MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode
-*MaintenanceAdminApi* | [**setMaintenanceMode**](doc//MaintenanceAdminApi.md#setmaintenancemode) | **POST** /admin/maintenance | Set maintenance mode
-*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | Retrieve map markers
-*MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | Reverse geocode coordinates
-*MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | Add assets to a memory
-*MemoriesApi* | [**createMemory**](doc//MemoriesApi.md#creatememory) | **POST** /memories | Create a memory
-*MemoriesApi* | [**deleteMemory**](doc//MemoriesApi.md#deletememory) | **DELETE** /memories/{id} | Delete a memory
-*MemoriesApi* | [**getMemory**](doc//MemoriesApi.md#getmemory) | **GET** /memories/{id} | Retrieve a memory
-*MemoriesApi* | [**memoriesStatistics**](doc//MemoriesApi.md#memoriesstatistics) | **GET** /memories/statistics | Retrieve memories statistics
-*MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets | Remove assets from a memory
-*MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories | Retrieve memories
-*MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} | Update a memory
-*NotificationsApi* | [**deleteNotification**](doc//NotificationsApi.md#deletenotification) | **DELETE** /notifications/{id} | Delete a notification
-*NotificationsApi* | [**deleteNotifications**](doc//NotificationsApi.md#deletenotifications) | **DELETE** /notifications | Delete notifications
-*NotificationsApi* | [**getNotification**](doc//NotificationsApi.md#getnotification) | **GET** /notifications/{id} | Get a notification
-*NotificationsApi* | [**getNotifications**](doc//NotificationsApi.md#getnotifications) | **GET** /notifications | Retrieve notifications
-*NotificationsApi* | [**updateNotification**](doc//NotificationsApi.md#updatenotification) | **PUT** /notifications/{id} | Update a notification
-*NotificationsApi* | [**updateNotifications**](doc//NotificationsApi.md#updatenotifications) | **PUT** /notifications | Update notifications
-*NotificationsAdminApi* | [**createNotification**](doc//NotificationsAdminApi.md#createnotification) | **POST** /admin/notifications | Create a notification
-*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /admin/notifications/templates/{name} | Render email template
-*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /admin/notifications/test-email | Send test email
-*PartnersApi* | [**createPartner**](doc//PartnersApi.md#createpartner) | **POST** /partners | Create a partner
-*PartnersApi* | [**createPartnerDeprecated**](doc//PartnersApi.md#createpartnerdeprecated) | **POST** /partners/{id} | Create a partner
-*PartnersApi* | [**getPartners**](doc//PartnersApi.md#getpartners) | **GET** /partners | Retrieve partners
-*PartnersApi* | [**removePartner**](doc//PartnersApi.md#removepartner) | **DELETE** /partners/{id} | Remove a partner
-*PartnersApi* | [**updatePartner**](doc//PartnersApi.md#updatepartner) | **PUT** /partners/{id} | Update a partner
-*PeopleApi* | [**createPerson**](doc//PeopleApi.md#createperson) | **POST** /people | Create a person
-*PeopleApi* | [**deletePeople**](doc//PeopleApi.md#deletepeople) | **DELETE** /people | Delete people
-*PeopleApi* | [**deletePerson**](doc//PeopleApi.md#deleteperson) | **DELETE** /people/{id} | Delete person
-*PeopleApi* | [**getAllPeople**](doc//PeopleApi.md#getallpeople) | **GET** /people | Get all people
-*PeopleApi* | [**getPerson**](doc//PeopleApi.md#getperson) | **GET** /people/{id} | Get a person
-*PeopleApi* | [**getPersonStatistics**](doc//PeopleApi.md#getpersonstatistics) | **GET** /people/{id}/statistics | Get person statistics
-*PeopleApi* | [**getPersonThumbnail**](doc//PeopleApi.md#getpersonthumbnail) | **GET** /people/{id}/thumbnail | Get person thumbnail
-*PeopleApi* | [**mergePerson**](doc//PeopleApi.md#mergeperson) | **POST** /people/{id}/merge | Merge people
-*PeopleApi* | [**reassignFaces**](doc//PeopleApi.md#reassignfaces) | **PUT** /people/{id}/reassign | Reassign faces
-*PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people
-*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
-*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
-*PluginsApi* | [**getPluginTriggers**](doc//PluginsApi.md#getplugintriggers) | **GET** /plugins/triggers | List all plugin triggers
-*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins
-*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
-*QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue
-*QueuesApi* | [**getQueueJobs**](doc//QueuesApi.md#getqueuejobs) | **GET** /queues/{name}/jobs | Retrieve queue jobs
-*QueuesApi* | [**getQueues**](doc//QueuesApi.md#getqueues) | **GET** /queues | List all queues
-*QueuesApi* | [**updateQueue**](doc//QueuesApi.md#updatequeue) | **PUT** /queues/{name} | Update a queue
-*SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | Retrieve assets by city
-*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | Retrieve explore data
-*SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | Retrieve search suggestions
-*SearchApi* | [**searchAssetStatistics**](doc//SearchApi.md#searchassetstatistics) | **POST** /search/statistics | Search asset statistics
-*SearchApi* | [**searchAssets**](doc//SearchApi.md#searchassets) | **POST** /search/metadata | Search assets by metadata
-*SearchApi* | [**searchLargeAssets**](doc//SearchApi.md#searchlargeassets) | **POST** /search/large-assets | Search large assets
-*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | Search people
-*SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places | Search places
-*SearchApi* | [**searchRandom**](doc//SearchApi.md#searchrandom) | **POST** /search/random | Search random assets
-*SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart | Smart asset search
-*ServerApi* | [**deleteServerLicense**](doc//ServerApi.md#deleteserverlicense) | **DELETE** /server/license | Delete server product key
*ServerApi* | [**getAboutInfo**](doc//ServerApi.md#getaboutinfo) | **GET** /server/about | Get server information
-*ServerApi* | [**getApkLinks**](doc//ServerApi.md#getapklinks) | **GET** /server/apk-links | Get APK links
*ServerApi* | [**getServerConfig**](doc//ServerApi.md#getserverconfig) | **GET** /server/config | Get config
*ServerApi* | [**getServerFeatures**](doc//ServerApi.md#getserverfeatures) | **GET** /server/features | Get features
-*ServerApi* | [**getServerLicense**](doc//ServerApi.md#getserverlicense) | **GET** /server/license | Get product key
-*ServerApi* | [**getServerStatistics**](doc//ServerApi.md#getserverstatistics) | **GET** /server/statistics | Get statistics
*ServerApi* | [**getServerVersion**](doc//ServerApi.md#getserverversion) | **GET** /server/version | Get server version
-*ServerApi* | [**getStorage**](doc//ServerApi.md#getstorage) | **GET** /server/storage | Get storage
-*ServerApi* | [**getSupportedMediaTypes**](doc//ServerApi.md#getsupportedmediatypes) | **GET** /server/media-types | Get supported media types
-*ServerApi* | [**getTheme**](doc//ServerApi.md#gettheme) | **GET** /server/theme | Get theme
-*ServerApi* | [**getVersionCheck**](doc//ServerApi.md#getversioncheck) | **GET** /server/version-check | Get version check status
-*ServerApi* | [**getVersionHistory**](doc//ServerApi.md#getversionhistory) | **GET** /server/version-history | Get version history
*ServerApi* | [**pingServer**](doc//ServerApi.md#pingserver) | **GET** /server/ping | Ping
-*ServerApi* | [**setServerLicense**](doc//ServerApi.md#setserverlicense) | **PUT** /server/license | Set server product key
*SessionsApi* | [**createSession**](doc//SessionsApi.md#createsession) | **POST** /sessions | Create a session
*SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions | Delete all sessions
*SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} | Delete a session
*SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions | Retrieve sessions
-*SessionsApi* | [**lockSession**](doc//SessionsApi.md#locksession) | **POST** /sessions/{id}/lock | Lock a session
-*SessionsApi* | [**updateSession**](doc//SessionsApi.md#updatesession) | **PUT** /sessions/{id} | Update a session
-*SharedLinksApi* | [**addSharedLinkAssets**](doc//SharedLinksApi.md#addsharedlinkassets) | **PUT** /shared-links/{id}/assets | Add assets to a shared link
-*SharedLinksApi* | [**createSharedLink**](doc//SharedLinksApi.md#createsharedlink) | **POST** /shared-links | Create a shared link
-*SharedLinksApi* | [**getAllSharedLinks**](doc//SharedLinksApi.md#getallsharedlinks) | **GET** /shared-links | Retrieve all shared links
-*SharedLinksApi* | [**getMySharedLink**](doc//SharedLinksApi.md#getmysharedlink) | **GET** /shared-links/me | Retrieve current shared link
-*SharedLinksApi* | [**getSharedLinkById**](doc//SharedLinksApi.md#getsharedlinkbyid) | **GET** /shared-links/{id} | Retrieve a shared link
-*SharedLinksApi* | [**removeSharedLink**](doc//SharedLinksApi.md#removesharedlink) | **DELETE** /shared-links/{id} | Delete a shared link
-*SharedLinksApi* | [**removeSharedLinkAssets**](doc//SharedLinksApi.md#removesharedlinkassets) | **DELETE** /shared-links/{id}/assets | Remove assets from a shared link
-*SharedLinksApi* | [**updateSharedLink**](doc//SharedLinksApi.md#updatesharedlink) | **PATCH** /shared-links/{id} | Update a shared link
-*StacksApi* | [**createStack**](doc//StacksApi.md#createstack) | **POST** /stacks | Create a stack
-*StacksApi* | [**deleteStack**](doc//StacksApi.md#deletestack) | **DELETE** /stacks/{id} | Delete a stack
-*StacksApi* | [**deleteStacks**](doc//StacksApi.md#deletestacks) | **DELETE** /stacks | Delete stacks
-*StacksApi* | [**getStack**](doc//StacksApi.md#getstack) | **GET** /stacks/{id} | Retrieve a stack
-*StacksApi* | [**removeAssetFromStack**](doc//StacksApi.md#removeassetfromstack) | **DELETE** /stacks/{id}/assets/{assetId} | Remove an asset from a stack
-*StacksApi* | [**searchStacks**](doc//StacksApi.md#searchstacks) | **GET** /stacks | Retrieve stacks
-*StacksApi* | [**updateStack**](doc//StacksApi.md#updatestack) | **PUT** /stacks/{id} | Update a stack
-*SyncApi* | [**deleteSyncAck**](doc//SyncApi.md#deletesyncack) | **DELETE** /sync/ack | Delete acknowledgements
-*SyncApi* | [**getDeltaSync**](doc//SyncApi.md#getdeltasync) | **POST** /sync/delta-sync | Get delta sync for user
-*SyncApi* | [**getFullSyncForUser**](doc//SyncApi.md#getfullsyncforuser) | **POST** /sync/full-sync | Get full sync for user
-*SyncApi* | [**getSyncAck**](doc//SyncApi.md#getsyncack) | **GET** /sync/ack | Retrieve acknowledgements
-*SyncApi* | [**getSyncStream**](doc//SyncApi.md#getsyncstream) | **POST** /sync/stream | Stream sync changes
-*SyncApi* | [**sendSyncAck**](doc//SyncApi.md#sendsyncack) | **POST** /sync/ack | Acknowledge changes
-*SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | Get system configuration
-*SystemConfigApi* | [**getConfigDefaults**](doc//SystemConfigApi.md#getconfigdefaults) | **GET** /system-config/defaults | Get system configuration defaults
-*SystemConfigApi* | [**getStorageTemplateOptions**](doc//SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options | Get storage template options
-*SystemConfigApi* | [**updateConfig**](doc//SystemConfigApi.md#updateconfig) | **PUT** /system-config | Update system configuration
-*SystemMetadataApi* | [**getAdminOnboarding**](doc//SystemMetadataApi.md#getadminonboarding) | **GET** /system-metadata/admin-onboarding | Retrieve admin onboarding
-*SystemMetadataApi* | [**getReverseGeocodingState**](doc//SystemMetadataApi.md#getreversegeocodingstate) | **GET** /system-metadata/reverse-geocoding-state | Retrieve reverse geocoding state
-*SystemMetadataApi* | [**getVersionCheckState**](doc//SystemMetadataApi.md#getversioncheckstate) | **GET** /system-metadata/version-check-state | Retrieve version check state
-*SystemMetadataApi* | [**updateAdminOnboarding**](doc//SystemMetadataApi.md#updateadminonboarding) | **POST** /system-metadata/admin-onboarding | Update admin onboarding
-*TagsApi* | [**bulkTagAssets**](doc//TagsApi.md#bulktagassets) | **PUT** /tags/assets | Tag assets
-*TagsApi* | [**createTag**](doc//TagsApi.md#createtag) | **POST** /tags | Create a tag
-*TagsApi* | [**deleteTag**](doc//TagsApi.md#deletetag) | **DELETE** /tags/{id} | Delete a tag
-*TagsApi* | [**getAllTags**](doc//TagsApi.md#getalltags) | **GET** /tags | Retrieve tags
-*TagsApi* | [**getTagById**](doc//TagsApi.md#gettagbyid) | **GET** /tags/{id} | Retrieve a tag
-*TagsApi* | [**tagAssets**](doc//TagsApi.md#tagassets) | **PUT** /tags/{id}/assets | Tag assets
-*TagsApi* | [**untagAssets**](doc//TagsApi.md#untagassets) | **DELETE** /tags/{id}/assets | Untag assets
-*TagsApi* | [**updateTag**](doc//TagsApi.md#updatetag) | **PUT** /tags/{id} | Update a tag
-*TagsApi* | [**upsertTags**](doc//TagsApi.md#upserttags) | **PUT** /tags | Upsert tags
-*TimelineApi* | [**getTimeBucket**](doc//TimelineApi.md#gettimebucket) | **GET** /timeline/bucket | Get time bucket
-*TimelineApi* | [**getTimeBuckets**](doc//TimelineApi.md#gettimebuckets) | **GET** /timeline/buckets | Get time buckets
-*TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty | Empty trash
-*TrashApi* | [**restoreAssets**](doc//TrashApi.md#restoreassets) | **POST** /trash/restore/assets | Restore assets
-*TrashApi* | [**restoreTrash**](doc//TrashApi.md#restoretrash) | **POST** /trash/restore | Restore trash
-*UsersApi* | [**createProfileImage**](doc//UsersApi.md#createprofileimage) | **POST** /users/profile-image | Create user profile image
-*UsersApi* | [**deleteProfileImage**](doc//UsersApi.md#deleteprofileimage) | **DELETE** /users/profile-image | Delete user profile image
-*UsersApi* | [**deleteUserLicense**](doc//UsersApi.md#deleteuserlicense) | **DELETE** /users/me/license | Delete user product key
-*UsersApi* | [**deleteUserOnboarding**](doc//UsersApi.md#deleteuseronboarding) | **DELETE** /users/me/onboarding | Delete user onboarding
-*UsersApi* | [**getMyPreferences**](doc//UsersApi.md#getmypreferences) | **GET** /users/me/preferences | Get my preferences
*UsersApi* | [**getMyUser**](doc//UsersApi.md#getmyuser) | **GET** /users/me | Get current user
-*UsersApi* | [**getProfileImage**](doc//UsersApi.md#getprofileimage) | **GET** /users/{id}/profile-image | Retrieve user profile image
*UsersApi* | [**getUser**](doc//UsersApi.md#getuser) | **GET** /users/{id} | Retrieve a user
-*UsersApi* | [**getUserLicense**](doc//UsersApi.md#getuserlicense) | **GET** /users/me/license | Retrieve user product key
-*UsersApi* | [**getUserOnboarding**](doc//UsersApi.md#getuseronboarding) | **GET** /users/me/onboarding | Retrieve user onboarding
*UsersApi* | [**searchUsers**](doc//UsersApi.md#searchusers) | **GET** /users | Get all users
-*UsersApi* | [**setUserLicense**](doc//UsersApi.md#setuserlicense) | **PUT** /users/me/license | Set user product key
-*UsersApi* | [**setUserOnboarding**](doc//UsersApi.md#setuseronboarding) | **PUT** /users/me/onboarding | Update user onboarding
-*UsersApi* | [**updateMyPreferences**](doc//UsersApi.md#updatemypreferences) | **PUT** /users/me/preferences | Update my preferences
*UsersApi* | [**updateMyUser**](doc//UsersApi.md#updatemyuser) | **PUT** /users/me | Update current user
-*UsersAdminApi* | [**createUserAdmin**](doc//UsersAdminApi.md#createuseradmin) | **POST** /admin/users | Create a user
-*UsersAdminApi* | [**deleteUserAdmin**](doc//UsersAdminApi.md#deleteuseradmin) | **DELETE** /admin/users/{id} | Delete a user
-*UsersAdminApi* | [**getUserAdmin**](doc//UsersAdminApi.md#getuseradmin) | **GET** /admin/users/{id} | Retrieve a user
-*UsersAdminApi* | [**getUserPreferencesAdmin**](doc//UsersAdminApi.md#getuserpreferencesadmin) | **GET** /admin/users/{id}/preferences | Retrieve user preferences
-*UsersAdminApi* | [**getUserSessionsAdmin**](doc//UsersAdminApi.md#getusersessionsadmin) | **GET** /admin/users/{id}/sessions | Retrieve user sessions
-*UsersAdminApi* | [**getUserStatisticsAdmin**](doc//UsersAdminApi.md#getuserstatisticsadmin) | **GET** /admin/users/{id}/statistics | Retrieve user statistics
-*UsersAdminApi* | [**restoreUserAdmin**](doc//UsersAdminApi.md#restoreuseradmin) | **POST** /admin/users/{id}/restore | Restore a deleted user
-*UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users | Search users
-*UsersAdminApi* | [**updateUserAdmin**](doc//UsersAdminApi.md#updateuseradmin) | **PUT** /admin/users/{id} | Update a user
-*UsersAdminApi* | [**updateUserPreferencesAdmin**](doc//UsersAdminApi.md#updateuserpreferencesadmin) | **PUT** /admin/users/{id}/preferences | Update user preferences
-*ViewsApi* | [**getAssetsByOriginalPath**](doc//ViewsApi.md#getassetsbyoriginalpath) | **GET** /view/folder | Retrieve assets by original path
-*ViewsApi* | [**getUniqueOriginalPaths**](doc//ViewsApi.md#getuniqueoriginalpaths) | **GET** /view/folder/unique-paths | Retrieve unique paths
-*WorkflowsApi* | [**createWorkflow**](doc//WorkflowsApi.md#createworkflow) | **POST** /workflows | Create a workflow
-*WorkflowsApi* | [**deleteWorkflow**](doc//WorkflowsApi.md#deleteworkflow) | **DELETE** /workflows/{id} | Delete a workflow
-*WorkflowsApi* | [**getWorkflow**](doc//WorkflowsApi.md#getworkflow) | **GET** /workflows/{id} | Retrieve a workflow
-*WorkflowsApi* | [**getWorkflows**](doc//WorkflowsApi.md#getworkflows) | **GET** /workflows | List all workflows
-*WorkflowsApi* | [**updateWorkflow**](doc//WorkflowsApi.md#updateworkflow) | **PUT** /workflows/{id} | Update a workflow
## Documentation For Models
@@ -332,351 +105,25 @@ Class | Method | HTTP request | Description
- [APIKeyCreateResponseDto](doc//APIKeyCreateResponseDto.md)
- [APIKeyResponseDto](doc//APIKeyResponseDto.md)
- [APIKeyUpdateDto](doc//APIKeyUpdateDto.md)
- - [ActivityCreateDto](doc//ActivityCreateDto.md)
- - [ActivityResponseDto](doc//ActivityResponseDto.md)
- - [ActivityStatisticsResponseDto](doc//ActivityStatisticsResponseDto.md)
- - [AddUsersDto](doc//AddUsersDto.md)
- - [AdminOnboardingUpdateDto](doc//AdminOnboardingUpdateDto.md)
- - [AlbumResponseDto](doc//AlbumResponseDto.md)
- - [AlbumStatisticsResponseDto](doc//AlbumStatisticsResponseDto.md)
- - [AlbumUserAddDto](doc//AlbumUserAddDto.md)
- - [AlbumUserCreateDto](doc//AlbumUserCreateDto.md)
- - [AlbumUserResponseDto](doc//AlbumUserResponseDto.md)
- - [AlbumUserRole](doc//AlbumUserRole.md)
- - [AlbumsAddAssetsDto](doc//AlbumsAddAssetsDto.md)
- - [AlbumsAddAssetsResponseDto](doc//AlbumsAddAssetsResponseDto.md)
- - [AlbumsResponse](doc//AlbumsResponse.md)
- - [AlbumsUpdate](doc//AlbumsUpdate.md)
- - [AssetBulkDeleteDto](doc//AssetBulkDeleteDto.md)
- - [AssetBulkUpdateDto](doc//AssetBulkUpdateDto.md)
- - [AssetBulkUploadCheckDto](doc//AssetBulkUploadCheckDto.md)
- - [AssetBulkUploadCheckItem](doc//AssetBulkUploadCheckItem.md)
- - [AssetBulkUploadCheckResponseDto](doc//AssetBulkUploadCheckResponseDto.md)
- - [AssetBulkUploadCheckResult](doc//AssetBulkUploadCheckResult.md)
- - [AssetCopyDto](doc//AssetCopyDto.md)
- - [AssetDeltaSyncDto](doc//AssetDeltaSyncDto.md)
- - [AssetDeltaSyncResponseDto](doc//AssetDeltaSyncResponseDto.md)
- - [AssetEditAction](doc//AssetEditAction.md)
- - [AssetEditActionCrop](doc//AssetEditActionCrop.md)
- - [AssetEditActionListDto](doc//AssetEditActionListDto.md)
- - [AssetEditActionListDtoEditsInner](doc//AssetEditActionListDtoEditsInner.md)
- - [AssetEditActionMirror](doc//AssetEditActionMirror.md)
- - [AssetEditActionRotate](doc//AssetEditActionRotate.md)
- - [AssetEditsDto](doc//AssetEditsDto.md)
- - [AssetFaceCreateDto](doc//AssetFaceCreateDto.md)
- - [AssetFaceDeleteDto](doc//AssetFaceDeleteDto.md)
- - [AssetFaceResponseDto](doc//AssetFaceResponseDto.md)
- - [AssetFaceUpdateDto](doc//AssetFaceUpdateDto.md)
- - [AssetFaceUpdateItem](doc//AssetFaceUpdateItem.md)
- - [AssetFaceWithoutPersonResponseDto](doc//AssetFaceWithoutPersonResponseDto.md)
- - [AssetFullSyncDto](doc//AssetFullSyncDto.md)
- - [AssetIdsDto](doc//AssetIdsDto.md)
- - [AssetIdsResponseDto](doc//AssetIdsResponseDto.md)
- - [AssetJobName](doc//AssetJobName.md)
- - [AssetJobsDto](doc//AssetJobsDto.md)
- - [AssetMediaResponseDto](doc//AssetMediaResponseDto.md)
- - [AssetMediaSize](doc//AssetMediaSize.md)
- - [AssetMediaStatus](doc//AssetMediaStatus.md)
- - [AssetMetadataBulkDeleteDto](doc//AssetMetadataBulkDeleteDto.md)
- - [AssetMetadataBulkDeleteItemDto](doc//AssetMetadataBulkDeleteItemDto.md)
- - [AssetMetadataBulkResponseDto](doc//AssetMetadataBulkResponseDto.md)
- - [AssetMetadataBulkUpsertDto](doc//AssetMetadataBulkUpsertDto.md)
- - [AssetMetadataBulkUpsertItemDto](doc//AssetMetadataBulkUpsertItemDto.md)
- - [AssetMetadataResponseDto](doc//AssetMetadataResponseDto.md)
- - [AssetMetadataUpsertDto](doc//AssetMetadataUpsertDto.md)
- - [AssetMetadataUpsertItemDto](doc//AssetMetadataUpsertItemDto.md)
- - [AssetOcrResponseDto](doc//AssetOcrResponseDto.md)
- - [AssetOrder](doc//AssetOrder.md)
- - [AssetResponseDto](doc//AssetResponseDto.md)
- - [AssetStackResponseDto](doc//AssetStackResponseDto.md)
- - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md)
- - [AssetTypeEnum](doc//AssetTypeEnum.md)
- - [AssetVisibility](doc//AssetVisibility.md)
- - [AudioCodec](doc//AudioCodec.md)
- - [AuthStatusResponseDto](doc//AuthStatusResponseDto.md)
- - [AvatarUpdate](doc//AvatarUpdate.md)
- - [BulkIdErrorReason](doc//BulkIdErrorReason.md)
- - [BulkIdResponseDto](doc//BulkIdResponseDto.md)
- - [BulkIdsDto](doc//BulkIdsDto.md)
- - [CLIPConfig](doc//CLIPConfig.md)
- - [CQMode](doc//CQMode.md)
- - [CastResponse](doc//CastResponse.md)
- - [CastUpdate](doc//CastUpdate.md)
- [ChangePasswordDto](doc//ChangePasswordDto.md)
- - [CheckExistingAssetsDto](doc//CheckExistingAssetsDto.md)
- - [CheckExistingAssetsResponseDto](doc//CheckExistingAssetsResponseDto.md)
- - [Colorspace](doc//Colorspace.md)
- - [ContributorCountResponseDto](doc//ContributorCountResponseDto.md)
- - [CreateAlbumDto](doc//CreateAlbumDto.md)
- - [CreateLibraryDto](doc//CreateLibraryDto.md)
- - [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
- - [CropParameters](doc//CropParameters.md)
- - [DatabaseBackupConfig](doc//DatabaseBackupConfig.md)
- - [DatabaseBackupDeleteDto](doc//DatabaseBackupDeleteDto.md)
- - [DatabaseBackupDto](doc//DatabaseBackupDto.md)
- - [DatabaseBackupListResponseDto](doc//DatabaseBackupListResponseDto.md)
- - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
- - [DownloadInfoDto](doc//DownloadInfoDto.md)
- - [DownloadResponse](doc//DownloadResponse.md)
- - [DownloadResponseDto](doc//DownloadResponseDto.md)
- - [DownloadUpdate](doc//DownloadUpdate.md)
- - [DuplicateDetectionConfig](doc//DuplicateDetectionConfig.md)
- - [DuplicateResponseDto](doc//DuplicateResponseDto.md)
- - [EmailNotificationsResponse](doc//EmailNotificationsResponse.md)
- - [EmailNotificationsUpdate](doc//EmailNotificationsUpdate.md)
- - [ExifResponseDto](doc//ExifResponseDto.md)
- - [FaceDto](doc//FaceDto.md)
- - [FacialRecognitionConfig](doc//FacialRecognitionConfig.md)
- - [FoldersResponse](doc//FoldersResponse.md)
- - [FoldersUpdate](doc//FoldersUpdate.md)
- - [ImageFormat](doc//ImageFormat.md)
- - [JobCreateDto](doc//JobCreateDto.md)
- - [JobName](doc//JobName.md)
- - [JobSettingsDto](doc//JobSettingsDto.md)
- - [LibraryResponseDto](doc//LibraryResponseDto.md)
- - [LibraryStatsResponseDto](doc//LibraryStatsResponseDto.md)
- - [LicenseKeyDto](doc//LicenseKeyDto.md)
- - [LicenseResponseDto](doc//LicenseResponseDto.md)
- - [LogLevel](doc//LogLevel.md)
- [LoginCredentialDto](doc//LoginCredentialDto.md)
- [LoginResponseDto](doc//LoginResponseDto.md)
- [LogoutResponseDto](doc//LogoutResponseDto.md)
- - [MachineLearningAvailabilityChecksDto](doc//MachineLearningAvailabilityChecksDto.md)
- - [MaintenanceAction](doc//MaintenanceAction.md)
- - [MaintenanceAuthDto](doc//MaintenanceAuthDto.md)
- - [MaintenanceDetectInstallResponseDto](doc//MaintenanceDetectInstallResponseDto.md)
- - [MaintenanceDetectInstallStorageFolderDto](doc//MaintenanceDetectInstallStorageFolderDto.md)
- - [MaintenanceLoginDto](doc//MaintenanceLoginDto.md)
- - [MaintenanceStatusResponseDto](doc//MaintenanceStatusResponseDto.md)
- - [ManualJobName](doc//ManualJobName.md)
- - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
- - [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md)
- - [MemoriesResponse](doc//MemoriesResponse.md)
- - [MemoriesUpdate](doc//MemoriesUpdate.md)
- - [MemoryCreateDto](doc//MemoryCreateDto.md)
- - [MemoryResponseDto](doc//MemoryResponseDto.md)
- - [MemorySearchOrder](doc//MemorySearchOrder.md)
- - [MemoryStatisticsResponseDto](doc//MemoryStatisticsResponseDto.md)
- - [MemoryType](doc//MemoryType.md)
- - [MemoryUpdateDto](doc//MemoryUpdateDto.md)
- - [MergePersonDto](doc//MergePersonDto.md)
- - [MetadataSearchDto](doc//MetadataSearchDto.md)
- - [MirrorAxis](doc//MirrorAxis.md)
- - [MirrorParameters](doc//MirrorParameters.md)
- - [NotificationCreateDto](doc//NotificationCreateDto.md)
- - [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.md)
- - [NotificationDto](doc//NotificationDto.md)
- - [NotificationLevel](doc//NotificationLevel.md)
- - [NotificationType](doc//NotificationType.md)
- - [NotificationUpdateAllDto](doc//NotificationUpdateAllDto.md)
- - [NotificationUpdateDto](doc//NotificationUpdateDto.md)
- - [OAuthAuthorizeResponseDto](doc//OAuthAuthorizeResponseDto.md)
- - [OAuthCallbackDto](doc//OAuthCallbackDto.md)
- - [OAuthConfigDto](doc//OAuthConfigDto.md)
- - [OAuthTokenEndpointAuthMethod](doc//OAuthTokenEndpointAuthMethod.md)
- - [OcrConfig](doc//OcrConfig.md)
- - [OnThisDayDto](doc//OnThisDayDto.md)
- - [OnboardingDto](doc//OnboardingDto.md)
- - [OnboardingResponseDto](doc//OnboardingResponseDto.md)
- - [PartnerCreateDto](doc//PartnerCreateDto.md)
- - [PartnerDirection](doc//PartnerDirection.md)
- - [PartnerResponseDto](doc//PartnerResponseDto.md)
- - [PartnerUpdateDto](doc//PartnerUpdateDto.md)
- - [PeopleResponse](doc//PeopleResponse.md)
- - [PeopleResponseDto](doc//PeopleResponseDto.md)
- - [PeopleUpdate](doc//PeopleUpdate.md)
- - [PeopleUpdateDto](doc//PeopleUpdateDto.md)
- - [PeopleUpdateItem](doc//PeopleUpdateItem.md)
- [Permission](doc//Permission.md)
- - [PersonCreateDto](doc//PersonCreateDto.md)
- - [PersonResponseDto](doc//PersonResponseDto.md)
- - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
- - [PersonUpdateDto](doc//PersonUpdateDto.md)
- - [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md)
- - [PinCodeChangeDto](doc//PinCodeChangeDto.md)
- - [PinCodeResetDto](doc//PinCodeResetDto.md)
- - [PinCodeSetupDto](doc//PinCodeSetupDto.md)
- - [PlacesResponseDto](doc//PlacesResponseDto.md)
- - [PluginActionResponseDto](doc//PluginActionResponseDto.md)
- - [PluginContextType](doc//PluginContextType.md)
- - [PluginFilterResponseDto](doc//PluginFilterResponseDto.md)
- - [PluginResponseDto](doc//PluginResponseDto.md)
- - [PluginTriggerResponseDto](doc//PluginTriggerResponseDto.md)
- - [PluginTriggerType](doc//PluginTriggerType.md)
- - [PurchaseResponse](doc//PurchaseResponse.md)
- - [PurchaseUpdate](doc//PurchaseUpdate.md)
- - [QueueCommand](doc//QueueCommand.md)
- - [QueueCommandDto](doc//QueueCommandDto.md)
- - [QueueDeleteDto](doc//QueueDeleteDto.md)
- - [QueueJobResponseDto](doc//QueueJobResponseDto.md)
- - [QueueJobStatus](doc//QueueJobStatus.md)
- - [QueueName](doc//QueueName.md)
- - [QueueResponseDto](doc//QueueResponseDto.md)
- - [QueueResponseLegacyDto](doc//QueueResponseLegacyDto.md)
- - [QueueStatisticsDto](doc//QueueStatisticsDto.md)
- - [QueueStatusLegacyDto](doc//QueueStatusLegacyDto.md)
- - [QueueUpdateDto](doc//QueueUpdateDto.md)
- - [QueuesResponseLegacyDto](doc//QueuesResponseLegacyDto.md)
- - [RandomSearchDto](doc//RandomSearchDto.md)
- - [RatingsResponse](doc//RatingsResponse.md)
- - [RatingsUpdate](doc//RatingsUpdate.md)
- - [ReactionLevel](doc//ReactionLevel.md)
- - [ReactionType](doc//ReactionType.md)
- - [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md)
- - [RotateParameters](doc//RotateParameters.md)
- - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
- - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md)
- - [SearchExploreItem](doc//SearchExploreItem.md)
- - [SearchExploreResponseDto](doc//SearchExploreResponseDto.md)
- - [SearchFacetCountResponseDto](doc//SearchFacetCountResponseDto.md)
- - [SearchFacetResponseDto](doc//SearchFacetResponseDto.md)
- - [SearchResponseDto](doc//SearchResponseDto.md)
- - [SearchStatisticsResponseDto](doc//SearchStatisticsResponseDto.md)
- - [SearchSuggestionType](doc//SearchSuggestionType.md)
- [ServerAboutResponseDto](doc//ServerAboutResponseDto.md)
- - [ServerApkLinksDto](doc//ServerApkLinksDto.md)
- [ServerConfigDto](doc//ServerConfigDto.md)
- [ServerFeaturesDto](doc//ServerFeaturesDto.md)
- - [ServerMediaTypesResponseDto](doc//ServerMediaTypesResponseDto.md)
- [ServerPingResponse](doc//ServerPingResponse.md)
- - [ServerStatsResponseDto](doc//ServerStatsResponseDto.md)
- - [ServerStorageResponseDto](doc//ServerStorageResponseDto.md)
- - [ServerThemeDto](doc//ServerThemeDto.md)
- - [ServerVersionHistoryResponseDto](doc//ServerVersionHistoryResponseDto.md)
- [ServerVersionResponseDto](doc//ServerVersionResponseDto.md)
- [SessionCreateDto](doc//SessionCreateDto.md)
- [SessionCreateResponseDto](doc//SessionCreateResponseDto.md)
- [SessionResponseDto](doc//SessionResponseDto.md)
- - [SessionUnlockDto](doc//SessionUnlockDto.md)
- - [SessionUpdateDto](doc//SessionUpdateDto.md)
- - [SetMaintenanceModeDto](doc//SetMaintenanceModeDto.md)
- - [SharedLinkCreateDto](doc//SharedLinkCreateDto.md)
- - [SharedLinkEditDto](doc//SharedLinkEditDto.md)
- - [SharedLinkResponseDto](doc//SharedLinkResponseDto.md)
- - [SharedLinkType](doc//SharedLinkType.md)
- - [SharedLinksResponse](doc//SharedLinksResponse.md)
- - [SharedLinksUpdate](doc//SharedLinksUpdate.md)
- [SignUpDto](doc//SignUpDto.md)
- - [SmartSearchDto](doc//SmartSearchDto.md)
- - [SourceType](doc//SourceType.md)
- - [StackCreateDto](doc//StackCreateDto.md)
- - [StackResponseDto](doc//StackResponseDto.md)
- - [StackUpdateDto](doc//StackUpdateDto.md)
- - [StatisticsSearchDto](doc//StatisticsSearchDto.md)
- - [StorageFolder](doc//StorageFolder.md)
- - [SyncAckDeleteDto](doc//SyncAckDeleteDto.md)
- - [SyncAckDto](doc//SyncAckDto.md)
- - [SyncAckSetDto](doc//SyncAckSetDto.md)
- - [SyncAlbumDeleteV1](doc//SyncAlbumDeleteV1.md)
- - [SyncAlbumToAssetDeleteV1](doc//SyncAlbumToAssetDeleteV1.md)
- - [SyncAlbumToAssetV1](doc//SyncAlbumToAssetV1.md)
- - [SyncAlbumUserDeleteV1](doc//SyncAlbumUserDeleteV1.md)
- - [SyncAlbumUserV1](doc//SyncAlbumUserV1.md)
- - [SyncAlbumV1](doc//SyncAlbumV1.md)
- - [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md)
- - [SyncAssetExifV1](doc//SyncAssetExifV1.md)
- - [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md)
- - [SyncAssetFaceV1](doc//SyncAssetFaceV1.md)
- - [SyncAssetMetadataDeleteV1](doc//SyncAssetMetadataDeleteV1.md)
- - [SyncAssetMetadataV1](doc//SyncAssetMetadataV1.md)
- - [SyncAssetV1](doc//SyncAssetV1.md)
- - [SyncAuthUserV1](doc//SyncAuthUserV1.md)
- - [SyncEntityType](doc//SyncEntityType.md)
- - [SyncMemoryAssetDeleteV1](doc//SyncMemoryAssetDeleteV1.md)
- - [SyncMemoryAssetV1](doc//SyncMemoryAssetV1.md)
- - [SyncMemoryDeleteV1](doc//SyncMemoryDeleteV1.md)
- - [SyncMemoryV1](doc//SyncMemoryV1.md)
- - [SyncPartnerDeleteV1](doc//SyncPartnerDeleteV1.md)
- - [SyncPartnerV1](doc//SyncPartnerV1.md)
- - [SyncPersonDeleteV1](doc//SyncPersonDeleteV1.md)
- - [SyncPersonV1](doc//SyncPersonV1.md)
- - [SyncRequestType](doc//SyncRequestType.md)
- - [SyncStackDeleteV1](doc//SyncStackDeleteV1.md)
- - [SyncStackV1](doc//SyncStackV1.md)
- - [SyncStreamDto](doc//SyncStreamDto.md)
- - [SyncUserDeleteV1](doc//SyncUserDeleteV1.md)
- - [SyncUserMetadataDeleteV1](doc//SyncUserMetadataDeleteV1.md)
- - [SyncUserMetadataV1](doc//SyncUserMetadataV1.md)
- - [SyncUserV1](doc//SyncUserV1.md)
- - [SystemConfigBackupsDto](doc//SystemConfigBackupsDto.md)
- - [SystemConfigDto](doc//SystemConfigDto.md)
- - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md)
- - [SystemConfigFacesDto](doc//SystemConfigFacesDto.md)
- - [SystemConfigGeneratedFullsizeImageDto](doc//SystemConfigGeneratedFullsizeImageDto.md)
- - [SystemConfigGeneratedImageDto](doc//SystemConfigGeneratedImageDto.md)
- - [SystemConfigImageDto](doc//SystemConfigImageDto.md)
- - [SystemConfigJobDto](doc//SystemConfigJobDto.md)
- - [SystemConfigLibraryDto](doc//SystemConfigLibraryDto.md)
- - [SystemConfigLibraryScanDto](doc//SystemConfigLibraryScanDto.md)
- - [SystemConfigLibraryWatchDto](doc//SystemConfigLibraryWatchDto.md)
- - [SystemConfigLoggingDto](doc//SystemConfigLoggingDto.md)
- - [SystemConfigMachineLearningDto](doc//SystemConfigMachineLearningDto.md)
- - [SystemConfigMapDto](doc//SystemConfigMapDto.md)
- - [SystemConfigMetadataDto](doc//SystemConfigMetadataDto.md)
- - [SystemConfigNewVersionCheckDto](doc//SystemConfigNewVersionCheckDto.md)
- - [SystemConfigNightlyTasksDto](doc//SystemConfigNightlyTasksDto.md)
- - [SystemConfigNotificationsDto](doc//SystemConfigNotificationsDto.md)
- - [SystemConfigOAuthDto](doc//SystemConfigOAuthDto.md)
- - [SystemConfigPasswordLoginDto](doc//SystemConfigPasswordLoginDto.md)
- - [SystemConfigReverseGeocodingDto](doc//SystemConfigReverseGeocodingDto.md)
- - [SystemConfigServerDto](doc//SystemConfigServerDto.md)
- - [SystemConfigSmtpDto](doc//SystemConfigSmtpDto.md)
- - [SystemConfigSmtpTransportDto](doc//SystemConfigSmtpTransportDto.md)
- - [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md)
- - [SystemConfigTemplateEmailsDto](doc//SystemConfigTemplateEmailsDto.md)
- - [SystemConfigTemplateStorageOptionDto](doc//SystemConfigTemplateStorageOptionDto.md)
- - [SystemConfigTemplatesDto](doc//SystemConfigTemplatesDto.md)
- - [SystemConfigThemeDto](doc//SystemConfigThemeDto.md)
- - [SystemConfigTrashDto](doc//SystemConfigTrashDto.md)
- - [SystemConfigUserDto](doc//SystemConfigUserDto.md)
- - [TagBulkAssetsDto](doc//TagBulkAssetsDto.md)
- - [TagBulkAssetsResponseDto](doc//TagBulkAssetsResponseDto.md)
- - [TagCreateDto](doc//TagCreateDto.md)
- - [TagResponseDto](doc//TagResponseDto.md)
- - [TagUpdateDto](doc//TagUpdateDto.md)
- - [TagUpsertDto](doc//TagUpsertDto.md)
- - [TagsResponse](doc//TagsResponse.md)
- - [TagsUpdate](doc//TagsUpdate.md)
- - [TemplateDto](doc//TemplateDto.md)
- - [TemplateResponseDto](doc//TemplateResponseDto.md)
- - [TestEmailResponseDto](doc//TestEmailResponseDto.md)
- - [TimeBucketAssetResponseDto](doc//TimeBucketAssetResponseDto.md)
- - [TimeBucketsResponseDto](doc//TimeBucketsResponseDto.md)
- - [ToneMapping](doc//ToneMapping.md)
- - [TranscodeHWAccel](doc//TranscodeHWAccel.md)
- - [TranscodePolicy](doc//TranscodePolicy.md)
- - [TrashResponseDto](doc//TrashResponseDto.md)
- - [UpdateAlbumDto](doc//UpdateAlbumDto.md)
- - [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md)
- - [UpdateAssetDto](doc//UpdateAssetDto.md)
- - [UpdateLibraryDto](doc//UpdateLibraryDto.md)
- - [UsageByUserDto](doc//UsageByUserDto.md)
- - [UserAdminCreateDto](doc//UserAdminCreateDto.md)
- - [UserAdminDeleteDto](doc//UserAdminDeleteDto.md)
- [UserAdminResponseDto](doc//UserAdminResponseDto.md)
- - [UserAdminUpdateDto](doc//UserAdminUpdateDto.md)
- - [UserAvatarColor](doc//UserAvatarColor.md)
- - [UserLicense](doc//UserLicense.md)
- - [UserMetadataKey](doc//UserMetadataKey.md)
- - [UserPreferencesResponseDto](doc//UserPreferencesResponseDto.md)
- - [UserPreferencesUpdateDto](doc//UserPreferencesUpdateDto.md)
- [UserResponseDto](doc//UserResponseDto.md)
- [UserStatus](doc//UserStatus.md)
- [UserUpdateMeDto](doc//UserUpdateMeDto.md)
- [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md)
- - [ValidateLibraryDto](doc//ValidateLibraryDto.md)
- - [ValidateLibraryImportPathResponseDto](doc//ValidateLibraryImportPathResponseDto.md)
- - [ValidateLibraryResponseDto](doc//ValidateLibraryResponseDto.md)
- - [VersionCheckStateResponseDto](doc//VersionCheckStateResponseDto.md)
- - [VideoCodec](doc//VideoCodec.md)
- - [VideoContainer](doc//VideoContainer.md)
- - [WorkflowActionItemDto](doc//WorkflowActionItemDto.md)
- - [WorkflowActionResponseDto](doc//WorkflowActionResponseDto.md)
- - [WorkflowCreateDto](doc//WorkflowCreateDto.md)
- - [WorkflowFilterItemDto](doc//WorkflowFilterItemDto.md)
- - [WorkflowFilterResponseDto](doc//WorkflowFilterResponseDto.md)
- - [WorkflowResponseDto](doc//WorkflowResponseDto.md)
- - [WorkflowUpdateDto](doc//WorkflowUpdateDto.md)
## Documentation For Authorization
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 90e426b547..39fd4e789a 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -31,392 +31,34 @@ part 'auth/http_basic_auth.dart';
part 'auth/http_bearer_auth.dart';
part 'api/api_keys_api.dart';
-part 'api/activities_api.dart';
-part 'api/albums_api.dart';
-part 'api/assets_api.dart';
part 'api/authentication_api.dart';
-part 'api/authentication_admin_api.dart';
-part 'api/database_backups_admin_api.dart';
-part 'api/deprecated_api.dart';
-part 'api/download_api.dart';
-part 'api/duplicates_api.dart';
-part 'api/faces_api.dart';
-part 'api/jobs_api.dart';
-part 'api/libraries_api.dart';
-part 'api/maintenance_admin_api.dart';
-part 'api/map_api.dart';
-part 'api/memories_api.dart';
-part 'api/notifications_api.dart';
-part 'api/notifications_admin_api.dart';
-part 'api/partners_api.dart';
-part 'api/people_api.dart';
-part 'api/plugins_api.dart';
-part 'api/queues_api.dart';
-part 'api/search_api.dart';
part 'api/server_api.dart';
part 'api/sessions_api.dart';
-part 'api/shared_links_api.dart';
-part 'api/stacks_api.dart';
-part 'api/sync_api.dart';
-part 'api/system_config_api.dart';
-part 'api/system_metadata_api.dart';
-part 'api/tags_api.dart';
-part 'api/timeline_api.dart';
-part 'api/trash_api.dart';
part 'api/users_api.dart';
-part 'api/users_admin_api.dart';
-part 'api/views_api.dart';
-part 'api/workflows_api.dart';
part 'model/api_key_create_dto.dart';
part 'model/api_key_create_response_dto.dart';
part 'model/api_key_response_dto.dart';
part 'model/api_key_update_dto.dart';
-part 'model/activity_create_dto.dart';
-part 'model/activity_response_dto.dart';
-part 'model/activity_statistics_response_dto.dart';
-part 'model/add_users_dto.dart';
-part 'model/admin_onboarding_update_dto.dart';
-part 'model/album_response_dto.dart';
-part 'model/album_statistics_response_dto.dart';
-part 'model/album_user_add_dto.dart';
-part 'model/album_user_create_dto.dart';
-part 'model/album_user_response_dto.dart';
-part 'model/album_user_role.dart';
-part 'model/albums_add_assets_dto.dart';
-part 'model/albums_add_assets_response_dto.dart';
-part 'model/albums_response.dart';
-part 'model/albums_update.dart';
-part 'model/asset_bulk_delete_dto.dart';
-part 'model/asset_bulk_update_dto.dart';
-part 'model/asset_bulk_upload_check_dto.dart';
-part 'model/asset_bulk_upload_check_item.dart';
-part 'model/asset_bulk_upload_check_response_dto.dart';
-part 'model/asset_bulk_upload_check_result.dart';
-part 'model/asset_copy_dto.dart';
-part 'model/asset_delta_sync_dto.dart';
-part 'model/asset_delta_sync_response_dto.dart';
-part 'model/asset_edit_action.dart';
-part 'model/asset_edit_action_crop.dart';
-part 'model/asset_edit_action_list_dto.dart';
-part 'model/asset_edit_action_list_dto_edits_inner.dart';
-part 'model/asset_edit_action_mirror.dart';
-part 'model/asset_edit_action_rotate.dart';
-part 'model/asset_edits_dto.dart';
-part 'model/asset_face_create_dto.dart';
-part 'model/asset_face_delete_dto.dart';
-part 'model/asset_face_response_dto.dart';
-part 'model/asset_face_update_dto.dart';
-part 'model/asset_face_update_item.dart';
-part 'model/asset_face_without_person_response_dto.dart';
-part 'model/asset_full_sync_dto.dart';
-part 'model/asset_ids_dto.dart';
-part 'model/asset_ids_response_dto.dart';
-part 'model/asset_job_name.dart';
-part 'model/asset_jobs_dto.dart';
-part 'model/asset_media_response_dto.dart';
-part 'model/asset_media_size.dart';
-part 'model/asset_media_status.dart';
-part 'model/asset_metadata_bulk_delete_dto.dart';
-part 'model/asset_metadata_bulk_delete_item_dto.dart';
-part 'model/asset_metadata_bulk_response_dto.dart';
-part 'model/asset_metadata_bulk_upsert_dto.dart';
-part 'model/asset_metadata_bulk_upsert_item_dto.dart';
-part 'model/asset_metadata_response_dto.dart';
-part 'model/asset_metadata_upsert_dto.dart';
-part 'model/asset_metadata_upsert_item_dto.dart';
-part 'model/asset_ocr_response_dto.dart';
-part 'model/asset_order.dart';
-part 'model/asset_response_dto.dart';
-part 'model/asset_stack_response_dto.dart';
-part 'model/asset_stats_response_dto.dart';
-part 'model/asset_type_enum.dart';
-part 'model/asset_visibility.dart';
-part 'model/audio_codec.dart';
-part 'model/auth_status_response_dto.dart';
-part 'model/avatar_update.dart';
-part 'model/bulk_id_error_reason.dart';
-part 'model/bulk_id_response_dto.dart';
-part 'model/bulk_ids_dto.dart';
-part 'model/clip_config.dart';
-part 'model/cq_mode.dart';
-part 'model/cast_response.dart';
-part 'model/cast_update.dart';
part 'model/change_password_dto.dart';
-part 'model/check_existing_assets_dto.dart';
-part 'model/check_existing_assets_response_dto.dart';
-part 'model/colorspace.dart';
-part 'model/contributor_count_response_dto.dart';
-part 'model/create_album_dto.dart';
-part 'model/create_library_dto.dart';
-part 'model/create_profile_image_response_dto.dart';
-part 'model/crop_parameters.dart';
-part 'model/database_backup_config.dart';
-part 'model/database_backup_delete_dto.dart';
-part 'model/database_backup_dto.dart';
-part 'model/database_backup_list_response_dto.dart';
-part 'model/download_archive_info.dart';
-part 'model/download_info_dto.dart';
-part 'model/download_response.dart';
-part 'model/download_response_dto.dart';
-part 'model/download_update.dart';
-part 'model/duplicate_detection_config.dart';
-part 'model/duplicate_response_dto.dart';
-part 'model/email_notifications_response.dart';
-part 'model/email_notifications_update.dart';
-part 'model/exif_response_dto.dart';
-part 'model/face_dto.dart';
-part 'model/facial_recognition_config.dart';
-part 'model/folders_response.dart';
-part 'model/folders_update.dart';
-part 'model/image_format.dart';
-part 'model/job_create_dto.dart';
-part 'model/job_name.dart';
-part 'model/job_settings_dto.dart';
-part 'model/library_response_dto.dart';
-part 'model/library_stats_response_dto.dart';
-part 'model/license_key_dto.dart';
-part 'model/license_response_dto.dart';
-part 'model/log_level.dart';
part 'model/login_credential_dto.dart';
part 'model/login_response_dto.dart';
part 'model/logout_response_dto.dart';
-part 'model/machine_learning_availability_checks_dto.dart';
-part 'model/maintenance_action.dart';
-part 'model/maintenance_auth_dto.dart';
-part 'model/maintenance_detect_install_response_dto.dart';
-part 'model/maintenance_detect_install_storage_folder_dto.dart';
-part 'model/maintenance_login_dto.dart';
-part 'model/maintenance_status_response_dto.dart';
-part 'model/manual_job_name.dart';
-part 'model/map_marker_response_dto.dart';
-part 'model/map_reverse_geocode_response_dto.dart';
-part 'model/memories_response.dart';
-part 'model/memories_update.dart';
-part 'model/memory_create_dto.dart';
-part 'model/memory_response_dto.dart';
-part 'model/memory_search_order.dart';
-part 'model/memory_statistics_response_dto.dart';
-part 'model/memory_type.dart';
-part 'model/memory_update_dto.dart';
-part 'model/merge_person_dto.dart';
-part 'model/metadata_search_dto.dart';
-part 'model/mirror_axis.dart';
-part 'model/mirror_parameters.dart';
-part 'model/notification_create_dto.dart';
-part 'model/notification_delete_all_dto.dart';
-part 'model/notification_dto.dart';
-part 'model/notification_level.dart';
-part 'model/notification_type.dart';
-part 'model/notification_update_all_dto.dart';
-part 'model/notification_update_dto.dart';
-part 'model/o_auth_authorize_response_dto.dart';
-part 'model/o_auth_callback_dto.dart';
-part 'model/o_auth_config_dto.dart';
-part 'model/o_auth_token_endpoint_auth_method.dart';
-part 'model/ocr_config.dart';
-part 'model/on_this_day_dto.dart';
-part 'model/onboarding_dto.dart';
-part 'model/onboarding_response_dto.dart';
-part 'model/partner_create_dto.dart';
-part 'model/partner_direction.dart';
-part 'model/partner_response_dto.dart';
-part 'model/partner_update_dto.dart';
-part 'model/people_response.dart';
-part 'model/people_response_dto.dart';
-part 'model/people_update.dart';
-part 'model/people_update_dto.dart';
-part 'model/people_update_item.dart';
part 'model/permission.dart';
-part 'model/person_create_dto.dart';
-part 'model/person_response_dto.dart';
-part 'model/person_statistics_response_dto.dart';
-part 'model/person_update_dto.dart';
-part 'model/person_with_faces_response_dto.dart';
-part 'model/pin_code_change_dto.dart';
-part 'model/pin_code_reset_dto.dart';
-part 'model/pin_code_setup_dto.dart';
-part 'model/places_response_dto.dart';
-part 'model/plugin_action_response_dto.dart';
-part 'model/plugin_context_type.dart';
-part 'model/plugin_filter_response_dto.dart';
-part 'model/plugin_response_dto.dart';
-part 'model/plugin_trigger_response_dto.dart';
-part 'model/plugin_trigger_type.dart';
-part 'model/purchase_response.dart';
-part 'model/purchase_update.dart';
-part 'model/queue_command.dart';
-part 'model/queue_command_dto.dart';
-part 'model/queue_delete_dto.dart';
-part 'model/queue_job_response_dto.dart';
-part 'model/queue_job_status.dart';
-part 'model/queue_name.dart';
-part 'model/queue_response_dto.dart';
-part 'model/queue_response_legacy_dto.dart';
-part 'model/queue_statistics_dto.dart';
-part 'model/queue_status_legacy_dto.dart';
-part 'model/queue_update_dto.dart';
-part 'model/queues_response_legacy_dto.dart';
-part 'model/random_search_dto.dart';
-part 'model/ratings_response.dart';
-part 'model/ratings_update.dart';
-part 'model/reaction_level.dart';
-part 'model/reaction_type.dart';
-part 'model/reverse_geocoding_state_response_dto.dart';
-part 'model/rotate_parameters.dart';
-part 'model/search_album_response_dto.dart';
-part 'model/search_asset_response_dto.dart';
-part 'model/search_explore_item.dart';
-part 'model/search_explore_response_dto.dart';
-part 'model/search_facet_count_response_dto.dart';
-part 'model/search_facet_response_dto.dart';
-part 'model/search_response_dto.dart';
-part 'model/search_statistics_response_dto.dart';
-part 'model/search_suggestion_type.dart';
part 'model/server_about_response_dto.dart';
-part 'model/server_apk_links_dto.dart';
part 'model/server_config_dto.dart';
part 'model/server_features_dto.dart';
-part 'model/server_media_types_response_dto.dart';
part 'model/server_ping_response.dart';
-part 'model/server_stats_response_dto.dart';
-part 'model/server_storage_response_dto.dart';
-part 'model/server_theme_dto.dart';
-part 'model/server_version_history_response_dto.dart';
part 'model/server_version_response_dto.dart';
part 'model/session_create_dto.dart';
part 'model/session_create_response_dto.dart';
part 'model/session_response_dto.dart';
-part 'model/session_unlock_dto.dart';
-part 'model/session_update_dto.dart';
-part 'model/set_maintenance_mode_dto.dart';
-part 'model/shared_link_create_dto.dart';
-part 'model/shared_link_edit_dto.dart';
-part 'model/shared_link_response_dto.dart';
-part 'model/shared_link_type.dart';
-part 'model/shared_links_response.dart';
-part 'model/shared_links_update.dart';
part 'model/sign_up_dto.dart';
-part 'model/smart_search_dto.dart';
-part 'model/source_type.dart';
-part 'model/stack_create_dto.dart';
-part 'model/stack_response_dto.dart';
-part 'model/stack_update_dto.dart';
-part 'model/statistics_search_dto.dart';
-part 'model/storage_folder.dart';
-part 'model/sync_ack_delete_dto.dart';
-part 'model/sync_ack_dto.dart';
-part 'model/sync_ack_set_dto.dart';
-part 'model/sync_album_delete_v1.dart';
-part 'model/sync_album_to_asset_delete_v1.dart';
-part 'model/sync_album_to_asset_v1.dart';
-part 'model/sync_album_user_delete_v1.dart';
-part 'model/sync_album_user_v1.dart';
-part 'model/sync_album_v1.dart';
-part 'model/sync_asset_delete_v1.dart';
-part 'model/sync_asset_exif_v1.dart';
-part 'model/sync_asset_face_delete_v1.dart';
-part 'model/sync_asset_face_v1.dart';
-part 'model/sync_asset_metadata_delete_v1.dart';
-part 'model/sync_asset_metadata_v1.dart';
-part 'model/sync_asset_v1.dart';
-part 'model/sync_auth_user_v1.dart';
-part 'model/sync_entity_type.dart';
-part 'model/sync_memory_asset_delete_v1.dart';
-part 'model/sync_memory_asset_v1.dart';
-part 'model/sync_memory_delete_v1.dart';
-part 'model/sync_memory_v1.dart';
-part 'model/sync_partner_delete_v1.dart';
-part 'model/sync_partner_v1.dart';
-part 'model/sync_person_delete_v1.dart';
-part 'model/sync_person_v1.dart';
-part 'model/sync_request_type.dart';
-part 'model/sync_stack_delete_v1.dart';
-part 'model/sync_stack_v1.dart';
-part 'model/sync_stream_dto.dart';
-part 'model/sync_user_delete_v1.dart';
-part 'model/sync_user_metadata_delete_v1.dart';
-part 'model/sync_user_metadata_v1.dart';
-part 'model/sync_user_v1.dart';
-part 'model/system_config_backups_dto.dart';
-part 'model/system_config_dto.dart';
-part 'model/system_config_f_fmpeg_dto.dart';
-part 'model/system_config_faces_dto.dart';
-part 'model/system_config_generated_fullsize_image_dto.dart';
-part 'model/system_config_generated_image_dto.dart';
-part 'model/system_config_image_dto.dart';
-part 'model/system_config_job_dto.dart';
-part 'model/system_config_library_dto.dart';
-part 'model/system_config_library_scan_dto.dart';
-part 'model/system_config_library_watch_dto.dart';
-part 'model/system_config_logging_dto.dart';
-part 'model/system_config_machine_learning_dto.dart';
-part 'model/system_config_map_dto.dart';
-part 'model/system_config_metadata_dto.dart';
-part 'model/system_config_new_version_check_dto.dart';
-part 'model/system_config_nightly_tasks_dto.dart';
-part 'model/system_config_notifications_dto.dart';
-part 'model/system_config_o_auth_dto.dart';
-part 'model/system_config_password_login_dto.dart';
-part 'model/system_config_reverse_geocoding_dto.dart';
-part 'model/system_config_server_dto.dart';
-part 'model/system_config_smtp_dto.dart';
-part 'model/system_config_smtp_transport_dto.dart';
-part 'model/system_config_storage_template_dto.dart';
-part 'model/system_config_template_emails_dto.dart';
-part 'model/system_config_template_storage_option_dto.dart';
-part 'model/system_config_templates_dto.dart';
-part 'model/system_config_theme_dto.dart';
-part 'model/system_config_trash_dto.dart';
-part 'model/system_config_user_dto.dart';
-part 'model/tag_bulk_assets_dto.dart';
-part 'model/tag_bulk_assets_response_dto.dart';
-part 'model/tag_create_dto.dart';
-part 'model/tag_response_dto.dart';
-part 'model/tag_update_dto.dart';
-part 'model/tag_upsert_dto.dart';
-part 'model/tags_response.dart';
-part 'model/tags_update.dart';
-part 'model/template_dto.dart';
-part 'model/template_response_dto.dart';
-part 'model/test_email_response_dto.dart';
-part 'model/time_bucket_asset_response_dto.dart';
-part 'model/time_buckets_response_dto.dart';
-part 'model/tone_mapping.dart';
-part 'model/transcode_hw_accel.dart';
-part 'model/transcode_policy.dart';
-part 'model/trash_response_dto.dart';
-part 'model/update_album_dto.dart';
-part 'model/update_album_user_dto.dart';
-part 'model/update_asset_dto.dart';
-part 'model/update_library_dto.dart';
-part 'model/usage_by_user_dto.dart';
-part 'model/user_admin_create_dto.dart';
-part 'model/user_admin_delete_dto.dart';
part 'model/user_admin_response_dto.dart';
-part 'model/user_admin_update_dto.dart';
-part 'model/user_avatar_color.dart';
-part 'model/user_license.dart';
-part 'model/user_metadata_key.dart';
-part 'model/user_preferences_response_dto.dart';
-part 'model/user_preferences_update_dto.dart';
part 'model/user_response_dto.dart';
part 'model/user_status.dart';
part 'model/user_update_me_dto.dart';
part 'model/validate_access_token_response_dto.dart';
-part 'model/validate_library_dto.dart';
-part 'model/validate_library_import_path_response_dto.dart';
-part 'model/validate_library_response_dto.dart';
-part 'model/version_check_state_response_dto.dart';
-part 'model/video_codec.dart';
-part 'model/video_container.dart';
-part 'model/workflow_action_item_dto.dart';
-part 'model/workflow_action_response_dto.dart';
-part 'model/workflow_create_dto.dart';
-part 'model/workflow_filter_item_dto.dart';
-part 'model/workflow_filter_response_dto.dart';
-part 'model/workflow_response_dto.dart';
-part 'model/workflow_update_dto.dart';
/// An [ApiClient] instance that uses the default values obtained from
diff --git a/mobile/openapi/lib/api/activities_api.dart b/mobile/openapi/lib/api/activities_api.dart
deleted file mode 100644
index 697598ac97..0000000000
--- a/mobile/openapi/lib/api/activities_api.dart
+++ /dev/null
@@ -1,291 +0,0 @@
-//
-// AUTO-GENERATED FILE, DO NOT MODIFY!
-//
-// @dart=2.18
-
-// ignore_for_file: unused_element, unused_import
-// ignore_for_file: always_put_required_named_parameters_first
-// ignore_for_file: constant_identifier_names
-// ignore_for_file: lines_longer_than_80_chars
-
-part of openapi.api;
-
-
-class ActivitiesApi {
- ActivitiesApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
-
- final ApiClient apiClient;
-
- /// Create an activity
- ///
- /// Create a like or a comment for an album, or an asset in an album.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [ActivityCreateDto] activityCreateDto (required):
- Future createActivityWithHttpInfo(ActivityCreateDto activityCreateDto,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/activities';
-
- // ignore: prefer_final_locals
- Object? postBody = activityCreateDto;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = ['application/json'];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'POST',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Create an activity
- ///
- /// Create a like or a comment for an album, or an asset in an album.
- ///
- /// Parameters:
- ///
- /// * [ActivityCreateDto] activityCreateDto (required):
- Future createActivity(ActivityCreateDto activityCreateDto,) async {
- final response = await createActivityWithHttpInfo(activityCreateDto,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ActivityResponseDto',) as ActivityResponseDto;
-
- }
- return null;
- }
-
- /// Delete an activity
- ///
- /// Removes a like or comment from a given album or asset in an album.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- Future deleteActivityWithHttpInfo(String id,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/activities/{id}'
- .replaceAll('{id}', id);
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'DELETE',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Delete an activity
- ///
- /// Removes a like or comment from a given album or asset in an album.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- Future deleteActivity(String id,) async {
- final response = await deleteActivityWithHttpInfo(id,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- }
-
- /// List all activities
- ///
- /// Returns a list of activities for the selected asset or album. The activities are returned in sorted order, with the oldest activities appearing first.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] albumId (required):
- /// Album ID
- ///
- /// * [String] assetId:
- /// Asset ID (if activity is for an asset)
- ///
- /// * [ReactionLevel] level:
- /// Filter by activity level
- ///
- /// * [ReactionType] type:
- /// Filter by activity type
- ///
- /// * [String] userId:
- /// Filter by user ID
- Future getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionLevel? level, ReactionType? type, String? userId, }) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/activities';
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- queryParams.addAll(_queryParams('', 'albumId', albumId));
- if (assetId != null) {
- queryParams.addAll(_queryParams('', 'assetId', assetId));
- }
- if (level != null) {
- queryParams.addAll(_queryParams('', 'level', level));
- }
- if (type != null) {
- queryParams.addAll(_queryParams('', 'type', type));
- }
- if (userId != null) {
- queryParams.addAll(_queryParams('', 'userId', userId));
- }
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// List all activities
- ///
- /// Returns a list of activities for the selected asset or album. The activities are returned in sorted order, with the oldest activities appearing first.
- ///
- /// Parameters:
- ///
- /// * [String] albumId (required):
- /// Album ID
- ///
- /// * [String] assetId:
- /// Asset ID (if activity is for an asset)
- ///
- /// * [ReactionLevel] level:
- /// Filter by activity level
- ///
- /// * [ReactionType] type:
- /// Filter by activity type
- ///
- /// * [String] userId:
- /// Filter by user ID
- Future?> getActivities(String albumId, { String? assetId, ReactionLevel? level, ReactionType? type, String? userId, }) async {
- final response = await getActivitiesWithHttpInfo(albumId, assetId: assetId, level: level, type: type, userId: userId, );
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- final responseBody = await _decodeBodyBytes(response);
- return (await apiClient.deserializeAsync(responseBody, 'List') as List)
- .cast()
- .toList(growable: false);
-
- }
- return null;
- }
-
- /// Retrieve activity statistics
- ///
- /// Returns the number of likes and comments for a given album or asset in an album.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] albumId (required):
- /// Album ID
- ///
- /// * [String] assetId:
- /// Asset ID (if activity is for an asset)
- Future getActivityStatisticsWithHttpInfo(String albumId, { String? assetId, }) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/activities/statistics';
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- queryParams.addAll(_queryParams('', 'albumId', albumId));
- if (assetId != null) {
- queryParams.addAll(_queryParams('', 'assetId', assetId));
- }
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Retrieve activity statistics
- ///
- /// Returns the number of likes and comments for a given album or asset in an album.
- ///
- /// Parameters:
- ///
- /// * [String] albumId (required):
- /// Album ID
- ///
- /// * [String] assetId:
- /// Asset ID (if activity is for an asset)
- Future getActivityStatistics(String albumId, { String? assetId, }) async {
- final response = await getActivityStatisticsWithHttpInfo(albumId, assetId: assetId, );
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ActivityStatisticsResponseDto',) as ActivityStatisticsResponseDto;
-
- }
- return null;
- }
-}
diff --git a/mobile/openapi/lib/api/albums_api.dart b/mobile/openapi/lib/api/albums_api.dart
deleted file mode 100644
index e2db95b9e0..0000000000
--- a/mobile/openapi/lib/api/albums_api.dart
+++ /dev/null
@@ -1,774 +0,0 @@
-//
-// AUTO-GENERATED FILE, DO NOT MODIFY!
-//
-// @dart=2.18
-
-// ignore_for_file: unused_element, unused_import
-// ignore_for_file: always_put_required_named_parameters_first
-// ignore_for_file: constant_identifier_names
-// ignore_for_file: lines_longer_than_80_chars
-
-part of openapi.api;
-
-
-class AlbumsApi {
- AlbumsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
-
- final ApiClient apiClient;
-
- /// Add assets to an album
- ///
- /// Add multiple assets to a specific album by its ID.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [BulkIdsDto] bulkIdsDto (required):
- ///
- /// * [String] key:
- ///
- /// * [String] slug:
- Future addAssetsToAlbumWithHttpInfo(String id, BulkIdsDto bulkIdsDto, { String? key, String? slug, }) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/albums/{id}/assets'
- .replaceAll('{id}', id);
-
- // ignore: prefer_final_locals
- Object? postBody = bulkIdsDto;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- if (key != null) {
- queryParams.addAll(_queryParams('', 'key', key));
- }
- if (slug != null) {
- queryParams.addAll(_queryParams('', 'slug', slug));
- }
-
- const contentTypes = ['application/json'];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'PUT',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Add assets to an album
- ///
- /// Add multiple assets to a specific album by its ID.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [BulkIdsDto] bulkIdsDto (required):
- ///
- /// * [String] key:
- ///
- /// * [String] slug:
- Future?> addAssetsToAlbum(String id, BulkIdsDto bulkIdsDto, { String? key, String? slug, }) async {
- final response = await addAssetsToAlbumWithHttpInfo(id, bulkIdsDto, key: key, slug: slug, );
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- final responseBody = await _decodeBodyBytes(response);
- return (await apiClient.deserializeAsync(responseBody, 'List') as List)
- .cast()
- .toList(growable: false);
-
- }
- return null;
- }
-
- /// Add assets to albums
- ///
- /// Send a list of asset IDs and album IDs to add each asset to each album.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [AlbumsAddAssetsDto] albumsAddAssetsDto (required):
- ///
- /// * [String] key:
- ///
- /// * [String] slug:
- Future addAssetsToAlbumsWithHttpInfo(AlbumsAddAssetsDto albumsAddAssetsDto, { String? key, String? slug, }) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/albums/assets';
-
- // ignore: prefer_final_locals
- Object? postBody = albumsAddAssetsDto;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- if (key != null) {
- queryParams.addAll(_queryParams('', 'key', key));
- }
- if (slug != null) {
- queryParams.addAll(_queryParams('', 'slug', slug));
- }
-
- const contentTypes = ['application/json'];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'PUT',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Add assets to albums
- ///
- /// Send a list of asset IDs and album IDs to add each asset to each album.
- ///
- /// Parameters:
- ///
- /// * [AlbumsAddAssetsDto] albumsAddAssetsDto (required):
- ///
- /// * [String] key:
- ///
- /// * [String] slug:
- Future addAssetsToAlbums(AlbumsAddAssetsDto albumsAddAssetsDto, { String? key, String? slug, }) async {
- final response = await addAssetsToAlbumsWithHttpInfo(albumsAddAssetsDto, key: key, slug: slug, );
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumsAddAssetsResponseDto',) as AlbumsAddAssetsResponseDto;
-
- }
- return null;
- }
-
- /// Share album with users
- ///
- /// Share an album with multiple users. Each user can be given a specific role in the album.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [AddUsersDto] addUsersDto (required):
- Future addUsersToAlbumWithHttpInfo(String id, AddUsersDto addUsersDto,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/albums/{id}/users'
- .replaceAll('{id}', id);
-
- // ignore: prefer_final_locals
- Object? postBody = addUsersDto;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = ['application/json'];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'PUT',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Share album with users
- ///
- /// Share an album with multiple users. Each user can be given a specific role in the album.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [AddUsersDto] addUsersDto (required):
- Future addUsersToAlbum(String id, AddUsersDto addUsersDto,) async {
- final response = await addUsersToAlbumWithHttpInfo(id, addUsersDto,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumResponseDto',) as AlbumResponseDto;
-
- }
- return null;
- }
-
- /// Create an album
- ///
- /// Create a new album. The album can also be created with initial users and assets.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [CreateAlbumDto] createAlbumDto (required):
- Future createAlbumWithHttpInfo(CreateAlbumDto createAlbumDto,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/albums';
-
- // ignore: prefer_final_locals
- Object? postBody = createAlbumDto;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = ['application/json'];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'POST',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Create an album
- ///
- /// Create a new album. The album can also be created with initial users and assets.
- ///
- /// Parameters:
- ///
- /// * [CreateAlbumDto] createAlbumDto (required):
- Future createAlbum(CreateAlbumDto createAlbumDto,) async {
- final response = await createAlbumWithHttpInfo(createAlbumDto,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumResponseDto',) as AlbumResponseDto;
-
- }
- return null;
- }
-
- /// Delete an album
- ///
- /// Delete a specific album by its ID. Note the album is initially trashed and then immediately scheduled for deletion, but relies on a background job to complete the process.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- Future deleteAlbumWithHttpInfo(String id,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/albums/{id}'
- .replaceAll('{id}', id);
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'DELETE',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Delete an album
- ///
- /// Delete a specific album by its ID. Note the album is initially trashed and then immediately scheduled for deletion, but relies on a background job to complete the process.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- Future deleteAlbum(String id,) async {
- final response = await deleteAlbumWithHttpInfo(id,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- }
-
- /// Retrieve an album
- ///
- /// Retrieve information about a specific album by its ID.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [String] key:
- ///
- /// * [String] slug:
- ///
- /// * [bool] withoutAssets:
- /// Exclude assets from response
- Future getAlbumInfoWithHttpInfo(String id, { String? key, String? slug, bool? withoutAssets, }) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/albums/{id}'
- .replaceAll('{id}', id);
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- if (key != null) {
- queryParams.addAll(_queryParams('', 'key', key));
- }
- if (slug != null) {
- queryParams.addAll(_queryParams('', 'slug', slug));
- }
- if (withoutAssets != null) {
- queryParams.addAll(_queryParams('', 'withoutAssets', withoutAssets));
- }
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Retrieve an album
- ///
- /// Retrieve information about a specific album by its ID.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [String] key:
- ///
- /// * [String] slug:
- ///
- /// * [bool] withoutAssets:
- /// Exclude assets from response
- Future getAlbumInfo(String id, { String? key, String? slug, bool? withoutAssets, }) async {
- final response = await getAlbumInfoWithHttpInfo(id, key: key, slug: slug, withoutAssets: withoutAssets, );
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumResponseDto',) as AlbumResponseDto;
-
- }
- return null;
- }
-
- /// Retrieve album statistics
- ///
- /// Returns statistics about the albums available to the authenticated user.
- ///
- /// Note: This method returns the HTTP [Response].
- Future getAlbumStatisticsWithHttpInfo() async {
- // ignore: prefer_const_declarations
- final apiPath = r'/albums/statistics';
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Retrieve album statistics
- ///
- /// Returns statistics about the albums available to the authenticated user.
- Future getAlbumStatistics() async {
- final response = await getAlbumStatisticsWithHttpInfo();
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumStatisticsResponseDto',) as AlbumStatisticsResponseDto;
-
- }
- return null;
- }
-
- /// List all albums
- ///
- /// Retrieve a list of albums available to the authenticated user.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] assetId:
- /// Filter albums containing this asset ID (ignores shared parameter)
- ///
- /// * [bool] shared:
- /// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums
- Future getAllAlbumsWithHttpInfo({ String? assetId, bool? shared, }) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/albums';
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- if (assetId != null) {
- queryParams.addAll(_queryParams('', 'assetId', assetId));
- }
- if (shared != null) {
- queryParams.addAll(_queryParams('', 'shared', shared));
- }
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// List all albums
- ///
- /// Retrieve a list of albums available to the authenticated user.
- ///
- /// Parameters:
- ///
- /// * [String] assetId:
- /// Filter albums containing this asset ID (ignores shared parameter)
- ///
- /// * [bool] shared:
- /// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums
- Future?> getAllAlbums({ String? assetId, bool? shared, }) async {
- final response = await getAllAlbumsWithHttpInfo( assetId: assetId, shared: shared, );
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- final responseBody = await _decodeBodyBytes(response);
- return (await apiClient.deserializeAsync(responseBody, 'List') as List)
- .cast()
- .toList(growable: false);
-
- }
- return null;
- }
-
- /// Remove assets from an album
- ///
- /// Remove multiple assets from a specific album by its ID.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [BulkIdsDto] bulkIdsDto (required):
- Future removeAssetFromAlbumWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/albums/{id}/assets'
- .replaceAll('{id}', id);
-
- // ignore: prefer_final_locals
- Object? postBody = bulkIdsDto;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = ['application/json'];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'DELETE',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Remove assets from an album
- ///
- /// Remove multiple assets from a specific album by its ID.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [BulkIdsDto] bulkIdsDto (required):
- Future?> removeAssetFromAlbum(String id, BulkIdsDto bulkIdsDto,) async {
- final response = await removeAssetFromAlbumWithHttpInfo(id, bulkIdsDto,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- final responseBody = await _decodeBodyBytes(response);
- return (await apiClient.deserializeAsync(responseBody, 'List') as List)
- .cast()
- .toList(growable: false);
-
- }
- return null;
- }
-
- /// Remove user from album
- ///
- /// Remove a user from an album. Use an ID of \"me\" to leave a shared album.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [String] userId (required):
- Future removeUserFromAlbumWithHttpInfo(String id, String userId,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/albums/{id}/user/{userId}'
- .replaceAll('{id}', id)
- .replaceAll('{userId}', userId);
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'DELETE',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Remove user from album
- ///
- /// Remove a user from an album. Use an ID of \"me\" to leave a shared album.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [String] userId (required):
- Future removeUserFromAlbum(String id, String userId,) async {
- final response = await removeUserFromAlbumWithHttpInfo(id, userId,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- }
-
- /// Update an album
- ///
- /// Update the information of a specific album by its ID. This endpoint can be used to update the album name, description, sort order, etc. However, it is not used to add or remove assets or users from the album.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [UpdateAlbumDto] updateAlbumDto (required):
- Future updateAlbumInfoWithHttpInfo(String id, UpdateAlbumDto updateAlbumDto,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/albums/{id}'
- .replaceAll('{id}', id);
-
- // ignore: prefer_final_locals
- Object? postBody = updateAlbumDto;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = ['application/json'];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'PATCH',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Update an album
- ///
- /// Update the information of a specific album by its ID. This endpoint can be used to update the album name, description, sort order, etc. However, it is not used to add or remove assets or users from the album.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [UpdateAlbumDto] updateAlbumDto (required):
- Future updateAlbumInfo(String id, UpdateAlbumDto updateAlbumDto,) async {
- final response = await updateAlbumInfoWithHttpInfo(id, updateAlbumDto,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumResponseDto',) as AlbumResponseDto;
-
- }
- return null;
- }
-
- /// Update user role
- ///
- /// Change the role for a specific user in a specific album.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [String] userId (required):
- ///
- /// * [UpdateAlbumUserDto] updateAlbumUserDto (required):
- Future updateAlbumUserWithHttpInfo(String id, String userId, UpdateAlbumUserDto updateAlbumUserDto,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/albums/{id}/user/{userId}'
- .replaceAll('{id}', id)
- .replaceAll('{userId}', userId);
-
- // ignore: prefer_final_locals
- Object? postBody = updateAlbumUserDto;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = ['application/json'];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'PUT',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Update user role
- ///
- /// Change the role for a specific user in a specific album.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [String] userId (required):
- ///
- /// * [UpdateAlbumUserDto] updateAlbumUserDto (required):
- Future updateAlbumUser(String id, String userId, UpdateAlbumUserDto updateAlbumUserDto,) async {
- final response = await updateAlbumUserWithHttpInfo(id, userId, updateAlbumUserDto,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- }
-}
diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart
deleted file mode 100644
index 5fda01a594..0000000000
--- a/mobile/openapi/lib/api/assets_api.dart
+++ /dev/null
@@ -1,1839 +0,0 @@
-//
-// AUTO-GENERATED FILE, DO NOT MODIFY!
-//
-// @dart=2.18
-
-// ignore_for_file: unused_element, unused_import
-// ignore_for_file: always_put_required_named_parameters_first
-// ignore_for_file: constant_identifier_names
-// ignore_for_file: lines_longer_than_80_chars
-
-part of openapi.api;
-
-
-class AssetsApi {
- AssetsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
-
- final ApiClient apiClient;
-
- /// Check bulk upload
- ///
- /// Determine which assets have already been uploaded to the server based on their SHA1 checksums.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [AssetBulkUploadCheckDto] assetBulkUploadCheckDto (required):
- Future checkBulkUploadWithHttpInfo(AssetBulkUploadCheckDto assetBulkUploadCheckDto,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets/bulk-upload-check';
-
- // ignore: prefer_final_locals
- Object? postBody = assetBulkUploadCheckDto;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = ['application/json'];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'POST',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Check bulk upload
- ///
- /// Determine which assets have already been uploaded to the server based on their SHA1 checksums.
- ///
- /// Parameters:
- ///
- /// * [AssetBulkUploadCheckDto] assetBulkUploadCheckDto (required):
- Future checkBulkUpload(AssetBulkUploadCheckDto assetBulkUploadCheckDto,) async {
- final response = await checkBulkUploadWithHttpInfo(assetBulkUploadCheckDto,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetBulkUploadCheckResponseDto',) as AssetBulkUploadCheckResponseDto;
-
- }
- return null;
- }
-
- /// Check existing assets
- ///
- /// Checks if multiple assets exist on the server and returns all existing - used by background backup
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [CheckExistingAssetsDto] checkExistingAssetsDto (required):
- Future checkExistingAssetsWithHttpInfo(CheckExistingAssetsDto checkExistingAssetsDto,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets/exist';
-
- // ignore: prefer_final_locals
- Object? postBody = checkExistingAssetsDto;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = ['application/json'];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'POST',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Check existing assets
- ///
- /// Checks if multiple assets exist on the server and returns all existing - used by background backup
- ///
- /// Parameters:
- ///
- /// * [CheckExistingAssetsDto] checkExistingAssetsDto (required):
- Future checkExistingAssets(CheckExistingAssetsDto checkExistingAssetsDto,) async {
- final response = await checkExistingAssetsWithHttpInfo(checkExistingAssetsDto,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'CheckExistingAssetsResponseDto',) as CheckExistingAssetsResponseDto;
-
- }
- return null;
- }
-
- /// Copy asset
- ///
- /// Copy asset information like albums, tags, etc. from one asset to another.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [AssetCopyDto] assetCopyDto (required):
- Future copyAssetWithHttpInfo(AssetCopyDto assetCopyDto,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets/copy';
-
- // ignore: prefer_final_locals
- Object? postBody = assetCopyDto;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = ['application/json'];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'PUT',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Copy asset
- ///
- /// Copy asset information like albums, tags, etc. from one asset to another.
- ///
- /// Parameters:
- ///
- /// * [AssetCopyDto] assetCopyDto (required):
- Future copyAsset(AssetCopyDto assetCopyDto,) async {
- final response = await copyAssetWithHttpInfo(assetCopyDto,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- }
-
- /// Delete asset metadata by key
- ///
- /// Delete a specific metadata key-value pair associated with the specified asset.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- /// Asset ID
- ///
- /// * [String] key (required):
- /// Metadata key
- Future deleteAssetMetadataWithHttpInfo(String id, String key,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets/{id}/metadata/{key}'
- .replaceAll('{id}', id)
- .replaceAll('{key}', key);
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'DELETE',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Delete asset metadata by key
- ///
- /// Delete a specific metadata key-value pair associated with the specified asset.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- /// Asset ID
- ///
- /// * [String] key (required):
- /// Metadata key
- Future deleteAssetMetadata(String id, String key,) async {
- final response = await deleteAssetMetadataWithHttpInfo(id, key,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- }
-
- /// Delete assets
- ///
- /// Deletes multiple assets at the same time.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [AssetBulkDeleteDto] assetBulkDeleteDto (required):
- Future deleteAssetsWithHttpInfo(AssetBulkDeleteDto assetBulkDeleteDto,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets';
-
- // ignore: prefer_final_locals
- Object? postBody = assetBulkDeleteDto;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = ['application/json'];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'DELETE',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Delete assets
- ///
- /// Deletes multiple assets at the same time.
- ///
- /// Parameters:
- ///
- /// * [AssetBulkDeleteDto] assetBulkDeleteDto (required):
- Future deleteAssets(AssetBulkDeleteDto assetBulkDeleteDto,) async {
- final response = await deleteAssetsWithHttpInfo(assetBulkDeleteDto,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- }
-
- /// Delete asset metadata
- ///
- /// Delete metadata key-value pairs for multiple assets.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [AssetMetadataBulkDeleteDto] assetMetadataBulkDeleteDto (required):
- Future deleteBulkAssetMetadataWithHttpInfo(AssetMetadataBulkDeleteDto assetMetadataBulkDeleteDto,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets/metadata';
-
- // ignore: prefer_final_locals
- Object? postBody = assetMetadataBulkDeleteDto;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = ['application/json'];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'DELETE',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Delete asset metadata
- ///
- /// Delete metadata key-value pairs for multiple assets.
- ///
- /// Parameters:
- ///
- /// * [AssetMetadataBulkDeleteDto] assetMetadataBulkDeleteDto (required):
- Future deleteBulkAssetMetadata(AssetMetadataBulkDeleteDto assetMetadataBulkDeleteDto,) async {
- final response = await deleteBulkAssetMetadataWithHttpInfo(assetMetadataBulkDeleteDto,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- }
-
- /// Download original asset
- ///
- /// Downloads the original file of the specified asset.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [bool] edited:
- /// Return edited asset if available
- ///
- /// * [String] key:
- ///
- /// * [String] slug:
- Future downloadAssetWithHttpInfo(String id, { bool? edited, String? key, String? slug, }) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets/{id}/original'
- .replaceAll('{id}', id);
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- if (edited != null) {
- queryParams.addAll(_queryParams('', 'edited', edited));
- }
- if (key != null) {
- queryParams.addAll(_queryParams('', 'key', key));
- }
- if (slug != null) {
- queryParams.addAll(_queryParams('', 'slug', slug));
- }
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Download original asset
- ///
- /// Downloads the original file of the specified asset.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [bool] edited:
- /// Return edited asset if available
- ///
- /// * [String] key:
- ///
- /// * [String] slug:
- Future downloadAsset(String id, { bool? edited, String? key, String? slug, }) async {
- final response = await downloadAssetWithHttpInfo(id, edited: edited, key: key, slug: slug, );
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
-
- }
- return null;
- }
-
- /// Apply edits to an existing asset
- ///
- /// Apply a series of edit actions (crop, rotate, mirror) to the specified asset.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [AssetEditActionListDto] assetEditActionListDto (required):
- Future editAssetWithHttpInfo(String id, AssetEditActionListDto assetEditActionListDto,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets/{id}/edits'
- .replaceAll('{id}', id);
-
- // ignore: prefer_final_locals
- Object? postBody = assetEditActionListDto;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = ['application/json'];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'PUT',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Apply edits to an existing asset
- ///
- /// Apply a series of edit actions (crop, rotate, mirror) to the specified asset.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [AssetEditActionListDto] assetEditActionListDto (required):
- Future editAsset(String id, AssetEditActionListDto assetEditActionListDto,) async {
- final response = await editAssetWithHttpInfo(id, assetEditActionListDto,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsDto',) as AssetEditsDto;
-
- }
- return null;
- }
-
- /// Retrieve assets by device ID
- ///
- /// Get all asset of a device that are in the database, ID only.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] deviceId (required):
- /// Device ID
- Future getAllUserAssetsByDeviceIdWithHttpInfo(String deviceId,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets/device/{deviceId}'
- .replaceAll('{deviceId}', deviceId);
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Retrieve assets by device ID
- ///
- /// Get all asset of a device that are in the database, ID only.
- ///
- /// Parameters:
- ///
- /// * [String] deviceId (required):
- /// Device ID
- Future?> getAllUserAssetsByDeviceId(String deviceId,) async {
- final response = await getAllUserAssetsByDeviceIdWithHttpInfo(deviceId,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- final responseBody = await _decodeBodyBytes(response);
- return (await apiClient.deserializeAsync(responseBody, 'List') as List)
- .cast()
- .toList(growable: false);
-
- }
- return null;
- }
-
- /// Retrieve edits for an existing asset
- ///
- /// Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- Future getAssetEditsWithHttpInfo(String id,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets/{id}/edits'
- .replaceAll('{id}', id);
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Retrieve edits for an existing asset
- ///
- /// Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- Future getAssetEdits(String id,) async {
- final response = await getAssetEditsWithHttpInfo(id,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsDto',) as AssetEditsDto;
-
- }
- return null;
- }
-
- /// Retrieve an asset
- ///
- /// Retrieve detailed information about a specific asset.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [String] key:
- ///
- /// * [String] slug:
- Future getAssetInfoWithHttpInfo(String id, { String? key, String? slug, }) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets/{id}'
- .replaceAll('{id}', id);
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- if (key != null) {
- queryParams.addAll(_queryParams('', 'key', key));
- }
- if (slug != null) {
- queryParams.addAll(_queryParams('', 'slug', slug));
- }
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Retrieve an asset
- ///
- /// Retrieve detailed information about a specific asset.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [String] key:
- ///
- /// * [String] slug:
- Future getAssetInfo(String id, { String? key, String? slug, }) async {
- final response = await getAssetInfoWithHttpInfo(id, key: key, slug: slug, );
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetResponseDto',) as AssetResponseDto;
-
- }
- return null;
- }
-
- /// Get asset metadata
- ///
- /// Retrieve all metadata key-value pairs associated with the specified asset.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- Future getAssetMetadataWithHttpInfo(String id,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets/{id}/metadata'
- .replaceAll('{id}', id);
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Get asset metadata
- ///
- /// Retrieve all metadata key-value pairs associated with the specified asset.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- Future?> getAssetMetadata(String id,) async {
- final response = await getAssetMetadataWithHttpInfo(id,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- final responseBody = await _decodeBodyBytes(response);
- return (await apiClient.deserializeAsync(responseBody, 'List') as List)
- .cast()
- .toList(growable: false);
-
- }
- return null;
- }
-
- /// Retrieve asset metadata by key
- ///
- /// Retrieve the value of a specific metadata key associated with the specified asset.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- /// Asset ID
- ///
- /// * [String] key (required):
- /// Metadata key
- Future getAssetMetadataByKeyWithHttpInfo(String id, String key,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets/{id}/metadata/{key}'
- .replaceAll('{id}', id)
- .replaceAll('{key}', key);
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Retrieve asset metadata by key
- ///
- /// Retrieve the value of a specific metadata key associated with the specified asset.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- /// Asset ID
- ///
- /// * [String] key (required):
- /// Metadata key
- Future getAssetMetadataByKey(String id, String key,) async {
- final response = await getAssetMetadataByKeyWithHttpInfo(id, key,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetMetadataResponseDto',) as AssetMetadataResponseDto;
-
- }
- return null;
- }
-
- /// Retrieve asset OCR data
- ///
- /// Retrieve all OCR (Optical Character Recognition) data associated with the specified asset.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- Future getAssetOcrWithHttpInfo(String id,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets/{id}/ocr'
- .replaceAll('{id}', id);
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Retrieve asset OCR data
- ///
- /// Retrieve all OCR (Optical Character Recognition) data associated with the specified asset.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- Future?> getAssetOcr(String id,) async {
- final response = await getAssetOcrWithHttpInfo(id,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- final responseBody = await _decodeBodyBytes(response);
- return (await apiClient.deserializeAsync(responseBody, 'List') as List)
- .cast()
- .toList(growable: false);
-
- }
- return null;
- }
-
- /// Get asset statistics
- ///
- /// Retrieve various statistics about the assets owned by the authenticated user.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [bool] isFavorite:
- /// Filter by favorite status
- ///
- /// * [bool] isTrashed:
- /// Filter by trash status
- ///
- /// * [AssetVisibility] visibility:
- /// Filter by visibility
- Future getAssetStatisticsWithHttpInfo({ bool? isFavorite, bool? isTrashed, AssetVisibility? visibility, }) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets/statistics';
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- if (isFavorite != null) {
- queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
- }
- if (isTrashed != null) {
- queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
- }
- if (visibility != null) {
- queryParams.addAll(_queryParams('', 'visibility', visibility));
- }
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Get asset statistics
- ///
- /// Retrieve various statistics about the assets owned by the authenticated user.
- ///
- /// Parameters:
- ///
- /// * [bool] isFavorite:
- /// Filter by favorite status
- ///
- /// * [bool] isTrashed:
- /// Filter by trash status
- ///
- /// * [AssetVisibility] visibility:
- /// Filter by visibility
- Future getAssetStatistics({ bool? isFavorite, bool? isTrashed, AssetVisibility? visibility, }) async {
- final response = await getAssetStatisticsWithHttpInfo( isFavorite: isFavorite, isTrashed: isTrashed, visibility: visibility, );
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetStatsResponseDto',) as AssetStatsResponseDto;
-
- }
- return null;
- }
-
- /// Get random assets
- ///
- /// Retrieve a specified number of random assets for the authenticated user.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [num] count:
- /// Number of random assets to return
- Future getRandomWithHttpInfo({ num? count, }) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets/random';
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- if (count != null) {
- queryParams.addAll(_queryParams('', 'count', count));
- }
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Get random assets
- ///
- /// Retrieve a specified number of random assets for the authenticated user.
- ///
- /// Parameters:
- ///
- /// * [num] count:
- /// Number of random assets to return
- Future?> getRandom({ num? count, }) async {
- final response = await getRandomWithHttpInfo( count: count, );
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- final responseBody = await _decodeBodyBytes(response);
- return (await apiClient.deserializeAsync(responseBody, 'List') as List)
- .cast()
- .toList(growable: false);
-
- }
- return null;
- }
-
- /// Play asset video
- ///
- /// Streams the video file for the specified asset. This endpoint also supports byte range requests.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [String] key:
- ///
- /// * [String] slug:
- Future playAssetVideoWithHttpInfo(String id, { String? key, String? slug, }) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets/{id}/video/playback'
- .replaceAll('{id}', id);
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- if (key != null) {
- queryParams.addAll(_queryParams('', 'key', key));
- }
- if (slug != null) {
- queryParams.addAll(_queryParams('', 'slug', slug));
- }
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Play asset video
- ///
- /// Streams the video file for the specified asset. This endpoint also supports byte range requests.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- ///
- /// * [String] key:
- ///
- /// * [String] slug:
- Future playAssetVideo(String id, { String? key, String? slug, }) async {
- final response = await playAssetVideoWithHttpInfo(id, key: key, slug: slug, );
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
-
- }
- return null;
- }
-
- /// Remove edits from an existing asset
- ///
- /// Removes all edit actions (crop, rotate, mirror) associated with the specified asset.
- ///
- /// Note: This method returns the HTTP [Response].
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- Future removeAssetEditsWithHttpInfo(String id,) async {
- // ignore: prefer_const_declarations
- final apiPath = r'/assets/{id}/edits'
- .replaceAll('{id}', id);
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- apiPath,
- 'DELETE',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Remove edits from an existing asset
- ///
- /// Removes all edit actions (crop, rotate, mirror) associated with the specified asset.
- ///
- /// Parameters:
- ///
- /// * [String] id (required):
- Future