Compare commits

..

12 Commits

Author SHA1 Message Date
Min Idzelis
053dd490b4 fix test 2025-06-15 03:40:31 +00:00
Min Idzelis
f4f81341da tests 2025-06-15 03:22:32 +00:00
Min Idzelis
3e66913cf8 chore: lint 2025-06-15 03:16:39 +00:00
Min Idzelis
504309eff5 chore: make sql 2025-06-15 03:04:23 +00:00
Min Idzelis
b44abf5b4b Merge remote-tracking branch 'origin/main' into timeline_events 2025-06-15 02:58:04 +00:00
Min Idzelis
c76e8da173 chore: cleanup 2025-06-15 02:47:18 +00:00
Min Idzelis
9cc2189ef7 chore: remove unused code and fix test expectations
- Remove unused activityManager import from asset viewer components
- Remove unused function stub in activity manager
- Fix album service test expectations for emit parameters
- Clean up formatting in person repository mock
- Update trash service tests for emit event changes
2025-06-15 02:25:42 +00:00
Min Idzelis
6b87efe7a3 feat(web): improve websocket filtering and add restored assets support
- Refactor websocket support to use modular filter functions
- Add support for on_asset_restore events
- Improve handling of asset updates with proper filtering for visibility, favorites, trash, tags, albums, and persons
- Add null checks in timeline manager for empty arrays
2025-06-15 02:25:18 +00:00
Min Idzelis
7b75da1f10 refactor(server): change asset update events to send IDs instead of full assets
- Change on_asset_update event to send asset IDs array instead of full AssetResponseDto
- Add asset.update event emission in asset service for update operations
- Update notification handlers to work with asset IDs
- Improve update logic to avoid duplicate events when metadata is updated
- Update frontend websocket types to match new event format
2025-06-15 02:24:06 +00:00
Min Idzelis
a7559f0691 feat(server): add websocket events for activity changes
- Add 'activity.change' event to event repository
- Emit event when new activity (reaction/comment) is created
- Add notification handler to broadcast activity changes to relevant users
- Update frontend websocket types to include on_activity_change event
- Update tests to mock album repository calls
2025-06-15 02:23:09 +00:00
Min Idzelis
6f2f295cf3 refactor(server): clean up asset repository and add getTrashedIds method
- Remove redundant return statement in asset repository update method
- Add getTrashedIds method to trash repository for retrieving trashed asset IDs by user
2025-06-15 02:22:37 +00:00
Min Idzelis
b3d080f6e8 feat(server,web): add websocket events for album updates
and person face changes
2025-06-11 10:52:43 +00:00
2035 changed files with 52131 additions and 126786 deletions

View File

@@ -55,7 +55,7 @@
"userEnvProbe": "loginInteractiveShell",
"remoteEnv": {
// The location where your uploaded files are stored
"UPLOAD_LOCATION": "${localEnv:UPLOAD_LOCATION:./library}",
"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}",

View File

@@ -11,8 +11,8 @@ services:
- open_api_node_modules:/workspaces/immich/open-api/typescript-sdk/node_modules
- server_node_modules:/workspaces/immich/server/node_modules
- web_node_modules:/workspaces/immich/web/node_modules
- ${UPLOAD_LOCATION}/photos:/data
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
- ${UPLOAD_LOCATION}/photos:/workspaces/immich/server/upload
- ${UPLOAD_LOCATION}/photos/upload:/workspaces/immich/server/upload/upload
- /etc/localtime:/etc/localtime:ro
database:

View File

@@ -7,34 +7,12 @@ export DEV_PORT="${DEV_PORT:-3000}"
# 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."
echo "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]}"
@@ -42,39 +20,38 @@ else
export IMMICH_WORKSPACE="$IMMICH_DIR"
fi
log "Found immich workspace in $IMMICH_WORKSPACE"
log ""
echo "Found immich workspace in $IMMICH_WORKSPACE"
run_cmd() {
echo "$@"
"$@"
}
fix_permissions() {
log "Fixing permissions for ${IMMICH_WORKSPACE}"
echo "Fixing permissions for ${IMMICH_WORKSPACE}"
run_cmd sudo find "${IMMICH_WORKSPACE}/server/upload" -not -path "${IMMICH_WORKSPACE}/server/upload/postgres/*" -not -path "${IMMICH_WORKSPACE}/server/upload/postgres" -exec chown node {} +
# Change ownership for directories that exist
for dir in "${IMMICH_WORKSPACE}/.vscode" \
run_cmd sudo chown node -R "${IMMICH_WORKSPACE}/.vscode" \
"${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 ""
"${IMMICH_WORKSPACE}/web/dist"
}
install_dependencies() {
log "Installing dependencies"
echo "Installing dependencies"
(
cd "${IMMICH_WORKSPACE}" || exit 1
export CI=1 FROZEN=1 OFFLINE=1
run_cmd make setup-web-dev setup-server-dev
run_cmd make install-server
run_cmd make install-open-api
run_cmd make build-open-api
run_cmd make install-web
)
log ""
}

View File

@@ -3,7 +3,6 @@ services:
build:
target: dev-container-server
env_file: !reset []
hostname: immich-dev
environment:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
volumes: !override
@@ -13,8 +12,8 @@ services:
- open_api_node_modules:/workspaces/immich/open-api/typescript-sdk/node_modules
- server_node_modules:/workspaces/immich/server/node_modules
- web_node_modules:/workspaces/immich/web/node_modules
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
- ${UPLOAD_LOCATION-./Library}/photos:/workspaces/immich/server/upload
- ${UPLOAD_LOCATION-./Library}/photos/upload:/workspaces/immich/server/upload/upload
- /etc/localtime:/etc/localtime:ro
immich-web:
@@ -30,9 +29,8 @@ services:
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
- ${UPLOAD_LOCATION-./Library}/postgres:/var/lib/postgresql/data
redis:
env_file: !reset []
@@ -44,6 +42,3 @@ volumes:
open_api_node_modules:
server_node_modules:
web_node_modules:
upload1-devcontainer-volume:
upload2-devcontainer-volume:
postgres-devcontainer-volume:

View File

@@ -3,15 +3,15 @@
# shellcheck disable=SC1091
source /immich-devcontainer/container-common.sh
log "Starting Nest API Server"
log ""
echo "Starting Nest API Server"
cd "${IMMICH_WORKSPACE}/server" || (
log "Immich workspace not found"
echo workspace not found
exit 1
)
while true; do
run_cmd node ./node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch
log "Nest API Server crashed with exit code $?. Respawning in 3s ..."
node ./node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch
echo " Nest API Server crashed with exit code $?. Respawning in 3s ..."
sleep 3
done

View File

@@ -3,20 +3,20 @@
# shellcheck disable=SC1091
source /immich-devcontainer/container-common.sh
log "Starting Immich Web Frontend"
log ""
echo "Starting Immich Web Frontend"
cd "${IMMICH_WORKSPACE}/web" || (
log "Immich Workspace not found"
echo 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..."
echo 'waiting for api server...'
sleep 1
done
while true; do
run_cmd node ./node_modules/.bin/vite dev --host 0.0.0.0 --port "${DEV_PORT}"
log "Web crashed with exit code $?. Respawning in 3s ..."
node ./node_modules/.bin/vite dev --host 0.0.0.0 --port "${DEV_PORT}"
echo "Web crashed with exit code $?. Respawning in 3s ..."
sleep 3
done

View File

@@ -3,18 +3,5 @@
# shellcheck disable=SC1091
source /immich-devcontainer/container-common.sh
log "Setting up Immich dev container..."
fix_permissions
log "Installing npm dependencies (node_modules)..."
install_dependencies
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"

View File

@@ -1,41 +1,33 @@
.vscode/
.github/
.git/
.env*
*.log
*.tmp
*.temp
**/Dockerfile
**/node_modules/
**/.pnpm-store/
**/dist/
**/coverage/
**/build/
design/
docker/
!docker/scripts
docs/
!docs/package.json
!docs/package-lock.json
e2e/
!e2e/package.json
!e2e/package-lock.json
fastlane/
machine-learning/
misc/
mobile/
open-api/typescript-sdk/build/
!open-api/typescript-sdk/package.json
!open-api/typescript-sdk/package-lock.json
cli/coverage/
cli/dist/
cli/node_modules/
open-api/typescript-sdk/build/
open-api/typescript-sdk/node_modules/
server/coverage/
server/node_modules/
server/upload/
server/src/queries
server/dist/
server/www/
web/node_modules/
web/coverage/
web/.svelte-kit
web/build/
web/.env

6
.gitattributes vendored
View File

@@ -12,12 +12,6 @@ mobile/lib/**/*.drift.dart linguist-generated=true
mobile/drift_schemas/main/drift_schema_*.json -diff -merge
mobile/drift_schemas/main/drift_schema_*.json linguist-generated=true
mobile/lib/infrastructure/repositories/db.repository.steps.dart -diff -merge
mobile/lib/infrastructure/repositories/db.repository.steps.dart linguist-generated=true
mobile/test/drift/main/generated/** -diff -merge
mobile/test/drift/main/generated/** linguist-generated=true
open-api/typescript-sdk/fetch-client.ts -diff -merge
open-api/typescript-sdk/fetch-client.ts linguist-generated=true

2
.github/.nvmrc vendored
View File

@@ -1 +1 @@
22.17.1
22.16.0

View File

@@ -1,4 +0,0 @@
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

6
.github/package-lock.json generated vendored
View File

@@ -9,9 +9,9 @@
}
},
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"bin": {

View File

@@ -58,7 +58,7 @@ jobs:
contents: read
# Skip when PR from a fork
if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && needs.pre-job.outputs.should_run == 'true' }}
runs-on: mich
runs-on: macos-14
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -66,40 +66,24 @@ jobs:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
- 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@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: 'zulu'
java-version: '17'
- name: Restore Gradle Cache
id: cache-gradle-restore
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.android/sdk
mobile/android/.gradle
mobile/.dart_tool
key: build-mobile-gradle-${{ runner.os }}-main
cache: 'gradle'
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2.19.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: Create the Keystore
env:
KEY_JKS: ${{ secrets.KEY_JKS }}
working-directory: ./mobile
run: echo $KEY_JKS | base64 -d > android/key.jks
- name: Get Packages
working-directory: ./mobile
@@ -119,30 +103,12 @@ jobs:
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
flutter build apk --release
flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
- name: Publish Android Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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@5a3ec84eff668545956fd18022155c47e93e2684 # v4
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 }}

View File

@@ -38,9 +38,6 @@ jobs:
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Prepare SDK
run: npm ci --prefix ../open-api/typescript-sdk/
- name: Build SDK
@@ -70,7 +67,7 @@ jobs:
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Login to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
@@ -99,7 +96,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
with:
file: cli/Dockerfile
platforms: linux/amd64,linux/arm64

View File

@@ -50,7 +50,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4
uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -63,7 +63,7 @@ jobs:
# 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@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4
uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
# 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
@@ -76,6 +76,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4
uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
with:
category: '/language:${{matrix.language}}'

View File

@@ -131,7 +131,7 @@ jobs:
tag-suffix: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "mich"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@129aeda75a450666ce96e8bc8126652e717917a7 # multi-runner-build-workflow-0.1.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@094bfb927b8cd75b343abaac27b3241be0fccfe9 # multi-runner-build-workflow-0.1.0
permissions:
contents: read
actions: read
@@ -154,7 +154,7 @@ jobs:
name: Build and Push Server
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@129aeda75a450666ce96e8bc8126652e717917a7 # multi-runner-build-workflow-0.1.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@094bfb927b8cd75b343abaac27b3241be0fccfe9 # multi-runner-build-workflow-0.1.0
permissions:
contents: read
actions: read
@@ -177,7 +177,7 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@6b81b1572e466f7f48ba3c823159ce3f4a4d66a6 # success-check-action-0.0.3
with:
needs: ${{ toJSON(needs) }}
@@ -188,6 +188,6 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@6b81b1572e466f7f48ba3c823159ce3f4a4d66a6 # success-check-action-0.0.3
with:
needs: ${{ toJSON(needs) }}

View File

@@ -57,8 +57,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './docs/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install
run: npm ci

View File

@@ -32,8 +32,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Fix formatting
run: make install-all && make format-all

View File

@@ -1,13 +0,0 @@
name: Org Checks
on:
pull_request_review:
pull_request:
jobs:
check-approvals:
name: Check for Team/Admin Review
uses: immich-app/devtools/.github/workflows/required-approval.yml@main
permissions:
pull-requests: read
contents: read

View File

@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- name: Require PR to have a changelog label
uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5.5.1
uses: mheap/github-action-required-labels@fb29a14a076b0f74099f6198f77750e8fc236016 # v5.5.0
with:
mode: exactly
count: 1

View File

@@ -100,7 +100,7 @@ jobs:
name: release-apk-signed
- name: Create draft release
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2
with:
draft: true
tag_name: ${{ env.IMMICH_VERSION }}

View File

@@ -25,8 +25,6 @@ jobs:
with:
node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install deps
run: npm ci
- name: Build

View File

@@ -42,9 +42,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: ./mobile
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -52,29 +49,34 @@ jobs:
persist-credentials: false
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2.19.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
- name: Install dependencies
run: dart pub get
working-directory: ./mobile
- name: Install DCM
uses: CQLabs/setup-dcm@8697ae0790c0852e964a6ef1d768d62a6675481a # v2.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
version: auto
working-directory: ./mobile
run: |
sudo apt-get update
wget -qO- https://dcm.dev/pgp-key.public | sudo gpg --dearmor -o /usr/share/keyrings/dcm.gpg
echo 'deb [signed-by=/usr/share/keyrings/dcm.gpg arch=amd64] https://dcm.dev/debian stable main' | sudo tee /etc/apt/sources.list.d/dart_stable.list
sudo apt-get update
sudo apt-get install dcm
- name: Generate translation file
run: make translation
working-directory: ./mobile
- name: Run Build Runner
run: make build
working-directory: ./mobile
- name: Generate platform API
run: make pigeon
working-directory: ./mobile
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
@@ -90,22 +92,25 @@ jobs:
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 "ERROR: Generated files not up to date! Run make_build inside the mobile directory"
echo "Changed files: ${CHANGED_FILES}"
exit 1
- name: Run dart analyze
run: dart analyze --fatal-infos
working-directory: ./mobile
- name: Run dart format
run: make format
run: dart format lib/ --set-exit-if-changed
working-directory: ./mobile
- name: Run dart custom_lint
run: dart run custom_lint
working-directory: ./mobile
# TODO: Use https://github.com/CQLabs/dcm-action
- name: Run DCM
run: dcm analyze lib --fatal-style --fatal-warnings
run: dcm analyze lib
working-directory: ./mobile
zizmor:
name: zizmor
@@ -129,7 +134,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4
uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
with:
sarif_file: results.sarif
category: zizmor

View File

@@ -84,8 +84,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install
run: npm ci
@@ -103,7 +101,7 @@ jobs:
if: ${{ !cancelled() }}
- name: Run small tests & coverage
run: npm test
run: npm run test:cov
if: ${{ !cancelled() }}
cli-unit-tests:
@@ -127,8 +125,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './cli/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Setup typescript-sdk
run: npm ci && npm run build
@@ -150,7 +146,7 @@ jobs:
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: npm run test
run: npm run test:cov
if: ${{ !cancelled() }}
cli-unit-tests-win:
@@ -174,8 +170,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './cli/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Setup typescript-sdk
run: npm ci && npm run build
@@ -190,7 +184,7 @@ jobs:
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: npm run test
run: npm run test:cov
if: ${{ !cancelled() }}
web-lint:
@@ -214,8 +208,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './web/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: npm ci && npm run build
@@ -257,8 +249,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './web/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: npm ci && npm run build
@@ -272,7 +262,7 @@ jobs:
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: npm run test
run: npm run test:cov
if: ${{ !cancelled() }}
i18n-tests:
@@ -292,8 +282,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './web/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install dependencies
run: npm --prefix=web ci
@@ -338,8 +326,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: npm ci && npm run build
@@ -383,8 +369,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install
run: npm ci
@@ -418,8 +402,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: npm ci && npm run build
@@ -468,8 +450,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: npm ci && npm run build
@@ -481,7 +461,7 @@ jobs:
if: ${{ !cancelled() }}
- name: Install Playwright Browsers
run: npx playwright install chromium --only-shell
run: npx playwright install --with-deps chromium
if: ${{ !cancelled() }}
- name: Docker build
@@ -499,7 +479,7 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@6b81b1572e466f7f48ba3c823159ce3f4a4d66a6 # success-check-action-0.0.3
with:
needs: ${{ toJSON(needs) }}
@@ -516,7 +496,7 @@ jobs:
persist-credentials: false
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2.19.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
@@ -588,8 +568,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './.github/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install
run: npm ci
@@ -609,7 +587,7 @@ jobs:
persist-credentials: false
- name: Run ShellCheck
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
uses: ludeeus/action-shellcheck@master
with:
ignore_paths: >-
**/open-api/**
@@ -631,8 +609,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install server dependencies
run: npm --prefix=server ci
@@ -668,7 +644,7 @@ jobs:
contents: read
services:
postgres:
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3@sha256:ec713143dca1a426eba2e03707c319e2ec3cc9d304ef767f777f8e297dee820c
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.1
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
@@ -694,8 +670,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install server dependencies
run: npm ci
@@ -748,7 +722,6 @@ jobs:
run: |
echo "ERROR: Generated SQL files not up to date!"
echo "Changed files: ${CHANGED_FILES}"
git diff
exit 1
# mobile-integration-tests:

View File

@@ -38,7 +38,7 @@ jobs:
exit 1
fi
- name: Find Pull Request
uses: juliangruber/find-pull-request-action@952b3bb1ddb2dcc0aa3479e98bb1c2d1a922f096 # v1.10.0
uses: juliangruber/find-pull-request-action@48b6133aa6c826f267ebd33aa2d29470f9d9e7d0 # v1.9.0
id: find-pr
with:
branch: chore/translations
@@ -52,6 +52,6 @@ jobs:
permissions: {}
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@6b81b1572e466f7f48ba3c823159ce3f4a4d66a6 # success-check-action-0.0.3
with:
needs: ${{ toJSON(needs) }}

1
.gitignore vendored
View File

@@ -24,4 +24,3 @@ mobile/android/fastlane/report.xml
mobile/ios/fastlane/report.xml
vite.config.js.timestamp-*
.pnpm-store

4
.vscode/launch.json vendored
View File

@@ -7,7 +7,7 @@
"restart": true,
"port": 9231,
"name": "Immich API Server",
"remoteRoot": "/usr/src/app/server",
"remoteRoot": "/usr/src/app",
"localRoot": "${workspaceFolder}/server"
},
{
@@ -16,7 +16,7 @@
"restart": true,
"port": 9230,
"name": "Immich Workers",
"remoteRoot": "/usr/src/app/server",
"remoteRoot": "/usr/src/app",
"localRoot": "${workspaceFolder}/server"
}
]

View File

@@ -1,33 +1,27 @@
dev:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans || make dev-down
dev-down:
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
dev-update:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-scale:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
.PHONY: e2e
e2e:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
e2e-update:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
e2e-down:
docker compose -f ./e2e/docker-compose.yml down --remove-orphans
docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
prod:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
prod-down:
docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans
prod-scale:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
.PHONY: open-api
open-api:
@@ -54,8 +48,6 @@ audit-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
install-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) i
ci-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) ci
build-cli: build-sdk
build-web: build-sdk
build-%: install-%
@@ -90,7 +82,6 @@ test-medium-dev:
build-all: $(foreach M,$(filter-out e2e .github,$(MODULES)),build-$M) ;
install-all: $(foreach M,$(MODULES),install-$M) ;
ci-all: $(foreach M,$(filter-out .github,$(MODULES)),ci-$M) ;
check-all: $(foreach M,$(filter-out sdk cli docs .github,$(MODULES)),check-$M) ;
lint-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),lint-$M) ;
format-all: $(foreach M,$(filter-out sdk,$(MODULES)),format-$M) ;
@@ -99,12 +90,9 @@ hygiene-all: lint-all format-all check-all sql audit-all;
test-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),test-$M) ;
clean:
find . -name "node_modules" -type d -prune -exec rm -rf {} +
find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
find . -name "dist" -type d -prune -exec rm -rf '{}' +
find . -name "build" -type d -prune -exec rm -rf '{}' +
find . -name "svelte-kit" -type d -prune -exec rm -rf '{}' +
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml rm -v -f || true
setup-server-dev: install-server
setup-web-dev: install-sdk build-sdk install-web
docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true
docker compose -f ./e2e/docker-compose.yml rm -v -f || true

View File

@@ -1 +1 @@
22.17.1
22.16.0

View File

@@ -1,2 +0,0 @@
#!/usr/bin/env node
import '../dist/index.js';

569
cli/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
{
"name": "@immich/cli",
"version": "2.2.73",
"version": "2.2.68",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
"bin": {
"immich": "./bin/immich"
"immich": "dist/index.js"
},
"license": "GNU Affero General Public License version 3",
"keywords": [
@@ -21,13 +21,13 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.16.5",
"@types/node": "^22.15.29",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
"commander": "^12.0.0",
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^59.0.0",
"globals": "^16.0.0",
@@ -36,7 +36,7 @@
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
"vite": "^7.0.0",
"vite": "^6.0.0",
"vite-tsconfig-paths": "^5.0.0",
"vitest": "^3.0.0",
"vitest-fetch-mock": "^0.4.0",
@@ -69,6 +69,6 @@
"micromatch": "^4.0.8"
},
"volta": {
"node": "22.17.1"
"node": "22.16.0"
}
}

View File

@@ -2,37 +2,37 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.1"
constraints = "4.52.1"
version = "4.52.0"
constraints = "4.52.0"
hashes = [
"h1:2lHvafwGbLdmc9lYkuJFw3nsInaQjRpjX/JfIRKmq/M=",
"h1:596JomwjrtUrOSreq9NNCS+rj70+jOV+0pfja5MXiTI=",
"h1:7mBOA5TVAIt3qAwPXKCtE0RSYeqij9v30mnksuBbpEg=",
"h1:ELVgzh4kHKBCYdL+2A8JjWS0E1snLUN3Mmz3Vo6qSfw=",
"h1:FGGM5yLFf72g3kSXM3LAN64Gf/AkXr5WCmhixgnP+l4=",
"h1:JupkJbQALcIVoMhHImrLeLDsQR1ET7VJLGC7ONxjqGU=",
"h1:KsaE4JNq+1uV1nJsuTcYar/8lyY6zKS5UBEpfYg3wvc=",
"h1:NHZ5RJIzQDLhie/ykl3uI6UPfNQR9Lu5Ti7JPR6X904=",
"h1:NfAuMbn6LQPLDtJhbzO1MX9JMIGLMa8K6CpekvtsuX8=",
"h1:e+vNKokamDsp/kJvFr2pRudzwEz2r49iZ/oSggw+1LY=",
"h1:jnb4VdfNZ79I3yj7Q8x+JmOT+FxbfjjRfrF0dL0yCW8=",
"h1:kmF//O539d7NuHU7qIxDj7Wz4eJmLKFiI5glwQivldU=",
"h1:s6XriaKwOgV4jvKAGPXkrxhhOQxpNU5dceZwi9Z/1k8=",
"h1:wt3WBEBAeSGTlC9OlnTlAALxRiK4SQgLy0KgBIS7qzs=",
"zh:2fb95e1d3229b9b6c704e1a413c7481c60f139780d9641f657b6eb9b633b90f2",
"zh:379c7680983383862236e9e6e720c3114195c40526172188e88d0ffcf50dfe2e",
"zh:55533beb6cfc02d22ffda8cba8027bc2c841bb172cd637ed0d28323d41395f8f",
"zh:5abd70760e4eb1f37a1c307cbd2989ea7c9ba0afb93818c67c1d363a31f75703",
"zh:699f1c8cd66129176fe659ebf0e6337632a8967a28d2630b6ae5948665c0c2ae",
"zh:69c15acd73c451e89de6477059cda2f3ec200b48ae4b9ff3646c4d389fd3205e",
"zh:6e02b687de21b844f8266dff99e93e7c61fc8eb688f4bbb23803caceb251839e",
"zh:7a51d17b87ed87b7bebf2ad9fc7c3a74f16a1b44eee92c779c08eb89258c0496",
"zh:88ad84436837b0f55302f22748505972634e87400d6902260fd6b7ba1610f937",
"h1:2BEJyXJtYC4B4nda/WCYUmuJYDaYk88F8t1pwPzr0iQ=",
"h1:4IASk5SESeWKQ7JU0+M7KApuF5mZyklvwMXPBabim3c=",
"h1:5ImZxxALSnWfH/4EXw/wFirSmk5Tr0ACmcysy51AafE=",
"h1:6TJ3dxLSin4ZKBJLsZDn95H2ZYnGm8S7GGHvvXuuMQU=",
"h1:IzTUjg9kQ4N3qizP9CjYLeHwjsuGgtxwXvfUQWyOLcA=",
"h1:NTaOQfYINA0YTG/V1/9+SYtgX1it63+cBugj4WK4FWc=",
"h1:PXH48LuJn329sCfMXprdMDk51EZaWFyajVvS03qhQLs=",
"h1:Pi5M+GeoMSN2eJ6QnIeXjBf19O+rby/74CfB2ocpv20=",
"h1:ShXZ2ZjBvm3thfoPPzPT8+OhyismnydQVkUAfI8X12w=",
"h1:WQ9hu0Wge2msBbODfottCSKgu8oKUrw4Opz+fDPVVHk=",
"h1:Z5yXML2DE0uH9UU+M0ut9JMQAORcwVZz1CxBHzeBmao=",
"h1:jqI2qKknpleS3JDSplyGYHMu0u9K/tor1ZOjFwDgEMk=",
"h1:kgfutDh14Q5nw4eg6qGFamFxIiY8Ae0FPKRBLDOzpcI=",
"h1:zCAO7GZmfYhWb+i6TfqlqhMeDyPZWGio2IzEzAh3YTs=",
"zh:19be1a91c982b902c42aba47766860dfa5dc151eed1e95fd39ca642229381ef0",
"zh:1de451c4d1ecf7efbe67b6dace3426ba810711afdd644b0f1b870364c8ae91f8",
"zh:352b4a2120173298622e669258744554339d959ac3a95607b117a48ee4a83238",
"zh:3c6f1346d9154afbd2d558fabb4b0150fc8d559aa961254144fe1bc17fe6032f",
"zh:4c4c92d53fb535b1e0eff26f222bbd627b97d3b4c891ec9c321268676d06152f",
"zh:53276f68006c9ceb7cdb10a6ccf91a5c1eadd1407a28edb5741e84e88d7e29e8",
"zh:7925a97773948171a63d4f65bb81ee92fd6d07a447e36012977313293a5435c9",
"zh:7dfb0a4496cfe032437386d0a2cd9229a1956e9c30bd920923c141b0f0440060",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8d46c3d9f4f7ad20ac6ef01daa63f4e30a2d16dcb1bb5c7c7ee3dc6be38e9ca1",
"zh:913d64e72a4929dae1d4793e2004f4f9a58b138ea337d9d94fa35cafbf06550a",
"zh:c8d93cf86e2e49f6cec665cfe78b82c144cce15a8b2e30f343385fadd1251849",
"zh:cc4f69397d9bc34a528a5609a024c3a48f54f21616c0008792dd417297add955",
"zh:df99cdb8b064aad35ffea77e645cf6541d0b1b2ebc51b6d26c42031de60ab69e",
"zh:8d4aa79f0a414bb4163d771063c70cd991c8fac6c766e685bac2ee12903c5bd6",
"zh:a67540c13565616a7e7e51ee9366e88b0dc60046e1d75c72680e150bd02725bb",
"zh:a936383a4767f5393f38f622e92bf2d0c03fe04b69c284951f27345766c7b31b",
"zh:d4887d73c466ff036eecf50ad6404ba38fd82ea4855296b1846d244b0f13c380",
"zh:e9093c8bd5b6cd99c81666e315197791781b8f93afa14fc2e0f732d1bb2a44b7",
"zh:efd3b3f1ec59a37f635aa1d4efcf178734c2fcf8ddb0d56ea690bec342da8672",
]
}

View File

@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.1"
version = "4.52.0"
}
}
}

View File

@@ -2,37 +2,37 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.1"
constraints = "4.52.1"
version = "4.52.0"
constraints = "4.52.0"
hashes = [
"h1:2lHvafwGbLdmc9lYkuJFw3nsInaQjRpjX/JfIRKmq/M=",
"h1:596JomwjrtUrOSreq9NNCS+rj70+jOV+0pfja5MXiTI=",
"h1:7mBOA5TVAIt3qAwPXKCtE0RSYeqij9v30mnksuBbpEg=",
"h1:ELVgzh4kHKBCYdL+2A8JjWS0E1snLUN3Mmz3Vo6qSfw=",
"h1:FGGM5yLFf72g3kSXM3LAN64Gf/AkXr5WCmhixgnP+l4=",
"h1:JupkJbQALcIVoMhHImrLeLDsQR1ET7VJLGC7ONxjqGU=",
"h1:KsaE4JNq+1uV1nJsuTcYar/8lyY6zKS5UBEpfYg3wvc=",
"h1:NHZ5RJIzQDLhie/ykl3uI6UPfNQR9Lu5Ti7JPR6X904=",
"h1:NfAuMbn6LQPLDtJhbzO1MX9JMIGLMa8K6CpekvtsuX8=",
"h1:e+vNKokamDsp/kJvFr2pRudzwEz2r49iZ/oSggw+1LY=",
"h1:jnb4VdfNZ79I3yj7Q8x+JmOT+FxbfjjRfrF0dL0yCW8=",
"h1:kmF//O539d7NuHU7qIxDj7Wz4eJmLKFiI5glwQivldU=",
"h1:s6XriaKwOgV4jvKAGPXkrxhhOQxpNU5dceZwi9Z/1k8=",
"h1:wt3WBEBAeSGTlC9OlnTlAALxRiK4SQgLy0KgBIS7qzs=",
"zh:2fb95e1d3229b9b6c704e1a413c7481c60f139780d9641f657b6eb9b633b90f2",
"zh:379c7680983383862236e9e6e720c3114195c40526172188e88d0ffcf50dfe2e",
"zh:55533beb6cfc02d22ffda8cba8027bc2c841bb172cd637ed0d28323d41395f8f",
"zh:5abd70760e4eb1f37a1c307cbd2989ea7c9ba0afb93818c67c1d363a31f75703",
"zh:699f1c8cd66129176fe659ebf0e6337632a8967a28d2630b6ae5948665c0c2ae",
"zh:69c15acd73c451e89de6477059cda2f3ec200b48ae4b9ff3646c4d389fd3205e",
"zh:6e02b687de21b844f8266dff99e93e7c61fc8eb688f4bbb23803caceb251839e",
"zh:7a51d17b87ed87b7bebf2ad9fc7c3a74f16a1b44eee92c779c08eb89258c0496",
"zh:88ad84436837b0f55302f22748505972634e87400d6902260fd6b7ba1610f937",
"h1:2BEJyXJtYC4B4nda/WCYUmuJYDaYk88F8t1pwPzr0iQ=",
"h1:4IASk5SESeWKQ7JU0+M7KApuF5mZyklvwMXPBabim3c=",
"h1:5ImZxxALSnWfH/4EXw/wFirSmk5Tr0ACmcysy51AafE=",
"h1:6TJ3dxLSin4ZKBJLsZDn95H2ZYnGm8S7GGHvvXuuMQU=",
"h1:IzTUjg9kQ4N3qizP9CjYLeHwjsuGgtxwXvfUQWyOLcA=",
"h1:NTaOQfYINA0YTG/V1/9+SYtgX1it63+cBugj4WK4FWc=",
"h1:PXH48LuJn329sCfMXprdMDk51EZaWFyajVvS03qhQLs=",
"h1:Pi5M+GeoMSN2eJ6QnIeXjBf19O+rby/74CfB2ocpv20=",
"h1:ShXZ2ZjBvm3thfoPPzPT8+OhyismnydQVkUAfI8X12w=",
"h1:WQ9hu0Wge2msBbODfottCSKgu8oKUrw4Opz+fDPVVHk=",
"h1:Z5yXML2DE0uH9UU+M0ut9JMQAORcwVZz1CxBHzeBmao=",
"h1:jqI2qKknpleS3JDSplyGYHMu0u9K/tor1ZOjFwDgEMk=",
"h1:kgfutDh14Q5nw4eg6qGFamFxIiY8Ae0FPKRBLDOzpcI=",
"h1:zCAO7GZmfYhWb+i6TfqlqhMeDyPZWGio2IzEzAh3YTs=",
"zh:19be1a91c982b902c42aba47766860dfa5dc151eed1e95fd39ca642229381ef0",
"zh:1de451c4d1ecf7efbe67b6dace3426ba810711afdd644b0f1b870364c8ae91f8",
"zh:352b4a2120173298622e669258744554339d959ac3a95607b117a48ee4a83238",
"zh:3c6f1346d9154afbd2d558fabb4b0150fc8d559aa961254144fe1bc17fe6032f",
"zh:4c4c92d53fb535b1e0eff26f222bbd627b97d3b4c891ec9c321268676d06152f",
"zh:53276f68006c9ceb7cdb10a6ccf91a5c1eadd1407a28edb5741e84e88d7e29e8",
"zh:7925a97773948171a63d4f65bb81ee92fd6d07a447e36012977313293a5435c9",
"zh:7dfb0a4496cfe032437386d0a2cd9229a1956e9c30bd920923c141b0f0440060",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8d46c3d9f4f7ad20ac6ef01daa63f4e30a2d16dcb1bb5c7c7ee3dc6be38e9ca1",
"zh:913d64e72a4929dae1d4793e2004f4f9a58b138ea337d9d94fa35cafbf06550a",
"zh:c8d93cf86e2e49f6cec665cfe78b82c144cce15a8b2e30f343385fadd1251849",
"zh:cc4f69397d9bc34a528a5609a024c3a48f54f21616c0008792dd417297add955",
"zh:df99cdb8b064aad35ffea77e645cf6541d0b1b2ebc51b6d26c42031de60ab69e",
"zh:8d4aa79f0a414bb4163d771063c70cd991c8fac6c766e685bac2ee12903c5bd6",
"zh:a67540c13565616a7e7e51ee9366e88b0dc60046e1d75c72680e150bd02725bb",
"zh:a936383a4767f5393f38f622e92bf2d0c03fe04b69c284951f27345766c7b31b",
"zh:d4887d73c466ff036eecf50ad6404ba38fd82ea4855296b1846d244b0f13c380",
"zh:e9093c8bd5b6cd99c81666e315197791781b8f93afa14fc2e0f732d1bb2a44b7",
"zh:efd3b3f1ec59a37f635aa1d4efcf178734c2fcf8ddb0d56ea690bec342da8672",
]
}

View File

@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.1"
version = "4.52.0"
}
}
}

View File

@@ -16,7 +16,7 @@ name: immich-dev
services:
immich-server:
container_name: immich_server
command: ['immich-dev']
command: [ '/usr/src/app/bin/immich-dev' ]
image: immich-server-dev:latest
# extends:
# file: hwaccel.transcoding.yml
@@ -27,11 +27,11 @@ services:
target: dev
restart: unless-stopped
volumes:
- ../server:/usr/src/app/server
- ../open-api:/usr/src/app/open-api
- ${UPLOAD_LOCATION}/photos:/data
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
- /usr/src/app/server/node_modules
- ../server:/usr/src/app
- ../open-api:/usr/src/open-api
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- ${UPLOAD_LOCATION}/photos/upload:/usr/src/app/upload/upload
- /usr/src/app/node_modules
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
@@ -69,20 +69,19 @@ services:
# Needed for rootless docker setup, see https://github.com/moby/moby/issues/45919
# user: 0:0
build:
context: ../
dockerfile: web/Dockerfile
command: ['immich-web']
context: ../web
command: [ '/usr/src/app/bin/immich-web' ]
env_file:
- .env
ports:
- 3000:3000
- 24678:24678
volumes:
- ../web:/usr/src/app/web
- ../i18n:/usr/src/app/i18n
- ../open-api/:/usr/src/app/open-api/
- ../web:/usr/src/app
- ../i18n:/usr/src/i18n
- ../open-api/:/usr/src/open-api/
# - ../../ui:/usr/ui
- /usr/src/app/web/node_modules
- /usr/src/app/node_modules
ulimits:
nofile:
soft: 1048576
@@ -117,13 +116,13 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
image: docker.io/valkey/valkey:8-bookworm@sha256:a19bebed6a91bd5e6e2106fef015f9602a3392deeb7c9ed47548378dcee3dfc2
healthcheck:
test: redis-cli ping || exit 1
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:32324a2f41df5de9efe1af166b7008c3f55646f8d0e00d9550c16c9822366b4a
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.1-pgvectors0.2.0
env_file:
- .env
environment:
@@ -135,7 +134,6 @@ services:
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
ports:
- 5432:5432
shm_size: 128mb
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
# immich-prometheus:
# container_name: immich_prometheus

View File

@@ -20,7 +20,7 @@ services:
context: ../
dockerfile: server/Dockerfile
volumes:
- ${UPLOAD_LOCATION}/photos:/data
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
@@ -56,14 +56,14 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
image: docker.io/valkey/valkey:8-bookworm@sha256:a19bebed6a91bd5e6e2106fef015f9602a3392deeb7c9ed47548378dcee3dfc2
healthcheck:
test: redis-cli ping || exit 1
restart: always
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:32324a2f41df5de9efe1af166b7008c3f55646f8d0e00d9550c16c9822366b4a
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.1-pgvectors0.2.0
env_file:
- .env
environment:
@@ -75,7 +75,6 @@ services:
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
ports:
- 5432:5432
shm_size: 128mb
restart: always
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
@@ -83,7 +82,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:63805ebb8d2b3920190daf1cb14a60871b16fd38bed42b857a3182bc621f4996
image: prom/prometheus@sha256:9abc6cf6aea7710d163dbb28d8eeb7dc5baef01e38fa4cd146a406dd9f07f70d
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
@@ -92,10 +91,10 @@ services:
# add data source for http://immich-prometheus:9090 to get started
immich-grafana:
container_name: immich_grafana
command: ['./run.sh', '-disable-reporting']
command: [ './run.sh', '-disable-reporting' ]
ports:
- 3000:3000
image: grafana/grafana:12.0.2-ubuntu@sha256:0512d81cdeaaff0e370a9aa66027b465d1f1f04379c3a9c801a905fabbdbc7a5
image: grafana/grafana:12.0.1-ubuntu@sha256:65575bb9c761335e2ff30e364f21d38632e3b2e75f5f81d83cc92f44b9bbc055
volumes:
- grafana-data:/var/lib/grafana

View File

@@ -18,7 +18,7 @@ services:
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
volumes:
# Do not edit the next line. If you want to change the media storage location on your system, edit the value of UPLOAD_LOCATION in the .env file
- ${UPLOAD_LOCATION}:/data
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
@@ -49,14 +49,14 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
image: docker.io/valkey/valkey:8-bookworm@sha256:a19bebed6a91bd5e6e2106fef015f9602a3392deeb7c9ed47548378dcee3dfc2
healthcheck:
test: redis-cli ping || exit 1
restart: always
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:32324a2f41df5de9efe1af166b7008c3f55646f8d0e00d9550c16c9822366b4a
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.1-pgvectors0.2.0
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
@@ -67,7 +67,6 @@ services:
volumes:
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
shm_size: 128mb
restart: always
volumes:

View File

@@ -1 +1 @@
22.17.1
22.16.0

View File

@@ -180,7 +180,7 @@ services:
...
volumes:
# Do not edit the next line. If you want to change the media storage location on your system, edit the value of UPLOAD_LOCATION in the .env file
- ${UPLOAD_LOCATION}:/data
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
+ - originals:/usr/src/app/originals
...
@@ -490,7 +490,7 @@ You can also scan the Postgres database file structure for errors:
<details>
<summary>Scan for file structure errors</summary>
```bash
docker exec -it immich_postgres pg_amcheck --username=<DB_USERNAME> --heapallindexed --parent-check --rootdescend --progress --all --install-missing
docker exec -it immich_postgres pg_amcheck --username=postgres --heapallindexed --parent-check --rootdescend --progress --all --install-missing
```
A normal result will end something like this and return with an exit code of `0`:

View File

@@ -57,7 +57,7 @@ Then please follow the steps in the following section for restoring the database
<TabItem value="Linux system" label="Linux system" default>
```bash title='Backup'
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=<DB_USERNAME> | gzip > "/path/to/backup/dump.sql.gz"
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres | gzip > "/path/to/backup/dump.sql.gz"
```
```bash title='Restore'
@@ -79,7 +79,7 @@ docker compose up -d # Start remainder of Immich apps
<TabItem value="Windows system (PowerShell)" label="Windows system (PowerShell)">
```powershell title='Backup'
[System.IO.File]::WriteAllLines("C:\absolute\path\to\backup\dump.sql", (docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=<DB_USERNAME>))
[System.IO.File]::WriteAllLines("C:\absolute\path\to\backup\dump.sql", (docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres))
```
```powershell title='Restore'
@@ -150,10 +150,12 @@ for more info read the [release notes](https://github.com/immich-app/immich/rele
- Preview images (small thumbnails and large previews) for each asset and thumbnails for recognized faces.
- Stored in `UPLOAD_LOCATION/thumbs/<userID>`.
- **Encoded Assets:**
- Videos that have been re-encoded from the original for wider compatibility. The original is not removed.
- Stored in `UPLOAD_LOCATION/encoded-video/<userID>`.
- **Postgres**
- The Immich database containing all the information to allow the system to function properly.
**Note:** This folder will only appear to users who have made the changes mentioned in [v1.102.0](https://github.com/immich-app/immich/discussions/8930) (an optional, non-mandatory change) or who started with this version.
- Stored in `DB_DATA_LOCATION`.
@@ -199,6 +201,7 @@ When you turn off the storage template engine, it will leave the assets in `UPLO
- Temporarily located in `UPLOAD_LOCATION/upload/<userID>`.
- Transferred to `UPLOAD_LOCATION/library/<userID>` upon successful upload.
- **Postgres**
- The Immich database containing all the information to allow the system to function properly.
**Note:** This folder will only appear to users who have made the changes mentioned in [v1.102.0](https://github.com/immich-app/immich/discussions/8930) (an optional, non-mandatory change) or who started with this version.
- Stored in `DB_DATA_LOCATION`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -46,12 +46,6 @@ services:
When a new asset is uploaded it kicks off a series of jobs, which include metadata extraction, thumbnail generation, machine learning tasks, and storage template migration, if enabled. To view the status of a job navigate to the Administration -> Jobs page.
Additionally, some jobs run on a schedule, which is every night at midnight. This schedule, with the exception of [External Libraries](/docs/features/libraries) scanning, cannot be changed.
<img src={require('./img/admin-jobs.webp').default} width="60%" title="Admin jobs" />
Additionally, some jobs (such as memories generation) run on a schedule, which is every night at midnight by default. To change when they run or enable/disable a job navigate to System Settings -> [Nightly Tasks Settings](https://my.immich.app/admin/system-settings?isOpen=nightly-tasks).
<img src={require('./img/admin-nightly-tasks.webp').default} width="60%" title="Admin nightly tasks" />
:::note
Some jobs ([External Libraries](/docs/features/libraries) scanning, Database Dump) are configured in their own sections in System Settings.
:::

View File

@@ -20,6 +20,7 @@ Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an i
Before enabling OAuth in Immich, a new client application needs to be configured in the 3rd-party authentication server. While the specifics of this setup vary from provider to provider, the general approach should be the same.
1. Create a new (Client) Application
1. The **Provider** type should be `OpenID Connect` or `OAuth2`
2. The **Client type** should be `Confidential`
3. The **Application** type should be `Web`
@@ -28,6 +29,7 @@ Before enabling OAuth in Immich, a new client application needs to be configured
2. Configure Redirect URIs/Origins
The **Sign-in redirect URIs** should include:
- `app.immich:///oauth-callback` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx)
- `http://DOMAIN:PORT/auth/login` - for logging in with OAuth from the Web Client
- `http://DOMAIN:PORT/user-settings` - for manually linking OAuth in the Web Client
@@ -35,17 +37,21 @@ Before enabling OAuth in Immich, a new client application needs to be configured
Redirect URIs should contain all the domains you will be using to access Immich. Some examples include:
Mobile
- `app.immich:///oauth-callback` (You **MUST** include this for iOS and Android mobile apps to work properly)
Localhost
- `http://localhost:2283/auth/login`
- `http://localhost:2283/user-settings`
Local IP
- `http://192.168.0.200:2283/auth/login`
- `http://192.168.0.200:2283/user-settings`
Hostname
- `https://immich.example.com/auth/login`
- `https://immich.example.com/user-settings`
@@ -62,7 +68,6 @@ Once you have a new OAuth client application configured, Immich can be configure
| Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| Signing Algorithm | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label**¹** |
| Role Claim | string | immich_role | Claim mapping for the user's role. (should return "user" or "admin")**¹** |
| Storage Quota Claim | string | immich_quota | Claim mapping for the user's storage**¹** |
| Default Storage Quota (GiB) | number | 0 | Default quota for user without storage quota claim (Enter 0 for unlimited quota) |
| Button Text | string | Login with OAuth | Text for the OAuth button on the web |

View File

@@ -64,13 +64,7 @@ COMMIT;
### Updating VectorChord
When installing a new version of VectorChord, you will need to manually update the extension and reindex by connecting to the Immich database and running:
```
ALTER EXTENSION vchord UPDATE;
REINDEX INDEX face_index;
REINDEX INDEX clip_index;
```
When installing a new version of VectorChord, you will need to manually update the extension by connecting to the Immich database and running `ALTER EXTENSION vchord UPDATE;`.
## Migrating to VectorChord
@@ -82,8 +76,6 @@ Support for pgvecto.rs will be dropped in a later release, hence we recommend al
The easiest option is to have both extensions installed during the migration:
<details>
<summary>Migration steps (automatic)</summary>
1. Ensure you still have pgvecto.rs installed
2. Install `pgvector` (`>= 0.7.0, < 1.0.0`). The easiest way to do this is on Debian/Ubuntu by adding the [PostgreSQL Apt repository][pg-apt] and then running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`)
3. [Install VectorChord][vchord-install]
@@ -97,12 +89,8 @@ The easiest option is to have both extensions installed during the migration:
11. Restart the Postgres database
12. Uninstall pgvecto.rs (e.g. `apt-get purge vectors-pg14` on Debian-based environments, replacing `pg14` as appropriate). `pgvector` must remain installed as it provides the data types used by `vchord`
</details>
If it is not possible to have both VectorChord and pgvecto.rs installed at the same time, you can perform the migration with more manual steps:
<details>
<summary>Migration steps (manual)</summary>
1. While pgvecto.rs is still installed, run the following SQL command using psql or your choice of database client. Take note of the number outputted by this command as you will need it later
```sql
@@ -135,20 +123,14 @@ ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE vector(512);
5. Start Immich and let it create new indices using VectorChord
</details>
### Migrating from pgvector
<details>
<summary>Migration steps</summary>
1. Ensure you have at least 0.7.0 of pgvector installed. If it is below that, please upgrade it and run the SQL command `ALTER EXTENSION vector UPDATE;` using psql or your choice of database client
2. Follow the Prerequisites to install VectorChord
3. If Immich does not have superuser permissions, run the SQL command `CREATE EXTENSION vchord CASCADE;`
4. Remove the `DB_VECTOR_EXTENSION=pgvector` environmental variable as it will make Immich still use pgvector if set
5. Start Immich and let it create new indices using VectorChord
</details>
Note that VectorChord itself uses pgvector types, so you should not uninstall pgvector after following these steps.
[vchord-install]: https://docs.vectorchord.ai/vectorchord/getting-started/installation.html

View File

@@ -2,17 +2,16 @@
The `immich-server` docker image comes preinstalled with an administrative CLI (`immich-admin`) that supports the following commands:
| Command | Description |
| ------------------------ | ------------------------------------------------------------- |
| `help` | Display help |
| `reset-admin-password` | Reset the password for the admin user |
| `disable-password-login` | Disable password login |
| `enable-password-login` | Enable password login |
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `version` | Print Immich version |
| `change-media-location` | Change database file paths to align with a new media location |
| Command | Description |
| ------------------------ | ------------------------------------- |
| `help` | Display help |
| `reset-admin-password` | Reset the password for the admin user |
| `disable-password-login` | Disable password login |
| `enable-password-login` | Enable password login |
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `version` | Print Immich version |
## How to run a command
@@ -89,21 +88,3 @@ Print Immich Version
immich-admin version
v1.129.0
```
Change media location
```
immich-admin change-media-location
? Enter the previous value of IMMICH_MEDIA_LOCATION: /data
? Enter the new value of IMMICH_MEDIA_LOCATION: /my-data
...
Previous value: /data
Current value: /my-data
Changing database paths from "/data/*" to "/my-data/*"
? Do you want to proceed? [Y/n] y
Database file paths updated successfully! 🎉
...
```

View File

@@ -7,7 +7,7 @@ sidebar_position: 3
Dev Containers provide a consistent, reproducible development environment using Docker containers. With a single click, you can get started with an Immich development environment on Mac, Linux, Windows, or in the cloud using GitHub Codespaces.
Get started fast!
[![Open in VSCode Containers](https://img.shields.io/static/v1?label=VSCode%20DevContainer&message=Immich&color=blue)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/immich-app/immich/)
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/immich-app/immich/)
@@ -71,7 +71,7 @@ cd immich
The immich dev containers read environment variables from your shell environment, not from `.env` files. This allows them to work in cloud environments without pre-configuration.
:::important Configuration
:::important Required Configuration
When running locally, and if you want to create (or use an existing) DB and/or photo storage folder, you must set the `UPLOAD_LOCATION` variable in your shell environment before launching the Dev Container. This determines where uploaded files are stored and also where the DB stores it data.
```bash
@@ -88,10 +88,6 @@ source ~/.bashrc
### Step 3: Launch the Dev Container
:::tip
Immich development makes extensive use of specialized [base images](https://github.com/immich-app/base-images) for its docker-compose based development. For this reason, you won't be able to use VSCode's **_Clone Repository in a Container Volume_** command.
:::
#### Using VS Code UI:
1. Open the cloned repository in VS Code
@@ -203,11 +199,13 @@ To use your SSH key for commit signing, see the [GitHub guide on SSH commit sign
When the Dev Container starts, it automatically:
1. **Runs post-create script** (`container-server-post-create.sh`):
- Adjusts file permissions for the `node` user
- Installs dependencies: `npm install` in all packages
- Builds TypeScript SDK: `npm run build` in `open-api/typescript-sdk`
2. **Starts development servers** via VS Code tasks:
- `Immich API Server (Nest)` - API server with hot-reloading on port 2283
- `Immich Web Server (Vite)` - Web frontend with hot-reloading on port 3000
- Both servers watch for file changes and recompile automatically
@@ -337,12 +335,14 @@ make install-all # Install all dependencies
The Dev Container is pre-configured for debugging:
1. **API Server Debugging**:
- Set breakpoints in VS Code
- Press `F5` or use "Run and Debug" panel
- Select "Attach to Server" configuration
- Debug port: 9231
2. **Worker Debugging**:
- Use "Attach to Workers" configuration
- Debug port: 9230
@@ -428,6 +428,7 @@ While the Dev Container focuses on server and web development, you can connect m
```
2. **Configure mobile app**:
- Server URL: `http://YOUR_IP:2283/api`
- Ensure firewall allows port 2283

View File

@@ -38,19 +38,6 @@ Run all server checks with `npm run check:all`
You can use `npm run __:fix` to potentially correct some issues automatically for `npm run format` and `lint`.
:::
## Mobile Checks
The following commands must be executed from within the mobile app directory of the codebase.
- [ ] `make build` (auto-generate files using build_runner)
- [ ] `make analyze` (static analysis via Dart Analyzer and DCM)
- [ ] `make format` (formatting via Dart Formatter)
- [ ] `make test` (unit tests)
:::info Auto Fix
You can use `dart fix --apply` and `dcm fix lib` to potentially correct some issues automatically for `make analyze`.
:::
## OpenAPI
The OpenAPI client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file. Note that you should not modify this file directly as it is auto-generated. See [OpenAPI](/docs/developer/open-api.md) for more details.

View File

@@ -2,7 +2,7 @@
Folder view provides an additional view besides the timeline that is similar to a file explorer. It allows you to navigate through the folders and files in the library. This feature is handy for a highly curated and customized external library or a nicely configured storage template.
You can enable this feature under [`Account Settings > Features > Folders`](https://my.immich.app/user-settings?isOpen=feature+folders)
You can enable this feature under [`Account Settings > Features > Folder View`](https://my.immich.app/user-settings?isOpen=feature+folders)
## Enable folder view

View File

@@ -56,9 +56,9 @@ Internally, Immich uses the [glob](https://www.npmjs.com/package/glob) package t
### Automatic watching (EXPERIMENTAL)
This feature is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan.
This feature - currently hidden in the config file - is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan.
If your photos are on a network drive, automatic file watching likely won't work. In that case, you will have to rely on a [periodic library refresh](#set-custom-scan-interval) to pull in your changes.
If your photos are on a network drive, automatic file watching likely won't work. In that case, you will have to rely on a periodic library refresh to pull in your changes.
#### Troubleshooting
@@ -72,9 +72,7 @@ In rare cases, the library watcher can hang, preventing Immich from starting up.
### Nightly job
There is an automatic scan job that is scheduled to run once a day. Its schedule is configurable, see [Set Custom Scan Interval](#set-custom-scan-interval).
This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library management page.
There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library management page.
## Usage
@@ -93,7 +91,7 @@ The `immich-server` container will need access to the gallery. Modify your docke
```diff title="docker-compose.yml"
immich-server:
volumes:
- ${UPLOAD_LOCATION}:/data
- ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - /mnt/nas/christmas-trip:/mnt/media/christmas-trip:ro
+ - /home/user/old-pics:/mnt/media/old-pics:ro
+ - /mnt/media/videos:/mnt/media/videos:ro
@@ -114,7 +112,7 @@ _Remember to run `docker compose up -d` to register the changes. Make sure you c
These actions must be performed by the Immich administrator.
- Click on your avatar in the upper right corner
- Click on your avatar on the upper right corner
- Click on Administration -> External Libraries
- Click on Create an external library…
- Select which user owns the library, this can not be changed later
@@ -161,7 +159,9 @@ Within seconds, the assets from the old-pics and videos folders should show up i
Folder view provides an additional view besides the timeline that is similar to a file explorer. It allows you to navigate through the folders and files in the library. This feature is handy for a highly curated and customized external library or a nicely configured storage template.
You can enable this feature under [`Account Settings > Features > Folders`](https://my.immich.app/user-settings?isOpen=feature+folders)
You can enable this feature under [`Account Settings > Features > Folder View`](https://my.immich.app/user-settings?isOpen=feature+folders)
The UI is currently only available for the web; mobile will come in a subsequent release.
<img src={require('./img/folder-view-1.webp').default} width="100%" title='Folder-view' />
@@ -171,7 +171,7 @@ You can enable this feature under [`Account Settings > Features > Folders`](http
Only an admin can do this.
:::
You can define a custom interval for the trigger external library rescan under Administration -> Settings -> External Library.
You can define a custom interval for the trigger external library rescan under Administration -> Settings -> Library.
You can set the scanning interval using the preset or cron format. For more information you can refer to [Crontab Guru](https://crontab.guru/).
<img src={require('./img/library-custom-scan-interval.webp').default} width="75%" title='Set custom scan interval for external library' />

View File

@@ -88,9 +88,9 @@ It will only reflect files you add.
:::
If the same asset is in more than one album it will only sync to the first album it's in, after that it won't sync again even if the user clicks sync albums manually.
To overcome this limitation, the files must be removed from the ignore list by
To overcome this limitation, the files must be removed from the blacklist by
App settings -> Advanced -> Duplicate Assets -> Clear
:::info
Cleaning duplicate assets from the list will cause all the previously uploaded duplicate files to be re-uploaded, the files will not actually be uploaded and will be rejected on the server side (due to duplication) but will be synchronized to the album and at the end will be added to the ignore list again at the end of the synchronization.
Cleaning duplicate assets from the list will cause all the previously uploaded duplicate files to be re-uploaded, the files will not actually be uploaded and will be rejected on the server side (due to duplication) but will be synchronized to the album and at the end will be added to the black list again at the end of the synchronization.
:::

View File

@@ -16,7 +16,7 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a
| `HEIC` | `.heic` | :white_check_mark: | |
| `HEIF` | `.heif` | :white_check_mark: | |
| `JPEG 2000` | `.jp2` | :white_check_mark: | |
| `JPEG` | `.jpeg` `.jpg` `.jpe` `.insp` | :white_check_mark: | |
| `JPEG` | `.webp` `.jpg` `.jpe` `.insp` | :white_check_mark: | |
| `JPEG XL` | `.jxl` | :white_check_mark: | |
| `PNG` | `.png` | :white_check_mark: | |
| `PSD` | `.psd` | :white_check_mark: | Adobe Photoshop |

View File

@@ -27,11 +27,11 @@ After defining the locations of these files, we will edit the `docker-compose.ym
services:
immich-server:
volumes:
- ${UPLOAD_LOCATION}:/data
+ - ${THUMB_LOCATION}:/data/thumbs
+ - ${ENCODED_VIDEO_LOCATION}:/data/encoded-video
+ - ${PROFILE_LOCATION}:/data/profile
+ - ${BACKUP_LOCATION}:/data/backups
- ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - ${THUMB_LOCATION}:/usr/src/app/upload/thumbs
+ - ${ENCODED_VIDEO_LOCATION}:/usr/src/app/upload/encoded-video
+ - ${PROFILE_LOCATION}:/usr/src/app/upload/profile
+ - ${BACKUP_LOCATION}:/usr/src/app/upload/backups
- /etc/localtime:/etc/localtime:ro
```
@@ -44,7 +44,7 @@ docker compose up -d
:::note
Because of the underlying properties of docker bind mounts, it is not recommended to mount the `upload/` and `library/` folders as separate bind mounts if they are on the same device.
For this reason, we mount the HDD or the network storage (NAS) to `/data` and then mount the folders we want to access under that folder.
For this reason, we mount the HDD or the network storage (NAS) to `/usr/src/app/upload` and then mount the folders we want to access under that folder.
The `thumbs/` folder contains both the small thumbnails displayed in the timeline and the larger previews shown when clicking into an image. These cannot be separated.

View File

@@ -12,131 +12,105 @@ Run `docker exec -it immich_postgres psql --dbname=<DB_DATABASE_NAME> --username
## Assets
### Name
:::note
The `"originalFileName"` column is the name of the file at time of upload, including the extension.
:::
```sql title="Find by original filename"
SELECT * FROM "asset" WHERE "originalFileName" = 'PXL_20230903_232542848.jpg';
SELECT * FROM "asset" WHERE "originalFileName" LIKE 'PXL_%'; -- all files starting with PXL_
SELECT * FROM "asset" WHERE "originalFileName" LIKE '%_2023_%'; -- all files with _2023_ in the middle
SELECT * FROM "assets" WHERE "originalFileName" = 'PXL_20230903_232542848.jpg';
SELECT * FROM "assets" WHERE "originalFileName" LIKE 'PXL_%'; -- all files starting with PXL_
SELECT * FROM "assets" WHERE "originalFileName" LIKE '%_2023_%'; -- all files with _2023_ in the middle
```
```sql title="Find by path"
SELECT * FROM "asset" WHERE "originalPath" = 'upload/library/admin/2023/2023-09-03/PXL_2023.jpg';
SELECT * FROM "asset" WHERE "originalPath" LIKE 'upload/library/admin/2023/%';
SELECT * FROM "assets" WHERE "originalPath" = 'upload/library/admin/2023/2023-09-03/PXL_2023.jpg';
SELECT * FROM "assets" WHERE "originalPath" LIKE 'upload/library/admin/2023/%';
```
### ID
```sql title="Find by ID"
SELECT * FROM "asset" WHERE "id" = '9f94e60f-65b6-47b7-ae44-a4df7b57f0e9';
SELECT * FROM "assets" WHERE "id" = '9f94e60f-65b6-47b7-ae44-a4df7b57f0e9';
```
```sql title="Find by partial ID"
SELECT * FROM "asset" WHERE "id"::text LIKE '%ab431d3a%';
SELECT * FROM "assets" WHERE "id"::text LIKE '%ab431d3a%';
```
### Checksum
:::note
You can calculate the checksum for a particular file by using the command `sha1sum <filename>`.
:::
```sql title="Find by checksum (SHA-1)"
SELECT encode("checksum", 'hex') FROM "asset";
SELECT * FROM "asset" WHERE "checksum" = decode('69de19c87658c4c15d9cacb9967b8e033bf74dd1', 'hex');
SELECT * FROM "asset" WHERE "checksum" = '\x69de19c87658c4c15d9cacb9967b8e033bf74dd1'; -- alternate notation
SELECT encode("checksum", 'hex') FROM "assets";
SELECT * FROM "assets" WHERE "checksum" = decode('69de19c87658c4c15d9cacb9967b8e033bf74dd1', 'hex');
SELECT * FROM "assets" WHERE "checksum" = '\x69de19c87658c4c15d9cacb9967b8e033bf74dd1'; -- alternate notation
```
```sql title="Find duplicate assets with identical checksum (SHA-1) (excluding trashed files)"
SELECT T1."checksum", array_agg(T2."id") ids FROM "asset" T1
INNER JOIN "asset" T2 ON T1."checksum" = T2."checksum" AND T1."id" != T2."id" AND T2."deletedAt" IS NULL
SELECT T1."checksum", array_agg(T2."id") ids FROM "assets" T1
INNER JOIN "assets" T2 ON T1."checksum" = T2."checksum" AND T1."id" != T2."id" AND T2."deletedAt" IS NULL
WHERE T1."deletedAt" IS NULL GROUP BY T1."checksum";
```
### Metadata
```sql title="Live photos"
SELECT * FROM "asset" WHERE "livePhotoVideoId" IS NOT NULL;
SELECT * FROM "assets" WHERE "livePhotoVideoId" IS NOT NULL;
```
```sql title="By description"
SELECT "asset".*, "asset_exif"."description" FROM "asset_exif"
JOIN "asset" ON "asset"."id" = "asset_exif"."assetId"
WHERE TRIM("asset_exif"."description") <> ''; -- all files with a description
SELECT "asset".*, "asset_exif"."description" FROM "asset_exif"
JOIN "asset" ON "asset"."id" = "asset_exif"."assetId"
WHERE "asset_exif"."description" ILIKE '%string to match%'; -- search by string
SELECT "assets".*, "exif"."description" FROM "exif"
JOIN "assets" ON "assets"."id" = "exif"."assetId"
WHERE TRIM("exif"."description") <> ''; -- all files with a description
SELECT "assets".*, "exif"."description" FROM "exif"
JOIN "assets" ON "assets"."id" = "exif"."assetId"
WHERE "exif"."description" ILIKE '%string to match%'; -- search by string
```
```sql title="Without metadata"
SELECT "asset".* FROM "asset_exif"
LEFT JOIN "asset" ON "asset"."id" = "asset_exif"."assetId"
WHERE "asset_exif"."assetId" IS NULL;
SELECT "assets".* FROM "exif"
LEFT JOIN "assets" ON "assets"."id" = "exif"."assetId"
WHERE "exif"."assetId" IS NULL;
```
```sql title="size < 100,000 bytes, smallest to largest"
SELECT * FROM "asset"
JOIN "asset_exif" ON "asset"."id" = "asset_exif"."assetId"
WHERE "asset_exif"."fileSizeInByte" < 100000
ORDER BY "asset_exif"."fileSizeInByte" ASC;
SELECT * FROM "assets"
JOIN "exif" ON "assets"."id" = "exif"."assetId"
WHERE "exif"."fileSizeInByte" < 100000
ORDER BY "exif"."fileSizeInByte" ASC;
```
### Type
```sql title="Without thumbnails"
SELECT * FROM "assets" WHERE "assets"."previewPath" IS NULL OR "assets"."thumbnailPath" IS NULL;
```
```sql title="By type"
SELECT * FROM "asset" WHERE "asset"."type" = 'VIDEO';
SELECT * FROM "asset" WHERE "asset"."type" = 'IMAGE';
SELECT * FROM "assets" WHERE "assets"."type" = 'VIDEO';
SELECT * FROM "assets" WHERE "assets"."type" = 'IMAGE';
```
```sql title="Count by type"
SELECT "asset"."type", COUNT(*) FROM "asset" GROUP BY "asset"."type";
SELECT "assets"."type", COUNT(*) FROM "assets" GROUP BY "assets"."type";
```
```sql title="Count by type (per user)"
SELECT "user"."email", "asset"."type", COUNT(*) FROM "asset"
JOIN "user" ON "asset"."ownerId" = "user"."id"
GROUP BY "asset"."type", "user"."email" ORDER BY "user"."email";
SELECT "users"."email", "assets"."type", COUNT(*) FROM "assets"
JOIN "users" ON "assets"."ownerId" = "users"."id"
GROUP BY "assets"."type", "users"."email" ORDER BY "users"."email";
```
## Tags
```sql title="Count by tag"
SELECT "t"."value" AS "tag_name", COUNT(*) AS "number_assets" FROM "tag" "t"
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagsId" JOIN "asset" "a" ON "ta"."assetsId" = "a"."id"
WHERE "a"."visibility" != 'hidden'
GROUP BY "t"."value" ORDER BY "number_assets" DESC;
```
```sql title="Count by tag (per user)"
SELECT "t"."value" AS "tag_name", "u"."email" as "user_email", COUNT(*) AS "number_assets" FROM "tag" "t"
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagsId" JOIN "asset" "a" ON "ta"."assetsId" = "a"."id" JOIN "user" "u" ON "a"."ownerId" = "u"."id"
WHERE "a"."visibility" != 'hidden'
GROUP BY "t"."value", "u"."email" ORDER BY "number_assets" DESC;
```sql title="Failed file movements"
SELECT * FROM "move_history";
```
## Users
```sql title="List all users"
SELECT * FROM "user";
SELECT * FROM "users";
```
```sql title="Get owner info from asset ID"
SELECT "user".* FROM "user" JOIN "asset" ON "user"."id" = "asset"."ownerId" WHERE "asset"."id" = 'fa310b01-2f26-4b7a-9042-d578226e021f';
SELECT "users".* FROM "users" JOIN "assets" ON "users"."id" = "assets"."ownerId" WHERE "assets"."id" = 'fa310b01-2f26-4b7a-9042-d578226e021f';
```
## Persons
```sql title="Delete person and unset it for the faces it was associated with"
DELETE FROM "person" WHERE "name" = 'PersonNameHere';
```
## System
### Config
## System Config
```sql title="Custom settings"
SELECT "key", "value" FROM "system_metadata" WHERE "key" = 'system-config';
@@ -144,14 +118,10 @@ SELECT "key", "value" FROM "system_metadata" WHERE "key" = 'system-config';
(Only used when not using the [config file](/docs/install/config-file))
### File properties
## Persons
```sql title="Without thumbnails"
SELECT * FROM "asset" WHERE "asset"."previewPath" IS NULL OR "asset"."thumbnailPath" IS NULL;
```
```sql title="Failed file movements"
SELECT * FROM "move_history";
```sql title="Delete person and unset it for the faces it was associated with"
DELETE FROM "person" WHERE "name" = 'PersonNameHere';
```
## Postgres internal

View File

@@ -12,7 +12,7 @@ If you want Immich to be able to delete the images in the external library or ad
```diff
immich-server:
volumes:
- ${UPLOAD_LOCATION}:/data
- ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - /home/user/photos1:/home/user/photos1:ro
+ - /mnt/photos2:/mnt/photos2:ro # you can delete this line if you only have one mount point, or you can add more lines if you have more than two
```
@@ -41,7 +41,7 @@ In the Immich web UI:
- Click Add path
<img src={require('./img/add-path-button.webp').default} width="50%" title="Add Path button" />
- Enter **/home/user/photos1** as the path and click Add
- Enter **/usr/src/app/external** as the path and click Add
<img src={require('./img/add-path-field.webp').default} width="50%" title="Add Path field" />
- Save the new path

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -52,9 +52,9 @@ REMOTE_BACKUP_PATH="/path/to/remote/backup/directory"
### Local
# Backup Immich database
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=<DB_USERNAME> > "$UPLOAD_LOCATION"/database-backup/immich-database.sql
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres > "$UPLOAD_LOCATION"/database-backup/immich-database.sql
# For deduplicating backup programs such as Borg or Restic, compressing the content can increase backup size by making it harder to deduplicate. If you are using a different program or still prefer to compress, you can use the following command instead:
# docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=<DB_USERNAME> | /usr/bin/gzip --rsyncable > "$UPLOAD_LOCATION"/database-backup/immich-database.sql.gz
# docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres | /usr/bin/gzip --rsyncable > "$UPLOAD_LOCATION"/database-backup/immich-database.sql.gz
### Append to local Borg repository
borg create "$BACKUP_PATH/immich-borg::{now}" "$UPLOAD_LOCATION" --exclude "$UPLOAD_LOCATION"/thumbs/ --exclude "$UPLOAD_LOCATION"/encoded-video/

View File

@@ -34,7 +34,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `./upload`<sup>\*3</sup> | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
@@ -49,6 +49,9 @@ These environment variables are used by the `docker-compose.yml` file and do **N
\*2: This path is where the Immich code looks for the files, which is internal to the docker container. Setting it to a path on your host will certainly break things, you should use the `UPLOAD_LOCATION` variable instead.
\*3: With the default `WORKDIR` of `/usr/src/app`, this path will resolve to `/usr/src/app/upload`.
It only needs to be set if the Immich deployment method is changing.
## Workers
| Variable | Description | Default | Containers |
@@ -69,25 +72,22 @@ Information on the current workers can be found [here](/docs/administration/jobs
## Database
| Variable | Description | Default | Containers |
| :---------------------------------- | :------------------------------------------------------------------------------------- | :--------: | :----------------------------- |
| `DB_URL` | Database URL | | server |
| `DB_HOSTNAME` | Database host | `database` | server |
| `DB_PORT` | Database port | `5432` | server |
| `DB_USERNAME` | Database user | `postgres` | server, database<sup>\*1</sup> |
| `DB_PASSWORD` | Database password | `postgres` | server, database<sup>\*1</sup> |
| `DB_DATABASE_NAME` | Database name | `immich` | server, database<sup>\*1</sup> |
| `DB_SSL_MODE` | Database SSL mode | | server |
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | server |
| Variable | Description | Default | Containers |
| :---------------------------------- | :--------------------------------------------------------------------------- | :--------: | :----------------------------- |
| `DB_URL` | Database URL | | server |
| `DB_HOSTNAME` | Database host | `database` | server |
| `DB_PORT` | Database port | `5432` | server |
| `DB_USERNAME` | Database user | `postgres` | server, database<sup>\*1</sup> |
| `DB_PASSWORD` | Database password | `postgres` | server, database<sup>\*1</sup> |
| `DB_DATABASE_NAME` | Database name | `immich` | server, database<sup>\*1</sup> |
| `DB_SSL_MODE` | Database SSL mode | | server |
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.
\*2: If not provided, the appropriate extension to use is auto-detected at startup by introspecting the database. When multiple extensions are installed, the order of preference is VectorChord, pgvecto.rs, pgvector.
\*3: Uses either [`postgresql.ssd.conf`](https://github.com/immich-app/base-images/blob/main/postgres/postgresql.ssd.conf) or [`postgresql.hdd.conf`](https://github.com/immich-app/base-images/blob/main/postgres/postgresql.hdd.conf) which mainly controls the Postgres `effective_io_concurrency` setting to allow for concurrenct IO on SSDs and sequential IO on HDDs.
:::info
All `DB_` variables must be provided to all Immich workers, including `api` and `microservices`.
@@ -199,11 +199,12 @@ Additional machine learning parameters can be tuned from the admin UI.
| `IMMICH_TELEMETRY_INCLUDE` | Collect these telemetries. List of `host`, `api`, `io`, `repo`, `job`. Note: You can also specify `all` to enable all | | server | api, microservices |
| `IMMICH_TELEMETRY_EXCLUDE` | Do not collect these telemetries. List of `host`, `api`, `io`, `repo`, `job` | | server | api, microservices |
## Secrets
## Docker Secrets
The following variables support reading from files, either via [Systemd Credentials][systemd-creds] or [Docker secrets][docker-secrets] for additional security.
The following variables support the use of [Docker secrets][docker-secrets] for additional security.
To use any of these, either set `CREDENTIALS_DIRECTORY` to a directory that contains files whose name is the regular variable” name, and whose content is the secret. If using Docker Secrets, setting `CREDENTIALS_DIRECTORY=/run/secrets` will cause all secrets present to be used. Alternatively, replace the regular variable with the equivalent `_FILE` environment variable as below. The value of the `_FILE` variable should be set to the path of a file containing the variable value.
To use any of these, replace the regular environment variable with the equivalent `_FILE` environment variable. The value of
the `_FILE` variable should be set to the path of a file containing the variable value.
| Regular Variable | Equivalent Docker Secrets '\_FILE' Variable |
| :----------------- | :------------------------------------------ |
@@ -225,4 +226,3 @@ to use a Docker secret for the password in the Redis container.
[docker-secrets-docs]: https://github.com/docker-library/docs/tree/master/postgres#docker-secrets
[docker-secrets]: https://docs.docker.com/engine/swarm/secrets/
[ioredis]: https://ioredis.readthedocs.io/en/latest/README/#connect-to-redis
[systemd-creds]: https://systemd.io/CREDENTIALS/

View File

@@ -39,8 +39,8 @@ alt="Dot Env Example"
/>
- Change the default `DB_PASSWORD`, and add custom database connection information if necessary.
- Change `DB_DATA_LOCATION` to a folder (absolute path) where the database will be saved to disk.
- Change `UPLOAD_LOCATION` to a folder (absolute path) where media (uploaded and generated) will be stored.
- Change `DB_DATA_LOCATION` to a folder where the database will be saved to disk.
- Change `UPLOAD_LOCATION` to a folder where media (uploaded and generated) will be stored.
11. Click on "**Deploy the stack**".

View File

@@ -9,7 +9,7 @@ This is a community contribution and not officially supported by the Immich team
Community support can be found in the dedicated channel on the [Discord Server](https://discord.immich.app/).
**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/apps/tree/master/trains/community/immich).**
**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).**
:::
Immich can easily be installed on TrueNAS Community Edition via the **Community** train application.

View File

@@ -75,6 +75,7 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
5. Click "**Save Changes**", you will be prompted to edit stack UI labels, just leave this blank and click "**Ok**"
6. Select the cog ⚙️ next to Immich, click "**Edit Stack**", then click "**Env File**"
7. Paste the entire contents of the [Immich example.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file into the Unraid editor, then **before saving** edit the following:
- `UPLOAD_LOCATION`: Create a folder in your Images Unraid share and place the **absolute** location here > For example my _"images"_ share has a folder within it called _"immich"_. If I browse to this directory in the terminal and type `pwd` the output is `/mnt/user/images/immich`. This is the exact value I need to enter as my `UPLOAD_LOCATION`
- `DB_DATA_LOCATION`: Change this to use an Unraid share (preferably a cache pool, e.g. `/mnt/user/appdata/postgresql/data`). This uses the `appdata` share. Do also create the `postgresql` folder, by running `mkdir /mnt/user/{share_location}/postgresql/data`. If left at default it will try to use Unraid's `/boot/config/plugins/compose.manager/projects/[stack_name]/postgres` folder which it doesn't have permissions to, resulting in this container continuously restarting.

1531
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,9 +16,8 @@
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
"@docusaurus/core": "~3.8.0",
"@docusaurus/preset-classic": "~3.8.0",
"@docusaurus/theme-common": "~3.8.0",
"@docusaurus/core": "~3.7.0",
"@docusaurus/preset-classic": "~3.7.0",
"@mdi/js": "^7.3.67",
"@mdi/react": "^1.6.1",
"@mdx-js/react": "^3.0.0",
@@ -27,7 +26,6 @@
"clsx": "^2.0.0",
"docusaurus-lunr-search": "^3.3.2",
"docusaurus-preset-openapi": "^0.7.5",
"lunr": "^2.3.9",
"postcss": "^8.4.25",
"prism-react-renderer": "^2.3.1",
"raw-loader": "^4.0.2",
@@ -37,7 +35,7 @@
"url": "^0.11.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "~3.8.0",
"@docusaurus/module-type-aliases": "~3.7.0",
"@docusaurus/tsconfig": "^3.7.0",
"@docusaurus/types": "^3.7.0",
"prettier": "^3.2.4",
@@ -59,6 +57,6 @@
"node": ">=20"
},
"volta": {
"node": "22.17.1"
"node": "22.16.0"
}
}

View File

@@ -58,12 +58,6 @@ const guides: CommunityGuidesProps[] = [
description: 'Access Immich with an end-to-end encrypted connection.',
url: 'https://meshnet.nordvpn.com/how-to/remote-files-media-access/immich-remote-access',
},
{
title: 'Trust Self Signed Certificates with Immich - OAuth Setup',
description:
'Set up Certificate Authority trust with Immich, and your private OAuth2/OpenID service, while using a private CA for HTTPS commication.',
url: 'https://github.com/immich-app/immich/discussions/18614',
},
];
function CommunityGuide({ title, description, url }: CommunityGuidesProps): JSX.Element {

View File

@@ -100,11 +100,6 @@ const projects: CommunityProjectProps[] = [
description: 'Automatically optimize files uploaded to Immich in order to save storage space',
url: 'https://github.com/miguelangel-nubla/immich-upload-optimizer',
},
{
title: 'Immich Machine Learning Load Balancer',
description: 'Speed up your machine learning by load balancing your requests to multiple computers',
url: 'https://github.com/apetersson/immich_ml_balancer',
},
];
function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element {

View File

@@ -2,23 +2,4 @@
## TypeORM Upgrade
In order to update to Immich to `v1.137.0` (or above), the application must be started at least once on a version in the range between `1.132.0` and `1.136.0`. Doing so will complete database schema upgrades that are required for `v1.137.0` (and above). After Immich has successfully updated to a version in this range, you can now attempt to update to v1.137.0 (or above). We recommend users upgrade to `1.132.0` since it does not have any other breaking changes.
## Inconsistent Media Location
:::caution
This error is related to the location of media files _inside the container_. Never move files on the host system when you run into this error message.
:::
Immich automatically tries to detect where your Immich data is located. On start up, it compares the detected media location with the file paths in the database and throws an Inconsistent Media Location error when they do not match.
To fix this issue, verify that the `IMMICH_MEDIA_LOCATION` environment variable and `UPLOAD_LOCATION` volume mount are in sync with the database paths.
If you would like to migrate from one media location to another, simply successfully start Immich on `v1.136.0` or later, then do the following steps:
1. Stop Immich
2. Update `IMMICH_MEDIA_LOCATION` to the new location
3. Update the right-hand side of the `UPLOAD_LOCATION` volume mount to the new location
4. Start up Immich
After version `1.136.0`, Immich can detect when a media location has moved and will automatically update the database paths to keep them in sync.
The upgrade to Immich `v2.x.x` has a required upgrade path to `v1.132.0+`. This means it is required to start up the application at least once on version `1.132.0` (or later). Doing so will complete database schema upgrades that are required for `v2.0.0`. After Immich has successfully booted on this version, shut the system down and try the `v2.x.x` upgrade again.

View File

@@ -85,7 +85,6 @@ import React from 'react';
import { Item, Timeline } from '../components/timeline';
const releases = {
'v1.135.0': new Date(2025, 5, 18),
'v1.133.0': new Date(2025, 4, 21),
'v1.130.0': new Date(2025, 2, 25),
'v1.127.0': new Date(2025, 1, 26),
@@ -197,6 +196,14 @@ const roadmap: Item[] = [
description: 'Automate tasks with workflows',
getDateLabel: () => 'Planned for 2025',
},
{
done: false,
icon: mdiTableKey,
iconColor: 'gray',
title: 'Fine grained access controls',
description: 'Granular access controls for users and api keys',
getDateLabel: () => 'Planned for 2025',
},
{
done: false,
icon: mdiImageEdit,
@@ -232,26 +239,12 @@ const roadmap: Item[] = [
];
const milestones: Item[] = [
{
icon: mdiStar,
iconColor: 'gold',
title: '70,000 Stars',
description: 'Reached 70K Stars on GitHub!',
getDateLabel: withLanguage(new Date(2025, 6, 9)),
},
withRelease({
icon: mdiTableKey,
iconColor: 'gray',
title: 'Fine grained access controls',
description: 'Granular access controls for api keys',
release: 'v1.135.0',
}),
withRelease({
icon: mdiCast,
iconColor: 'aqua',
title: 'Google Cast (web and mobile)',
title: 'Google Cast (web)',
description: 'Cast assets to Google Cast/Chromecast compatible devices',
release: 'v1.135.0',
release: 'v1.133.0',
}),
withRelease({
icon: mdiLockOutline,

View File

@@ -1,5 +1,4 @@
/docs /docs/overview/welcome 307
/docs/ /docs/overview/welcome 307
/docs /docs/overview/introduction 307
/docs/mobile-app-beta-program /docs/features/mobile-app 307
/docs/contribution-guidelines /docs/overview/support-the-project#contributing 307
/docs/install /docs/install/docker-compose 307

View File

@@ -1,24 +1,4 @@
[
{
"label": "v1.136.0",
"url": "https://v1.136.0.archive.immich.app"
},
{
"label": "v1.135.3",
"url": "https://v1.135.3.archive.immich.app"
},
{
"label": "v1.135.2",
"url": "https://v1.135.2.archive.immich.app"
},
{
"label": "v1.135.1",
"url": "https://v1.135.1.archive.immich.app"
},
{
"label": "v1.135.0",
"url": "https://v1.135.0.archive.immich.app"
},
{
"label": "v1.134.0",
"url": "https://v1.134.0.archive.immich.app"

View File

@@ -1 +1 @@
22.17.1
22.16.0

View File

@@ -3,6 +3,7 @@ name: immich-e2e
services:
immich-server:
container_name: immich-e2e-server
command: ['./start.sh']
image: immich-server:latest
build:
context: ../
@@ -35,10 +36,10 @@ services:
- 2285:2285
redis:
image: redis:6.2-alpine@sha256:7fe72c486b910f6b1a9769c937dad5d63648ddee82e056f47417542dd40825bb
image: redis:6.2-alpine@sha256:3211c33a618c457e5d241922c975dbc4f446d0bdb2dc75694f5573ef8e2d01fa
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:0e763a2383d56f90364fcd72767ac41400cd30d2627f407f7e7960c9f1923c21
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:9c704fb49ce27549df00f1b096cc93f8b0c959ef087507704d74954808f78a82
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
environment:
POSTGRES_PASSWORD: postgres

1339
e2e/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.136.0",
"version": "1.134.0",
"description": "",
"main": "index.js",
"type": "module",
@@ -24,16 +24,15 @@
"@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^22.16.5",
"@types/node": "^22.15.29",
"@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",
"@vitest/coverage-v8": "^3.0.0",
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^59.0.0",
"exiftool-vendored": "^28.3.1",
@@ -45,7 +44,6 @@
"pngjs": "^7.0.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.34.0",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"typescript": "^5.3.3",
@@ -54,6 +52,6 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "22.17.1"
"node": "22.16.0"
}
}

View File

@@ -7,7 +7,6 @@ import {
ReactionType,
createActivity as create,
createAlbum,
removeAssetFromAlbum,
} from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
@@ -343,36 +342,5 @@ describe('/activities', () => {
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([]);
});
});
});

View File

@@ -470,7 +470,7 @@ describe('/albums', () => {
.send({ ids: [asset.id] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not found or no albumAsset.create access'));
expect(body).toEqual(errorDto.badRequest('Not found or no album.addAsset access'));
});
it('should add duplicate assets only once', async () => {
@@ -599,7 +599,7 @@ describe('/albums', () => {
.send({ ids: [user1Asset1.id] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not found or no albumAsset.delete access'));
expect(body).toEqual(errorDto.badRequest('Not found or no album.removeAsset access'));
});
it('should remove duplicate assets only once', async () => {

View File

@@ -20,7 +20,7 @@ describe('/api-keys', () => {
});
beforeEach(async () => {
await utils.resetDatabase(['api_key']);
await utils.resetDatabase(['api_keys']);
});
describe('POST /api-keys', () => {

View File

@@ -15,7 +15,6 @@ import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import sharp from 'sharp';
import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
@@ -41,40 +40,6 @@ const today = DateTime.fromObject({
}) as DateTime<true>;
const yesterday = today.minus({ days: 1 });
const createTestImageWithExif = async (filename: string, exifData: Record<string, any>) => {
// Generate unique color to ensure different checksums for each image
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
// Create a 100x100 solid color JPEG using Sharp
const imageBytes = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r, g, b },
},
})
.jpeg({ quality: 90 })
.toBuffer();
// Add random suffix to filename to avoid collisions
const uniqueFilename = filename.replace('.jpg', `-${randomBytes(4).toString('hex')}.jpg`);
const filepath = join(tempDir, uniqueFilename);
await writeFile(filepath, imageBytes);
// Filter out undefined values before writing EXIF
const cleanExifData = Object.fromEntries(Object.entries(exifData).filter(([, value]) => value !== undefined));
await exiftool.write(filepath, cleanExifData);
// Re-read the image bytes after EXIF has been written
const finalImageBytes = await readFile(filepath);
return { filepath, imageBytes: finalImageBytes, filename: uniqueFilename };
};
describe('/asset', () => {
let admin: LoginResponseDto;
let websocket: Socket;
@@ -1225,411 +1190,6 @@ describe('/asset', () => {
});
});
describe('EXIF metadata extraction', () => {
describe('Additional date tag extraction', () => {
describe('Date-time vs time-only tag handling', () => {
it('should fall back to file timestamps when only time-only tags are available', async () => {
const { imageBytes, filename } = await createTestImageWithExif('time-only-fallback.jpg', {
TimeCreated: '2023:11:15 14:30:00', // Time-only tag, should not be used for dateTimeOriginal
// Exclude all date-time tags to force fallback to file timestamps
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
CreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
GPSDateTime: undefined,
DateTimeUTC: undefined,
SonyDateTime2: undefined,
GPSDateStamp: undefined,
});
const oldDate = new Date('2020-01-01T00:00:00.000Z');
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
fileCreatedAt: oldDate.toISOString(),
fileModifiedAt: oldDate.toISOString(),
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should fall back to file timestamps, which we set to 2020-01-01
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2020-01-01T00:00:00.000Z').getTime(),
);
});
it('should prefer DateTimeOriginal over time-only tags', async () => {
const { imageBytes, filename } = await createTestImageWithExif('datetime-over-time.jpg', {
DateTimeOriginal: '2023:10:10 10:00:00', // Should be preferred
TimeCreated: '2023:11:15 14:30:00', // Should be ignored (time-only)
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should use DateTimeOriginal, not TimeCreated
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-10-10T10:00:00.000Z').getTime(),
);
});
});
describe('GPSDateTime tag extraction', () => {
it('should extract GPSDateTime with GPS coordinates', async () => {
const { imageBytes, filename } = await createTestImageWithExif('gps-datetime.jpg', {
GPSDateTime: '2023:11:15 12:30:00Z',
GPSLatitude: 37.7749,
GPSLongitude: -122.4194,
// Exclude other date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
CreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
TimeCreated: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
expect(assetInfo.exifInfo?.latitude).toBeCloseTo(37.7749, 4);
expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-122.4194, 4);
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-11-15T12:30:00.000Z').getTime(),
);
});
});
describe('CreateDate tag extraction', () => {
it('should extract CreateDate when available', async () => {
const { imageBytes, filename } = await createTestImageWithExif('create-date.jpg', {
CreateDate: '2023:11:15 10:30:00',
// Exclude other higher priority date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
TimeCreated: undefined,
GPSDateTime: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-11-15T10:30:00.000Z').getTime(),
);
});
});
describe('GPSDateStamp tag extraction', () => {
it('should fall back to file timestamps when only date-only tags are available', async () => {
const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp.jpg', {
GPSDateStamp: '2023:11:15', // Date-only tag, should not be used for dateTimeOriginal
// Note: NOT including GPSTimeStamp to avoid automatic GPSDateTime creation
GPSLatitude: 51.5074,
GPSLongitude: -0.1278,
// Explicitly exclude all testable date-time tags to force fallback to file timestamps
DateTimeOriginal: undefined,
CreateDate: undefined,
CreationDate: undefined,
GPSDateTime: undefined,
});
const oldDate = new Date('2020-01-01T00:00:00.000Z');
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
fileCreatedAt: oldDate.toISOString(),
fileModifiedAt: oldDate.toISOString(),
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
expect(assetInfo.exifInfo?.latitude).toBeCloseTo(51.5074, 4);
expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-0.1278, 4);
// Should fall back to file timestamps, which we set to 2020-01-01
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2020-01-01T00:00:00.000Z').getTime(),
);
});
});
/*
* NOTE: The following EXIF date tags are NOT effectively usable with JPEG test files:
*
* NOT WRITABLE to JPEG:
* - MediaCreateDate: Can be read from video files but not written to JPEG
* - DateTimeCreated: Read-only tag in JPEG format
* - DateTimeUTC: Cannot be written to JPEG files
* - SonyDateTime2: Proprietary Sony tag, not writable to JPEG
* - SubSecMediaCreateDate: Tag not defined for JPEG format
* - SourceImageCreateTime: Non-standard insta360 tag, not writable to JPEG
*
* WRITABLE but NOT READABLE from JPEG:
* - SubSecDateTimeOriginal: Can be written but not read back from JPEG
* - SubSecCreateDate: Can be written but not read back from JPEG
*
* EFFECTIVELY TESTABLE TAGS (writable and readable):
* - DateTimeOriginal ✓
* - CreateDate ✓
* - CreationDate ✓
* - GPSDateTime ✓
*
* The metadata service correctly handles non-readable tags and will fall back to
* file timestamps when only non-readable tags are present.
*/
describe('Date tag priority order', () => {
it('should respect the complete date tag priority order', async () => {
// Test cases using only EFFECTIVELY TESTABLE tags (writable AND readable from JPEG)
const testCases = [
{
name: 'DateTimeOriginal has highest priority among testable tags',
exifData: {
DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
CreateDate: '2023:05:05 05:00:00', // TESTABLE
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
},
expectedDate: '2023-04-04T04:00:00.000Z',
},
{
name: 'CreateDate when DateTimeOriginal missing',
exifData: {
CreateDate: '2023:05:05 05:00:00', // TESTABLE
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
},
expectedDate: '2023-05-05T05:00:00.000Z',
},
{
name: 'CreationDate when standard EXIF tags missing',
exifData: {
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
},
expectedDate: '2023-07-07T07:00:00.000Z',
},
{
name: 'GPSDateTime when no other testable date tags present',
exifData: {
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
Make: 'SONY',
},
expectedDate: '2023-10-10T10:00:00.000Z',
},
];
for (const testCase of testCases) {
const { imageBytes, filename } = await createTestImageWithExif(
`${testCase.name.replaceAll(/\s+/g, '-').toLowerCase()}.jpg`,
testCase.exifData,
);
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal, `Failed for: ${testCase.name}`).toBeDefined();
expect(
new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime(),
`Date mismatch for: ${testCase.name}`,
).toBe(new Date(testCase.expectedDate).getTime());
}
});
});
describe('Edge cases for date tag handling', () => {
it('should fall back to file timestamps with GPSDateStamp alone', async () => {
const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp-only.jpg', {
GPSDateStamp: '2023:08:08', // Date-only tag, should not be used for dateTimeOriginal
// Intentionally no GPSTimeStamp
// Exclude all other date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
CreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
TimeCreated: undefined,
GPSDateTime: undefined,
DateTimeUTC: undefined,
});
const oldDate = new Date('2020-01-01T00:00:00.000Z');
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
fileCreatedAt: oldDate.toISOString(),
fileModifiedAt: oldDate.toISOString(),
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should fall back to file timestamps, which we set to 2020-01-01
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2020-01-01T00:00:00.000Z').getTime(),
);
});
it('should handle all testable date tags present to verify complete priority order', async () => {
const { imageBytes, filename } = await createTestImageWithExif('all-testable-date-tags.jpg', {
// All TESTABLE date tags to JPEG format (writable AND readable)
DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
CreateDate: '2023:05:05 05:00:00', // TESTABLE
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
// Note: Excluded non-testable tags:
// SubSec tags: writable but not readable from JPEG
// Non-writable tags: MediaCreateDate, DateTimeCreated, DateTimeUTC, SonyDateTime2, etc.
// Time-only/date-only tags: already excluded from EXIF_DATE_TAGS
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should use DateTimeOriginal as it has the highest priority among testable tags
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-04-04T04:00:00.000Z').getTime(),
);
});
it('should use CreationDate when SubSec tags are missing', async () => {
const { imageBytes, filename } = await createTestImageWithExif('creation-date-priority.jpg', {
CreationDate: '2023:07:07 07:00:00', // WRITABLE
GPSDateTime: '2023:10:10 10:00:00', // WRITABLE
// Note: DateTimeCreated, DateTimeUTC, SonyDateTime2 are NOT writable to JPEG
// Note: TimeCreated and GPSDateStamp are excluded from EXIF_DATE_TAGS (time-only/date-only)
// Exclude SubSec and standard EXIF tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
CreateDate: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should use CreationDate when available
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-07-07T07:00:00.000Z').getTime(),
);
});
it('should skip invalid date formats and use next valid tag', async () => {
const { imageBytes, filename } = await createTestImageWithExif('invalid-date-handling.jpg', {
// Note: Testing invalid date handling with only WRITABLE tags
GPSDateTime: '2023:10:10 10:00:00', // WRITABLE - Valid date
CreationDate: '2023:13:13 13:00:00', // WRITABLE - Valid date
// Note: TimeCreated excluded (time-only), DateTimeCreated not writable to JPEG
// Exclude other date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
CreateDate: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should skip invalid dates and use the first valid one (GPSDateTime)
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-10-10T10:00:00.000Z').getTime(),
);
});
});
});
});
describe('POST /assets/exist', () => {
it('ignores invalid deviceAssetIds', async () => {
const response = await utils.checkExistingAssets(user1.accessToken, {

View File

@@ -0,0 +1,146 @@
import { LoginResponseDto, login, signUpAdmin } from '@immich/sdk';
import { loginDto, signupDto } from 'src/fixtures';
import { errorDto, loginResponseDto, signupResponseDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeEach, describe, expect, it } from 'vitest';
const { email, password } = signupDto.admin;
describe(`/auth/admin-sign-up`, () => {
beforeEach(async () => {
await utils.resetDatabase();
});
describe('POST /auth/admin-sign-up', () => {
it(`should sign up the admin`, async () => {
const { status, body } = await request(app).post('/auth/admin-sign-up').send(signupDto.admin);
expect(status).toBe(201);
expect(body).toEqual(signupResponseDto.admin);
});
it('should not allow a second admin to sign up', async () => {
await signUpAdmin({ signUpDto: signupDto.admin });
const { status, body } = await request(app).post('/auth/admin-sign-up').send(signupDto.admin);
expect(status).toBe(400);
expect(body).toEqual(errorDto.alreadyHasAdmin);
});
});
});
describe('/auth/*', () => {
let admin: LoginResponseDto;
beforeEach(async () => {
await utils.resetDatabase();
await signUpAdmin({ signUpDto: signupDto.admin });
admin = await login({ loginCredentialDto: loginDto.admin });
});
describe(`POST /auth/login`, () => {
it('should reject an incorrect password', async () => {
const { status, body } = await request(app).post('/auth/login').send({ email, password: 'incorrect' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.incorrectLogin);
});
it('should accept a correct password', async () => {
const { status, body, headers } = await request(app).post('/auth/login').send({ email, password });
expect(status).toBe(201);
expect(body).toEqual(loginResponseDto.admin);
const token = body.accessToken;
expect(token).toBeDefined();
const cookies = headers['set-cookie'];
expect(cookies).toHaveLength(3);
expect(cookies[0].split(';').map((item) => item.trim())).toEqual([
`immich_access_token=${token}`,
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'HttpOnly',
'SameSite=Lax',
]);
expect(cookies[1].split(';').map((item) => item.trim())).toEqual([
'immich_auth_type=password',
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'HttpOnly',
'SameSite=Lax',
]);
expect(cookies[2].split(';').map((item) => item.trim())).toEqual([
'immich_is_authenticated=true',
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'SameSite=Lax',
]);
});
});
describe('POST /auth/validateToken', () => {
it('should reject an invalid token', async () => {
const { status, body } = await request(app).post(`/auth/validateToken`).set('Authorization', 'Bearer 123');
expect(status).toBe(401);
expect(body).toEqual(errorDto.invalidToken);
});
it('should accept a valid token', async () => {
const { status, body } = await request(app)
.post(`/auth/validateToken`)
.send({})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ authStatus: true });
});
});
describe('POST /auth/change-password', () => {
it('should require the current password', async () => {
const { status, body } = await request(app)
.post(`/auth/change-password`)
.send({ password: 'wrong-password', newPassword: 'Password1234' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.wrongPassword);
});
it('should change the password', async () => {
const { status } = await request(app)
.post(`/auth/change-password`)
.send({ password, newPassword: 'Password1234' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
await login({
loginCredentialDto: {
email: 'admin@immich.cloud',
password: 'Password1234',
},
});
});
});
describe('POST /auth/logout', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/auth/logout`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should logout the user', async () => {
const { status, body } = await request(app)
.post(`/auth/logout`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
successful: true,
redirectUri: '/auth/login?autoLaunch=0',
});
});
});
});

View File

@@ -6,7 +6,7 @@ import {
createMemory,
getMemory,
} from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
@@ -17,6 +17,7 @@ describe('/memories', () => {
let user: LoginResponseDto;
let adminAsset: AssetMediaResponseDto;
let userAsset1: AssetMediaResponseDto;
let userAsset2: AssetMediaResponseDto;
let userMemory: MemoryResponseDto;
beforeAll(async () => {
@@ -24,9 +25,10 @@ describe('/memories', () => {
admin = await utils.adminSetup();
user = await utils.userSetup(admin.accessToken, createUserDto.user1);
[adminAsset, userAsset1] = await Promise.all([
[adminAsset, userAsset1, userAsset2] = await Promise.all([
utils.createAsset(admin.accessToken),
utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken),
]);
userMemory = await createMemory(
{
@@ -41,7 +43,121 @@ describe('/memories', () => {
);
});
describe('GET /memories', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/memories');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
});
describe('POST /memories', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/memories');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should validate data when type is on this day', async () => {
const { status, body } = await request(app)
.post('/memories')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({
type: 'on_this_day',
data: {},
memoryAt: new Date(2021).toISOString(),
});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['data.year must be a positive number', 'data.year must be an integer number']),
);
});
it('should create a new memory', async () => {
const { status, body } = await request(app)
.post('/memories')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({
type: 'on_this_day',
data: { year: 2021 },
memoryAt: new Date(2021).toISOString(),
});
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
type: 'on_this_day',
data: { year: 2021 },
createdAt: expect.any(String),
updatedAt: expect.any(String),
isSaved: false,
memoryAt: expect.any(String),
ownerId: user.userId,
assets: [],
});
});
it('should create a new memory (with assets)', async () => {
const { status, body } = await request(app)
.post('/memories')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({
type: 'on_this_day',
data: { year: 2021 },
memoryAt: new Date(2021).toISOString(),
assetIds: [userAsset1.id, userAsset2.id],
});
expect(status).toBe(201);
expect(body).toMatchObject({
id: expect.any(String),
assets: expect.arrayContaining([
expect.objectContaining({ id: userAsset1.id }),
expect.objectContaining({ id: userAsset2.id }),
]),
});
expect(body.assets).toHaveLength(2);
});
it('should create a new memory and ignore assets the user does not have access to', async () => {
const { status, body } = await request(app)
.post('/memories')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({
type: 'on_this_day',
data: { year: 2021 },
memoryAt: new Date(2021).toISOString(),
assetIds: [userAsset1.id, adminAsset.id],
});
expect(status).toBe(201);
expect(body).toMatchObject({
id: expect.any(String),
assets: [expect.objectContaining({ id: userAsset1.id })],
});
expect(body.assets).toHaveLength(1);
});
});
describe('GET /memories/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/memories/${uuidDto.invalid}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.get(`/memories/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(app)
.get(`/memories/${userMemory.id}`)
@@ -60,6 +176,22 @@ describe('/memories', () => {
});
describe('PUT /memories/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/memories/${uuidDto.invalid}`).send({ isSaved: true });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.put(`/memories/${uuidDto.invalid}`)
.send({ isSaved: true })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}`)
@@ -86,6 +218,23 @@ describe('/memories', () => {
});
describe('PUT /memories/:id/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}/assets`)
.send({ ids: [userAsset1.id] });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.put(`/memories/${uuidDto.invalid}/assets`)
.send({ ids: [userAsset1.id] })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}/assets`)
@@ -95,6 +244,15 @@ describe('/memories', () => {
expect(body).toEqual(errorDto.noPermission);
});
it('should require a valid asset id', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}/assets`)
.send({ ids: [uuidDto.invalid] })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
});
it('should require asset access', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}/assets`)
@@ -121,6 +279,23 @@ describe('/memories', () => {
});
describe('DELETE /memories/:id/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}/assets`)
.send({ ids: [userAsset1.id] });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.delete(`/memories/${uuidDto.invalid}/assets`)
.send({ ids: [userAsset1.id] })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}/assets`)
@@ -130,6 +305,15 @@ describe('/memories', () => {
expect(body).toEqual(errorDto.noPermission);
});
it('should require a valid asset id', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}/assets`)
.send({ ids: [uuidDto.invalid] })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
});
it('should only remove assets in the memory', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}/assets`)
@@ -156,6 +340,21 @@ describe('/memories', () => {
});
describe('DELETE /memories/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/memories/${uuidDto.invalid}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.delete(`/memories/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}`)

View File

@@ -227,21 +227,6 @@ describe(`/oauth`, () => {
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,

View File

@@ -14,11 +14,7 @@ describe('/people', () => {
let nameAlicePerson: PersonResponseDto;
let nameBobPerson: PersonResponseDto;
let nameCharliePerson: PersonResponseDto;
let nameNullPerson4Assets: PersonResponseDto;
let nameNullPerson3Assets: PersonResponseDto;
let nameNullPerson1Asset: PersonResponseDto;
let nameBillPersonFavourite: PersonResponseDto;
let nameFreddyPersonFavourite: PersonResponseDto;
let nameNullPerson: PersonResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
@@ -31,11 +27,7 @@ describe('/people', () => {
nameCharliePerson,
nameBobPerson,
nameAlicePerson,
nameNullPerson4Assets,
nameNullPerson3Assets,
nameNullPerson1Asset,
nameBillPersonFavourite,
nameFreddyPersonFavourite,
nameNullPerson,
] = await Promise.all([
utils.createPerson(admin.accessToken, {
name: 'visible_person',
@@ -60,26 +52,11 @@ describe('/people', () => {
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 }),
@@ -87,27 +64,15 @@ describe('/people', () => {
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
utils.createFace({ assetId: asset3.id, personId: multipleAssetsPerson.id }),
// 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 }),
// Null-named person
utils.createFace({ assetId: asset1.id, personId: nameNullPerson.id }),
utils.createFace({ assetId: asset2.id, personId: nameNullPerson.id }), // 2 assets
]);
});
@@ -122,19 +87,15 @@ describe('/people', () => {
expect(status).toBe(200);
expect(body).toEqual({
hasNextPage: false,
total: 11,
total: 7,
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
expect.objectContaining({ name: 'hidden_person' }),
],
});
});
@@ -144,21 +105,17 @@ describe('/people', () => {
expect(status).toBe(200);
expect(body.hasNextPage).toBe(false);
expect(body.total).toBe(11); // All persons
expect(body.total).toBe(7); // 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);
@@ -170,18 +127,14 @@ describe('/people', () => {
expect(status).toBe(200);
expect(body).toEqual({
hasNextPage: false,
total: 11,
total: 7,
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: '' }),
],
});
});
@@ -195,9 +148,9 @@ describe('/people', () => {
expect(status).toBe(200);
expect(body).toEqual({
hasNextPage: true,
total: 11,
total: 7,
hidden: 1,
people: [expect.objectContaining({ name: 'Alice' })],
people: [expect.objectContaining({ name: 'visible_person' })],
});
});
});

View File

@@ -117,25 +117,8 @@ describe('/shared-links', () => {
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(`<meta property="og:image" content="http://127.0.0.1:2285`);
});
it('should fall back to my.immich.app og:image meta tag for shared asset if Host header is not present', async () => {
const resp = await request(shareUrl).get(`/${linkWithAssets.key}`).set('Host', '');
expect(resp.status).toBe(200);
expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).toContain(`<meta property="og:image" content="https://my.immich.app`);
});
it('should return 404 for an invalid shared link', async () => {
const resp = await request(shareUrl).get(`/invalid-key`);
expect(resp.status).toBe(404);
expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).not.toContain(`og:type`);
expect(resp.text).not.toContain(`og:title`);
expect(resp.text).not.toContain(`og:description`);
expect(resp.text).not.toContain(`og:image`);
});
});
describe('GET /shared-links', () => {

View File

@@ -15,6 +15,12 @@ describe('/system-config', () => {
});
describe('PUT /system-config', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put('/system-config');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should always return the new config', async () => {
const config = await getSystemConfig(admin.accessToken);

View File

@@ -37,7 +37,7 @@ describe('/tags', () => {
beforeEach(async () => {
// tagging assets eventually triggers metadata extraction which can impact other tests
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
await utils.resetDatabase(['tag']);
await utils.resetDatabase(['tags']);
});
describe('POST /tags', () => {

View File

@@ -0,0 +1,230 @@
import {
AssetMediaResponseDto,
AssetVisibility,
LoginResponseDto,
SharedLinkType,
TimeBucketAssetResponseDto,
} from '@immich/sdk';
import { DateTime } from 'luxon';
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';
// TODO this should probably be a test util function
const today = DateTime.fromObject({
year: 2023,
month: 11,
day: 3,
}) as DateTime<true>;
const yesterday = today.minus({ days: 1 });
describe('/timeline', () => {
let admin: LoginResponseDto;
let user: LoginResponseDto;
let timeBucketUser: LoginResponseDto;
let user1Assets: AssetMediaResponseDto[];
let user2Assets: AssetMediaResponseDto[];
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false });
[user, timeBucketUser] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.create('1')),
utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')),
]);
user1Assets = await Promise.all([
utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken, {
isFavorite: true,
fileCreatedAt: yesterday.toISO(),
fileModifiedAt: yesterday.toISO(),
assetData: { filename: 'example.mp4' },
}),
utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken),
]);
user2Assets = 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() }),
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-12').toISOString() }),
]);
await utils.deleteAssets(timeBucketUser.accessToken, [user2Assets[4].id]);
});
describe('GET /timeline/buckets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/timeline/buckets');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should get time buckets by month', async () => {
const { status, body } = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(
expect.arrayContaining([
{ count: 3, timeBucket: '1970-02-01' },
{ count: 1, timeBucket: '1970-01-01' },
]),
);
});
it('should not allow access for unrelated shared links', async () => {
const sharedLink = await utils.createSharedLink(user.accessToken, {
type: SharedLinkType.Individual,
assetIds: user1Assets.map(({ id }) => id),
});
const { status, body } = await request(app).get('/timeline/buckets').query({ key: sharedLink.key });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should return error if time bucket is requested with partners asset and archived', async () => {
const req1 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ withPartners: true, visibility: AssetVisibility.Archive });
expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest());
const req2 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${user.accessToken}`)
.query({ withPartners: true, visibility: undefined });
expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest());
});
it('should return error if time bucket is requested with partners asset and favorite', async () => {
const req1 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ withPartners: true, isFavorite: true });
expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest());
const req2 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ withPartners: true, isFavorite: false });
expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest());
});
it('should return error if time bucket is requested with partners asset and trash', async () => {
const req = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${user.accessToken}`)
.query({ withPartners: true, isTrashed: true });
expect(req.status).toBe(400);
expect(req.body).toEqual(errorDto.badRequest());
});
});
describe('GET /timeline/bucket', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/timeline/bucket').query({
timeBucket: '1900-01-01',
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should handle 5 digit years', async () => {
const { status, body } = await request(app)
.get('/timeline/bucket')
.query({ timeBucket: '012345-01-01' })
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
city: [],
country: [],
duration: [],
id: [],
visibility: [],
isFavorite: [],
isImage: [],
isTrashed: [],
livePhotoVideoId: [],
fileCreatedAt: [],
localOffsetHours: [],
ownerId: [],
projectionType: [],
ratio: [],
status: [],
thumbhash: [],
});
});
// TODO enable date string validation while still accepting 5 digit years
// it('should fail if time bucket is invalid', async () => {
// const { status, body } = await request(app)
// .get('/timeline/bucket')
// .set('Authorization', `Bearer ${user.accessToken}`)
// .query({ timeBucket: 'foo' });
// expect(status).toBe(400);
// expect(body).toEqual(errorDto.badRequest);
// });
it('should return time bucket', async () => {
const { status, body } = await request(app)
.get('/timeline/bucket')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ timeBucket: '1970-02-10' });
expect(status).toBe(200);
expect(body).toEqual({
city: [],
country: [],
duration: [],
id: [],
visibility: [],
isFavorite: [],
isImage: [],
isTrashed: [],
livePhotoVideoId: [],
fileCreatedAt: [],
localOffsetHours: [],
ownerId: [],
projectionType: [],
ratio: [],
status: [],
thumbhash: [],
});
});
it('should return time bucket in trash', async () => {
const { status, body } = await request(app)
.get('/timeline/bucket')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ timeBucket: '1970-02-01', isTrashed: true });
expect(status).toBe(200);
const timeBucket: TimeBucketAssetResponseDto = body;
expect(timeBucket.isTrashed).toEqual([true]);
});
});
});

View File

@@ -97,7 +97,7 @@ describe(`immich upload`, () => {
});
beforeEach(async () => {
await utils.resetDatabase(['asset', 'album']);
await utils.resetDatabase(['assets', 'albums']);
});
describe(`immich upload /path/to/file.jpg`, () => {

View File

@@ -1,178 +0,0 @@
#!/usr/bin/env node
/**
* Script to generate test images with additional EXIF date tags
* This creates actual JPEG images with embedded metadata for testing
* Images are generated into e2e/test-assets/metadata/dates/
*/
import { execSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
interface TestImage {
filename: string;
description: string;
exifTags: Record<string, string>;
}
const testImages: TestImage[] = [
{
filename: 'time-created.jpg',
description: 'Image with TimeCreated tag',
exifTags: {
TimeCreated: '2023:11:15 14:30:00',
Make: 'Canon',
Model: 'EOS R5',
},
},
{
filename: 'gps-datetime.jpg',
description: 'Image with GPSDateTime and coordinates',
exifTags: {
GPSDateTime: '2023:11:15 12:30:00Z',
GPSLatitude: '37.7749',
GPSLongitude: '-122.4194',
GPSLatitudeRef: 'N',
GPSLongitudeRef: 'W',
},
},
{
filename: 'datetime-utc.jpg',
description: 'Image with DateTimeUTC tag',
exifTags: {
DateTimeUTC: '2023:11:15 10:30:00',
Make: 'Nikon',
Model: 'D850',
},
},
{
filename: 'gps-datestamp.jpg',
description: 'Image with GPSDateStamp and GPSTimeStamp',
exifTags: {
GPSDateStamp: '2023:11:15',
GPSTimeStamp: '08:30:00',
GPSLatitude: '51.5074',
GPSLongitude: '-0.1278',
GPSLatitudeRef: 'N',
GPSLongitudeRef: 'W',
},
},
{
filename: 'sony-datetime2.jpg',
description: 'Sony camera image with SonyDateTime2 tag',
exifTags: {
SonyDateTime2: '2023:11:15 06:30:00',
Make: 'SONY',
Model: 'ILCE-7RM5',
},
},
{
filename: 'date-priority-test.jpg',
description: 'Image with multiple date tags to test priority',
exifTags: {
SubSecDateTimeOriginal: '2023:01:01 01:00:00',
DateTimeOriginal: '2023:02:02 02:00:00',
SubSecCreateDate: '2023:03:03 03:00:00',
CreateDate: '2023:04:04 04:00:00',
CreationDate: '2023:05:05 05:00:00',
DateTimeCreated: '2023:06:06 06:00:00',
TimeCreated: '2023:07:07 07:00:00',
GPSDateTime: '2023:08:08 08:00:00',
DateTimeUTC: '2023:09:09 09:00:00',
GPSDateStamp: '2023:10:10',
SonyDateTime2: '2023:11:11 11:00:00',
},
},
{
filename: 'new-tags-only.jpg',
description: 'Image with only additional date tags (no standard tags)',
exifTags: {
TimeCreated: '2023:12:01 15:45:30',
GPSDateTime: '2023:12:01 13:45:30Z',
DateTimeUTC: '2023:12:01 13:45:30',
GPSDateStamp: '2023:12:01',
SonyDateTime2: '2023:12:01 08:45:30',
GPSLatitude: '40.7128',
GPSLongitude: '-74.0060',
GPSLatitudeRef: 'N',
GPSLongitudeRef: 'W',
},
},
];
const generateTestImages = async (): Promise<void> => {
// Target directory: e2e/test-assets/metadata/dates/
// Current file is in: e2e/src/
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const targetDir = join(__dirname, '..', 'test-assets', 'metadata', 'dates');
console.log('Generating test images with additional EXIF date tags...');
console.log(`Target directory: ${targetDir}`);
for (const image of testImages) {
try {
const imagePath = join(targetDir, image.filename);
// Create unique JPEG file using Sharp
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
const jpegData = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r, g, b },
},
})
.jpeg({ quality: 90 })
.toBuffer();
writeFileSync(imagePath, jpegData);
// Build exiftool command to add EXIF data
const exifArgs = Object.entries(image.exifTags)
.map(([tag, value]) => `-${tag}="${value}"`)
.join(' ');
const command = `exiftool ${exifArgs} -overwrite_original "${imagePath}"`;
console.log(`Creating ${image.filename}: ${image.description}`);
execSync(command, { stdio: 'pipe' });
// Verify the tags were written
const verifyCommand = `exiftool -json "${imagePath}"`;
const result = execSync(verifyCommand, { encoding: 'utf8' });
const metadata = JSON.parse(result)[0];
console.log(` ✓ Created with ${Object.keys(image.exifTags).length} EXIF tags`);
// Log first date tag found for verification
const firstDateTag = Object.keys(image.exifTags).find(
(tag) => tag.includes('Date') || tag.includes('Time') || tag.includes('Created'),
);
if (firstDateTag && metadata[firstDateTag]) {
console.log(` ✓ Verified ${firstDateTag}: ${metadata[firstDateTag]}`);
}
} catch (error) {
console.error(`Failed to create ${image.filename}:`, (error as Error).message);
}
}
console.log('\nTest image generation complete!');
console.log('Files created in:', targetDir);
console.log('\nTo test these images:');
console.log(`cd ${targetDir} && exiftool -time:all -gps:all *.jpg`);
};
export { generateTestImages };
// Run the generator if this file is executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
generateTestImages().catch(console.error);
}

View File

@@ -116,7 +116,6 @@ export const deviceDto = {
createdAt: expect.any(String),
updatedAt: expect.any(String),
current: true,
isPendingSyncReset: false,
deviceOS: '',
deviceType: '',
},

View File

@@ -12,7 +12,6 @@ export enum OAuthUser {
NO_NAME = 'no-name',
WITH_QUOTA = 'with-quota',
WITH_USERNAME = 'with-username',
WITH_ROLE = 'with-role',
}
const claims = [
@@ -35,12 +34,6 @@ const claims = [
preferred_username: 'user-quota',
immich_quota: 25,
},
{
sub: OAuthUser.WITH_ROLE,
email: 'oauth-with-role@immich.app',
email_verified: true,
immich_role: 'admin',
},
];
const withDefaultClaims = (sub: string) => ({
@@ -71,15 +64,7 @@ const setup = async () => {
claims: {
openid: ['sub'],
email: ['email', 'email_verified'],
profile: [
'name',
'given_name',
'family_name',
'preferred_username',
'immich_quota',
'immich_username',
'immich_role',
],
profile: ['name', 'given_name', 'family_name', 'preferred_username', 'immich_quota', 'immich_username'],
},
features: {
jwtUserinfo: {

View File

@@ -60,7 +60,6 @@ import { io, type Socket } from 'socket.io-client';
import { loginDto, signupDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
import request from 'supertest';
export type { Emitter } from '@socket.io/component-emitter';
type CommandResponse = { stdout: string; stderr: string; exitCode: number | null };
type EventType = 'assetUpload' | 'assetUpdate' | 'assetDelete' | 'userDelete' | 'assetHidden';
@@ -85,10 +84,10 @@ export const immichAdmin = (args: string[]) =>
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const executeCommand = (command: string, args: string[], options?: { cwd?: string }) => {
const executeCommand = (command: string, args: string[]) => {
let _resolve: (value: CommandResponse) => void;
const promise = new Promise<CommandResponse>((resolve) => (_resolve = resolve));
const child = spawn(command, args, { stdio: 'pipe', cwd: options?.cwd });
const child = spawn(command, args, { stdio: 'pipe' });
let stdout = '';
let stderr = '';
@@ -154,19 +153,19 @@ export const utils = {
tables = tables || [
// TODO e2e test for deleting a stack, since it is quite complex
'stack',
'library',
'shared_link',
'asset_stack',
'libraries',
'shared_links',
'person',
'album',
'asset',
'asset_face',
'albums',
'assets',
'asset_faces',
'activity',
'api_key',
'session',
'user',
'api_keys',
'sessions',
'users',
'system_metadata',
'tag',
'tags',
];
const sql: string[] = [];
@@ -175,7 +174,7 @@ export const utils = {
if (table === 'system_metadata') {
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
} else {
sql.push(`DELETE FROM "${table}" CASCADE;`);
sql.push(`DELETE FROM ${table} CASCADE;`);
}
}
@@ -186,6 +185,18 @@ export const utils = {
}
},
resetFilesystem: async () => {
const mediaInternal = '/usr/src/app/upload';
const dirs = [
`"${mediaInternal}/thumbs"`,
`"${mediaInternal}/upload"`,
`"${mediaInternal}/library"`,
`"${mediaInternal}/encoded-video"`,
].join(' ');
await execPromise(`docker exec -i "immich-e2e-server" /bin/bash -c "rm -rf ${dirs} && mkdir ${dirs}"`);
},
unzip: async (input: string, output: string) => {
await execPromise(`unzip -o -d "${output}" "${input}"`);
},
@@ -205,7 +216,11 @@ export const utils = {
websocket
.on('connect', () => resolve(websocket))
.on('on_upload_success', (data: AssetResponseDto) => onEvent({ event: 'assetUpload', id: data.id }))
.on('on_asset_update', (data: AssetResponseDto) => onEvent({ event: 'assetUpdate', id: data.id }))
.on('on_asset_update', (assetId: string[]) => {
for (const id of assetId) {
onEvent({ event: 'assetUpdate', id });
}
})
.on('on_asset_hidden', (assetId: string) => onEvent({ event: 'assetHidden', id: assetId }))
.on('on_asset_delete', (assetId: string) => onEvent({ event: 'assetDelete', id: assetId }))
.on('on_user_delete', (userId: string) => onEvent({ event: 'userDelete', id: userId }))
@@ -439,7 +454,7 @@ export const utils = {
return;
}
await client.query('INSERT INTO asset_face ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]);
await client.query('INSERT INTO asset_faces ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]);
},
setPersonThumbnail: async (personId: string) => {

View File

@@ -37,7 +37,6 @@ test.describe('Registration', () => {
await page.getByRole('button', { name: 'Server Privacy' }).click();
await page.getByRole('button', { name: 'User Privacy' }).click();
await page.getByRole('button', { name: 'Storage Template' }).click();
await page.getByRole('button', { name: 'Backups' }).click();
await page.getByRole('button', { name: 'Done' }).click();
// success

View File

@@ -4,7 +4,6 @@
"account_settings": "Rekeninginstellings",
"acknowledge": "Erken",
"action": "Aksie",
"action_common_update": "Opdateur",
"actions": "Aksies",
"active": "Aktief",
"activity": "Aktiwiteite",
@@ -14,7 +13,6 @@
"add_a_location": "Voeg 'n ligging by",
"add_a_name": "Voeg 'n naam by",
"add_a_title": "Voeg 'n titel by",
"add_endpoint": "Voeg Koppelvlakpunt by",
"add_exclusion_pattern": "Voeg uitsgluitingspatrone by",
"add_import_path": "Voeg invoerpad by",
"add_location": "Voeg ligging by",
@@ -22,30 +20,26 @@
"add_partner": "Voeg vennoot by",
"add_path": "Voeg pad by",
"add_photos": "Voeg foto's by",
"add_tag": "Voeg tag by",
"add_to": "Voeg by…",
"add_to_album": "Voeg na album",
"add_to_album_bottom_sheet_added": "By {album} bygevoeg",
"add_to_album_bottom_sheet_already_exists": "Reeds in {album}",
"add_to_shared_album": "Voeg toe aan gedeelde album",
"add_to_shared_album": "Voeg na gedeelde album",
"add_url": "Voeg URL by",
"added_to_archive": "By argief toegevoegd",
"added_to_favorites": "By gunstelinge toegevoegd",
"added_to_favorites_count": "Het {count, number} by gunstelinge toegevoegd",
"added_to_archive": "By argief gevoeg",
"added_to_favorites": "By gunstelinge gevoeg",
"added_to_favorites_count": "Het {count, number} by gunstelinge gevoeg",
"admin": {
"add_exclusion_pattern_description": "Voeg uitsluitingspatrone by. Globbing met *, ** en ? word ondersteun. Om alle lêers in enige lêergids genaamd \"Raw\" te ignoreer, gebruik \"**/Raw/**\". Om alle lêers wat op \".tif\" eindig, te ignoreer, gebruik \"**/*.tif\". Om 'n absolute pad te ignoreer, gebruik \"/path/to/ignore/**\".",
"admin_user": "Admin gebruiker",
"asset_offline_description": "Hierdie eksterne biblioteekbate word nie meer op skyf gevind nie en is na die asblik geskuif. As die lêer binne die biblioteek geskuif is, gaan jou tydlyn na vir die nuwe ooreenstemmende bate. Om hierdie bate te herstel, maak asseblief seker dat die lêerpad hieronder deur Immich verkry kan word en skandeer die biblioteek.",
"authentication_settings": "Verifikasie instellings",
"authentication_settings_description": "Bestuur wagwoord, OAuth en ander verifikasie instellings",
"authentication_settings_disable_all": "Is jy seker jy wil alle aanmeldmetodes deaktiveer? Aanmelding sal heeltemal gedeaktiveer word.",
"authentication_settings_reenable": "Om te heraktiveer, gebruik 'n <link>Server Command</link>.",
"background_task_job": "Agtergrondtake",
"backup_database": "Skep Datastortlêer",
"backup_database": "Rugsteun databasis",
"backup_database_enable_description": "Aktiveer databasisrugsteun",
"backup_keep_last_amount": "Aantal vorige rugsteune om te hou",
"backup_settings": "Rugsteun instellings",
"backup_settings_description": "Bestuur databasis rugsteun instellings.",
"backup_settings_description": "Bestuur databasis rugsteun instellings",
"cleared_jobs": "Poste gevee vir: {job}",
"config_set_by_file": "Config word tans deur 'n konfigurasielêer gestel",
"confirm_delete_library": "Is jy seker jy wil {library}-biblioteek uitvee?",
@@ -53,7 +47,6 @@
"confirm_email_below": "Om te bevestig, tik \"{email}\" hieronder",
"confirm_reprocess_all_faces": "Is jy seker jy wil alle gesigte herverwerk? Dit sal ook genoemde mense skoonmaak.",
"confirm_user_password_reset": "Is jy seker jy wil {user} se wagwoord terugstel?",
"confirm_user_pin_code_reset": "Is jy seker jy wil {user} se PIN kode herstel?",
"create_job": "Skep werk",
"cron_expression": "Cron uitdrukking",
"cron_expression_description": "Stel die skanderingsinterval in met die cron-formaat. Vir meer inligting verwys asseblief na bv. <link>Crontab Guru</link>",
@@ -63,14 +56,10 @@
"exclusion_pattern_description": "Met uitsluitingspatrone kan jy lêers en vouers ignoreer wanneer jy jou biblioteek skandeer. Dit is nuttig as jy vouers het wat lêers bevat wat jy nie wil invoer nie, soos RAW-lêers.",
"external_library_management": "Eksterne Biblioteekbestuur",
"face_detection": "Gesig deteksie",
"face_detection_description": "Detecteer die gesigte in media deur middel van masjienleer. Vir videos word slegs die duimnaelskets oorweeg. “Herlaai” (ver)werk al die media weer. “Stel terug” verwyder boonop alle huidige gesigdata. “Onverwerk” plaas bates in die tou wat nog nie verwerk is nie. Gedekte gesigte sal ná voltooiing van Gesigdetectie vir Gesigherkenning in die tou geplaas word, om hulle in bestaande of nuwe persone te groepeer.",
"facial_recognition_job_description": "Groepeer gesigte in mense in. Die stap is vinniger nadat Gesig Deteksie klaar is. \"Herstel\" (her-)groepeer alle gesigte. \"Vermiste\" plaas gesigte in ry wat nie 'n persoon gekoppel het nie.",
"failed_job_command": "Opdrag {command} het misluk vir werk: {job}",
"force_delete_user_warning": "WAARSKUWING: Dit sal onmiddellik die gebruiker en alle bates verwyder. Dit kan nie ontdoen word nie en die lêers kan nie herstel word nie.",
"image_format": "Formaat",
"image_format_description": "WebP produseer kleiner lêers as JPEG, maar is stadiger om te enkodeer.",
"image_fullsize_description": "Vol grote prent met geen metadata, gebruik wanner ingezoem",
"image_fullsize_enabled": "Skakel aan vol grote prent generasie",
"image_prefer_embedded_preview": "Verkies ingebedde voorskou",
"image_prefer_wide_gamut": "Verkies wide gamut",
"image_prefer_wide_gamut_setting_description": "Gebruik Display P3 vir kleinkiekies. Dit behou die lewendheid van beelde met wye kleurruimtes beter, maar beelde kan anders verskyn op ou apparate met 'n ou blaaierweergawe. sRGB-beelde gebruik steeds sRGB om kleurverskuiwings te voorkom.",
@@ -88,99 +77,8 @@
"job_concurrency": "{job} gelyktydigheid",
"job_created": "Taak gemaak",
"job_not_concurrency_safe": "Hierdie taak kan nie gelyktydig uitgevoer word nie.",
"job_settings": "Agtergrondtaakinstellings",
"job_settings_description": "Bestuur werkgelyktydigheid",
"job_status": "Werkstatus",
"library_created": "Biblioteek geskep: {library}",
"library_deleted": "Biblioteek verwyder",
"library_import_path_description": "Spesifiseer 'n leer om in te neem. Hierdie leer, en al die sub leers, gaan geskandeer for vir prente en videos.",
"library_scanning": "Periodieke Skandering",
"library_scanning_description": "Stel periodieke skandering van biblioteek in",
"library_scanning_enable_description": "Aktiveer periodieke biblioteekskandering",
"library_settings": "Eksterne Biblioteek",
"map_settings": "Kaart",
"migration_job": "Migrasie",
"oauth_settings": "OAuth",
"transcoding_acceleration_vaapi": "VAAPI"
"job_settings": "Agtergrondtaakinstellings"
},
"administration": "Administrasie",
"advanced": "Gevorderde",
"albums": "Albums",
"all": "Alle",
"anti_clockwise": "Anti-kloksgewys",
"archive": "Argief",
"asset_skipped": "Oorgeslaan",
"asset_uploaded": "Opgelaai",
"asset_uploading": "Oplaai…",
"assets": "Bates",
"back": "Terug",
"backward": "Agteruit",
"build": "Bou",
"camera": "Kamera",
"cancel": "Kanselleer",
"city": "Stad",
"clockwise": "Kloksgewys",
"close": "Maak toe",
"color": "Kleur",
"confirm": "Bevestig",
"contain": "Bevat",
"context": "Konteks",
"continue": "Gaan voort",
"country": "Land",
"cover": "Bedek",
"create": "Skep",
"created": "Geskep",
"dark": "Donker",
"day": "Dag",
"delete": "Verwyder",
"description": "Beskrywing",
"details": "Besonderhede",
"direction": "Rigting",
"discover": "Ontdek",
"documentation": "Dokumentasie",
"done": "Klaar",
"download": "Aflaai",
"download_settings": "Aflaai",
"duplicates": "Duplikate",
"duration": "Duur",
"edit": "Wysig",
"edited": "Gewysigd",
"search_by_description": "Soek by beskrywing",
"search_by_description_example": "Stapdag in Sapa",
"version": "Weergawe",
"version_announcement_closing": "Jou friend, Alex",
"version_history": "Weergawegeskiedenis",
"version_history_item": "{version} geinstaleerd op {date}",
"video": "Video",
"videos": "Video's",
"view": "Bekyk",
"view_album": "Bekyk Album",
"view_all": "Bekyk alle",
"view_all_users": "Bekyk alle gebruikers",
"view_in_timeline": "Bekyk in tydlyn",
"view_link": "Bekyk skakel",
"view_links": "Bekyk skakels",
"view_name": "Bekyk",
"view_next_asset": "Bekyk volgende bate",
"view_previous_asset": "Bekyk vorige bate",
"view_qr_code": "Bekyk QR-kode",
"view_stack": "Bekyk stapel",
"view_user": "Bekyk gebruiker",
"viewer_remove_from_stack": "Verwyder van stapel",
"viewer_stack_use_as_main_asset": "Gebruik as hoofbate",
"viewer_unstack": "Ontstapel",
"visibility_changed": "Sigbaarheid verander voor {count, plural, one {# person} other {# people}}",
"waiting": "Wag",
"warning": "Waaskuwing",
"week": "Week",
"welcome": "Welkom",
"welcome_to_immich": "Welkom by Immich",
"wifi_name": "Wi-Fi Naam",
"wrong_pin_code": "Verkeerde PIN-kode",
"year": "Jaar",
"years_ago": "{years, plural, one {# year} other {# years}} gelede",
"yes": "Ja",
"you_dont_have_any_shared_links": "Jy het geen gedeelde skakels",
"your_wifi_name": "Jou Wi-Fi naam",
"zoom_image": "Vergroot Prent"
"search_by_description_example": "Stapdag in Sapa"
}

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,6 @@
"add_partner": "Дадаць партнёра",
"add_path": "Дадаць шлях",
"add_photos": "Дадаць фота",
"add_tag": "Дадаць тэг",
"add_to": "Дадаць у…",
"add_to_album": "Дадаць у альбом",
"add_to_album_bottom_sheet_added": "Дададзена да {album}",
@@ -34,30 +33,28 @@
"added_to_favorites_count": "Дададзена {count, number} да абранага",
"admin": {
"add_exclusion_pattern_description": "Дадайце шаблоны выключэнняў. Падтрымліваецца выкарыстанне сімвалаў * , ** і ?. Каб ігнараваць усе файлы ў любой дырэкторыі з назвай \"Raw\", выкарыстоўвайце \"**/Raw/**\". Каб ігнараваць усе файлы, якія заканчваюцца на \".tif\", выкарыстоўвайце \"**/.tif\". Каб ігнараваць абсолютны шлях, выкарыстоўвайце \"/path/to/ignore/**\".",
"admin_user": "Адміністратар",
"asset_offline_description": "Гэты знешні бібліятэчны актыў больш не знойдзены на дыску і быў перамешчаны ў сметніцу. Калі файл быў перамешчаны ў межах бібліятэкі, праверце вашу хроніку для новага адпаведнага актыва. Каб аднавіць гэты актыў, пераканайцеся, што шлях да файла ніжэй даступны для Immich і адскануйце бібліятэку.",
"authentication_settings": "Налады праверкі сапраўднасці",
"authentication_settings_description": "Кіраванне паролямі, OAuth, і іншыя налады праверкі сапраўднасці",
"authentication_settings_disable_all": "Вы ўпэўнены, што жадаеце адключыць усе спосабы логіну? Логін будзе цалкам адключаны.",
"authentication_settings_reenable": "Каб зноў уключыць, выкарыстайце <link>Каманду сервера</link>.",
"background_task_job": "Фонавыя заданні",
"backup_database": "Стварыць рэзервовую копію базы даных",
"backup_database": "Рэзервовая копія базы даных",
"backup_database_enable_description": "Уключыць рэзерваванне базы даных",
"backup_keep_last_amount": "Колькасць папярэдніх рэзервовых копій для захавання",
"backup_settings": "Налады рэзервовага капіявання",
"backup_settings_description": "Кіраванне наладамі рэзервавання базы даных.",
"backup_settings_description": "Кіраванне наладамі дампа базы дадзеных. Заўвага: гэтыя задачы не кантралююцца, і ў выпадку няўдачы паведамленне адпраўлена не будзе.",
"cleared_jobs": "Ачышчаны заданні для: {job}",
"config_set_by_file": "Канфігурацыя зараз усталявана праз файл канфігурацыі",
"confirm_delete_library": "Вы ўпэўнены што жадаеце выдаліць бібліятэку {library}?",
"config_set_by_file": "Канфігурацыя ў зараз усталявана праз файл канфігурацыі",
"confirm_delete_library": "Вы ўпэўнены што жадаеце выдаліць {library} бібліятэку?",
"confirm_delete_library_assets": "Вы ўпэўнены, што хочаце выдаліць гэтую бібліятэку? Гэта прывядзе да выдалення {count, plural, one {# актыву} other {усіх # актываў}}, якія змяшчаюцца ў Immich, і гэта дзеянне немагчыма будзе адмяніць. Файлы застануцца на дыску.",
"confirm_email_below": "Каб пацвердзіць, увядзіце \"{email}\" ніжэй",
"confirm_reprocess_all_faces": "Вы ўпэўнены, што хочаце пераапрацаваць усе твары? Гэта таксама прывядзе да выдалення імя людзей.",
"confirm_user_password_reset": "Вы ўпэўнены ў тым, што жадаеце скінуць пароль {user}?",
"confirm_user_pin_code_reset": "Вы ўпэўнены ў тым, што жадаеце скінуць PIN-код {user}?",
"create_job": "Стварыць заданне",
"cron_expression": "Выраз Cron",
"cron_expression_description": "Усталюйце інтэрвал сканавання, выкарыстоўваючы фармат cron. Для атрымання дадатковай інфармацыі, калі ласка, звярніцеся, напрыклад, да <link>Crontab Guru</link>",
"cron_expression_presets": "Прадустаноўкі выразаў Cron",
"cron_expression_presets": "Прадустановкі выразаў Cron",
"disable_login": "Адключыць уваход",
"duplicate_detection_job_description": "Запусціць машыннае навучанне на актывах для выяўлення падобных выяў. Залежыць ад Smart Search",
"exclusion_pattern_description": "Шаблоны выключэння дазваляюць ігнараваць файлы і папкі пры сканаванні вашай бібліятэкі. Гэта карысна, калі ў вас ёсць папкі, якія змяшчаюць файлы, якія вы не хочаце імпартаваць, напрыклад, файлы RAW.",
@@ -74,272 +71,15 @@
"image_fullsize_enabled_description": "Ствараць выяву ў поўным памеры для фарматаў, што не прыдатныя для вэб. Калі ўключана опцыя \"Аддаваць перавагу ўбудаванай праяве\", прагляды выкарыстоўваюцца непасрэдна без канвертацыі. Не ўплывае на вэб-прыдатныя фарматы, такія як JPEG.",
"image_fullsize_quality_description": "Якасць выявы ў поўным памеры ад 1 да 100. Больш высокае значэнне лепшае, але прыводзіць да павелічэння памеру файла.",
"image_fullsize_title": "Налады выявы ў поўным памеры",
"image_prefer_embedded_preview_setting_description": "Выкарыстоўваць убудаваныя праявы ў RAW-фотаздымках ў якасці ўваходных дадзеных для апрацоўкі малюнкаў, калі магчыма. Гэта дазваляе атрымаць больш дакладныя колеры для некаторых відарысаў, але ж якасць праяў залежыць ад камеры, і на відарысе можа быць больш артэфактаў сціску.",
"image_prefer_wide_gamut": "Аддаць перавагу шырокай гаме",
"image_preview_title": "Налады папярэдняга прагляду",
"image_quality": "Якасць",
"image_resolution": "Раздзяляльнасць",
"image_settings": "Налады відарыса",
"image_settings_description": "Кіруйце якасцю і раздзяляльнасцю сгенерыраваных відарысаў",
"library_created": "Створана бібліятэка: {library}",
"library_deleted": "Бібліятэка выдалена",
"map_dark_style": "Цёмны стыль",
"map_enable_description": "Уключыць функцыі карты",
"map_gps_settings": "Налады карты і GPS",
"map_light_style": "Светлы стыль",
"map_settings": "Карта",
"map_settings_description": "Кіраванне наладамі карты",
"map_style_description": "URL-адрас style.json тэмы карты",
"metadata_settings": "Налады метаданых",
"oauth_button_text": "Тэкст кнопкі",
"oauth_settings": "OAuth",
"system_settings": "Сістэмныя налады",
"theme_settings": "Налады тэмы",
"transcoding_acceleration_vaapi": "VAAPI",
"transcoding_audio_codec": "Аудыякодэк",
"transcoding_video_codec": "Відэакодэк",
"trash_settings": "Налады сметніцы",
"trash_settings_description": "Кіраванне наладамі сметніцы",
"version_check_settings": "Праверка версіі",
"version_check_settings_description": "Уключыць/адключыць апавяшчэнні аб новай версіі"
"image_settings_description": "Кіруйце якасцю і раздзяляльнасцю сгенерыраваных відарысаў"
},
"advanced_settings_troubleshooting_title": "Выпраўленне непаладак",
"album_added": "Альбом дададзены",
"album_name": "Назва альбома",
"album_remove_user": "Выдаліць карыстальніка?",
"album_updated": "Альбом абноўлены",
"albums": "Альбомы",
"all": "Усе",
"all_albums": "Усе альбомы",
"all_people": "Усе людзі",
"all_videos": "Усе відэа",
"app_bar_signout_dialog_ok": "Так",
"app_bar_signout_dialog_title": "Выйсці",
"app_settings": "Налады праграмы",
"archive": "Архіў",
"archive_size": "Памер архіва",
"asset_uploading": "Запампоўванне…",
"back": "Назад",
"backup_all": "Усе",
"backup_controller_page_background_wifi": "Толькі праз Wi-Fi",
"buy": "Купіць Immich",
"cache_settings_clear_cache_button": "Ачысціць кэш",
"cache_settings_tile_title": "Лакальнае сховішча",
"cancel": "Скасаваць",
"cancel_search": "Скасаваць пошук",
"canceled": "Скасавана",
"city": "Горад",
"clear": "Ачысціць",
"clear_all": "Ачысціць усё",
"client_cert_dialog_msg_confirm": "ОК",
"client_cert_enter_password": "Увядзіце пароль",
"client_cert_import": "Імпарт",
"close": "Закрыць",
"collapse": "Згарнуць",
"collapse_all": "Згарнуць усё",
"color": "Колер",
"color_theme": "Колеравая тэма",
"continue": "Працягнуць",
"control_bottom_app_bar_create_new_album": "Стварыць новы альбом",
"control_bottom_app_bar_delete_from_immich": "Выдаліць з Immich",
"control_bottom_app_bar_delete_from_local": "Выдаліць з прылады",
"control_bottom_app_bar_edit_location": "Рэдагаваць месцазнаходжанне",
"country": "Краіна",
"cover": "Вокладка",
"covers": "Вокладкі",
"create": "Стварыць",
"create_album": "Стварыць альбом",
"create_album_page_untitled": "Без назвы",
"create_library": "Стварыць бібліятэку",
"create_link": "Стварыць спасылку",
"create_new_user": "Стварыць новага карыстальніка",
"create_tag": "Стварыць тэг",
"create_user": "Стварыць карыстальніка",
"dark": "Цёмная",
"day": "Дзень",
"delete": "Выдаліць",
"delete_album": "Выдаліць альбом",
"delete_dialog_ok_force": "Усё адно выдаліць",
"delete_dialog_title": "Выдаліць назаўжды",
"delete_face": "Выдаліць твар",
"delete_key": "Выдаліць ключ",
"delete_library": "Выдаліць бібліятэку",
"delete_link": "Выдаліць спасылку",
"delete_local_dialog_ok_force": "Усё адно выдаліць",
"delete_others": "Выдаліць іншыя",
"delete_tag": "Выдаліць тэг",
"delete_user": "Выдаліць карыстальніка",
"discord": "Discord",
"documentation": "Дакументацыя",
"done": "Гатова",
"download": "Спампаваць",
"download_canceled": "Спампоўванне скасавана",
"download_complete": "Спампоўванне завершана",
"download_enqueue": "Спампоўванне дададзена ў чаргу",
"downloading": "Спампоўванне",
"edit": "Рэдагаваць",
"edit_album": "Рэдагаваць альбом",
"edit_avatar": "Рэдагаваць аватар",
"edit_date": "Рэдагаваць дату",
"edit_date_and_time": "Рэдагаваь дату і час",
"edit_description": "Рэдагаваць апісанне",
"edit_description_prompt": "Выберыце новае апісанне:",
"edit_faces": "Рэдагаваць твары",
"edit_import_path": "Рэдагаваць шлях імпарту",
"edit_import_paths": "Рэдагаваць шляхі імпарту",
"edit_key": "Рэдагаваць ключ",
"edit_link": "Рэдагаваць спасылку",
"edit_location": "Рэдагаваць месцазнаходжанне",
"edit_location_dialog_title": "Месцазнаходжанне",
"edit_name": "Рэдагаваць назву",
"edit_people": "Рэдагаваць людзей",
"edit_tag": "Рэдагаваць тэг",
"edit_title": "Рэдагаваць загаловак",
"edit_user": "Рэдагаваць карыстальніка",
"edited": "Адрэдагавана",
"editor": "Рэдактар",
"editor_close_without_save_prompt": "Змены не будуць захаваны",
"editor_close_without_save_title": "Закрыць рэдактар?",
"editor_crop_tool_h2_aspect_ratios": "Суадносіны бакоў",
"editor_crop_tool_h2_rotation": "Паварот",
"error": "Памылка",
"error_saving_image": "Памылка: {error}",
"exif": "Exif",
"exif_bottom_sheet_description": "Дадаць апісанне...",
"favorite": "У абраным",
"favorite_or_unfavorite_photo": "Дадаць або выдаліць фота з абранага",
"favorites": "Абраныя",
"file_name": "Назва файла",
"filename": "Назва файла",
"filetype": "Тып файла",
"filter": "Фільтр",
"forward": "Наперад",
"gcast_enabled": "Google Cast",
"general": "Агульныя",
"go_back": "Назад",
"go_to_folder": "Перайсці да папкі",
"hi_user": "Вітаем, {name} ({email})",
"hide_all_people": "Схаваць усіх людзей",
"hide_gallery": "Схаваць галерэю",
"hide_named_person": "Схаваць {name}",
"hide_password": "Схаваць пароль",
"hide_person": "Схаваць чалавека",
"image_viewer_page_state_provider_download_started": "Спампоўванне пачалося",
"immich_logo": "Лагатып Immich",
"interval": {
"day_at_onepm": "Кожны дзень а 13-й гадзіне",
"hours": "{hours, plural, one {Кожную гадзіну} few {Кожныя {hours, number} гадзіны} many {Кожныя {hours, number} гадзін} other {Кожныя {hours, number} гадзін}}",
"night_at_midnight": "Кожную ноч апоўначы",
"night_at_twoam": "Кожную ноч а 2-й гадзіне"
},
"language": "Мова",
"library": "Бібліятэка",
"light": "Светлая",
"login_form_back_button_text": "Назад",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port",
"login_form_password_hint": "пароль",
"login_form_save_login": "Заставацца ў сістэме",
"main_menu": "Галоўнае меню",
"map_location_dialog_yes": "Так",
"map_settings_dark_mode": "Цёмны рэжым",
"map_settings_date_range_option_day": "Апошнія 24 гадзіны",
"map_settings_date_range_option_days": "Апошніх дзён: {days}",
"map_settings_date_range_option_year": "Апошні год",
"map_settings_date_range_option_years": "Апошніх год: {years}",
"map_settings_dialog_title": "Налады карты",
"map_settings_theme_settings": "Тэма карты",
"menu": "Меню",
"minute": "Хвіліна",
"month": "Месяц",
"monthly_title_text_date_format": "MMMM y",
"my_albums": "Мае альбомы",
"name": "Імя",
"name_or_nickname": "Імя або псеўданім",
"next": "Далей",
"no": "Не",
"offline": "Па-за сеткай",
"ok": "ОК",
"online": "У сетцы",
"open": "Адкрыць",
"or": "або",
"partner_list_user_photos": "Фота карыстальніка {user}",
"pause": "Прыпыніць",
"people": "Людзі",
"permission_onboarding_back": "Назад",
"permission_onboarding_continue_anyway": "Усё адно працягнуць",
"photos": "Фота",
"photos_and_videos": "Фота і відэа",
"place": "Месца",
"places": "Месцы",
"port": "Порт",
"previous": "Папярэдняе",
"profile": "Профіль",
"profile_drawer_app_logs": "Журналы",
"profile_drawer_github": "GitHub",
"purchase_button_buy": "Купіць",
"purchase_button_buy_immich": "Купіць Immich",
"purchase_button_select": "Выбраць",
"remove": "Выдаліць",
"remove_from_album": "Выдаліць з альбома",
"remove_from_favorites": "Выдаліць з абраных",
"remove_tag": "Выдаліць тэг",
"remove_url": "Выдаліць URL-адрас",
"remove_user": "Выдаліць карыстальніка",
"rename": "Перайменаваць",
"repository": "Рэпазіторый",
"reset": "Скінуць",
"reset_password": "Скінуць пароль",
"restore": "Аднавіць",
"restore_all": "Аднавіць усё",
"restore_user": "Аднавіць карыстальніка",
"resume": "Узнавіць",
"role": "Роля",
"role_editor": "Рэдактар",
"role_viewer": "Глядач",
"save": "Захаваць",
"save_to_gallery": "Захаваць у галерэю",
"search_filter_date": "Дата",
"search_filter_location": "Месцазнаходжанне",
"search_filter_location_title": "Выберыце месцазнаходжанне",
"search_filter_media_type": "Тып медыя",
"search_filter_media_type_title": "Выберыце тып медыя",
"search_page_screenshots": "Здымкі экрана",
"search_page_selfies": "Сэлфі",
"search_page_things": "Рэчы",
"search_page_your_map": "Ваша карта",
"second": "Секунда",
"send_message": "Адправіць паведамленне",
"setting_languages_apply": "Ужыць",
"setting_notifications_notify_never": "ніколі",
"settings": "Налады",
"share_add_photos": "Дадаць фота",
"shared_album_section_people_title": "ЛЮДЗІ",
"shared_link_info_chip_metadata": "EXIF",
"sharing_page_empty_list": "ПУСТЫ СПІС",
"sign_out": "Выйсці",
"sign_up": "Зарэгістравацца",
"size": "Памер",
"sort_title": "Загаловак",
"source": "Крыніца",
"tag": "Тэг",
"tags": "Тэгі",
"theme": "Тэма",
"theme_selection": "Выбар тэмы",
"timeline": "Хроніка",
"total": "Усяго",
"trash": "Сметніца",
"trash_page_delete_all": "Выдаліць усе",
"trash_page_restore_all": "Аднавіць усе",
"trash_page_title": "Сметніца ({count})",
"type": "Тып",
"undo": "Адрабіць",
"upload": "Запампаваць",
"upload_status_errors": "Памылкі",
"uploading": "Запампоўванне",
"url": "URL-адрас",
"user": "Карыстальнік",
"user_has_been_deleted": "Гэты карыстальнік быў выдалены.",
"user_id": "ID карыстальніка",
"user_purchase_settings": "Купля",
"user_purchase_settings_description": "Кіруйце пакупкамі",
@@ -372,14 +112,14 @@
"view_next_asset": "Паказаць наступны аб'ект",
"view_previous_asset": "Праглядзець папярэдні аб'ект",
"view_stack": "Прагляд стэка",
"visibility_changed": "Бачнасць змянілася для {count, plural, one {# чалавека} other {# чалавек}}",
"visibility_changed": "Відзімасць змянілася для {count, plural, one {# чалавек(-аў)} астатніх {# чалавек}}",
"waiting": "Чакаюць",
"warning": "Папярэджанне",
"week": "Тыдзень",
"welcome": "Вітаем",
"welcome_to_immich": "Вітаем у Immich",
"year": "Год",
"years_ago": "{years, plural, one {# год} few {# гады} many {# гадоў} other {# гадоў}} таму",
"years_ago": "{years, plural, one {# год} other {# гадоў}} таму",
"yes": "Так",
"you_dont_have_any_shared_links": "У вас няма абагуленых спасылак",
"zoom_image": "Павялічыць відарыс"

View File

@@ -34,7 +34,6 @@
"added_to_favorites_count": "Добавени {count, number} към любими",
"admin": {
"add_exclusion_pattern_description": "Добави модели за изключване. Поддържа се \"globbing\" с помощта на *, ** и ?. За да игнорирате всички файлове в директория с име \"Raw\", използвайте \"**/Raw/**\". За да игнорирате всички файлове, завършващи на \".tif\", използвайте \"**/*.tif\". За да игнорирате абсолютен път, използвайте \"/path/to/ignore/**\".",
"admin_user": "Администратор",
"asset_offline_description": "Този външен библиотечен елемент не може да бъде открит на диска и е преместен в кошчето за боклук. Ако файлът е преместен в библиотеката, проверете вашата история за нов съответстващ елемент. За да възстановите елемента, моля проверете дали файловият път отдолу може да бъде достъпен от Immich и сканирайте библиотеката.",
"authentication_settings": "Настройки за удостоверяване",
"authentication_settings_description": "Управление на парола, OAuth и други настройки за удостоверяване",
@@ -53,7 +52,7 @@
"confirm_email_below": "За потвърждение, моля въведете \"{email}\" отдолу",
"confirm_reprocess_all_faces": "Сигурни ли сте, че искате да се обработят лицата отново? Това ще изчисти всички именувани хора.",
"confirm_user_password_reset": "Сигурни ли сте, че искате да нулирате паролата на {user}?",
"confirm_user_pin_code_reset": "Наистина ли искате да смените PIN кода на потребителя {user}?",
"confirm_user_pin_code_reset": "Наистина ли искаш да смениш PIN-кода на потребителя {user}?",
"create_job": "Създайте задача",
"cron_expression": "Cron израз",
"cron_expression_description": "Настрой интервала на сканиране използвайки cron формата. За повече информация <link>Crontab Guru</link>",
@@ -166,26 +165,12 @@
"metadata_settings_description": "Управление на настройките за метаданни",
"migration_job": "Миграция",
"migration_job_description": "Мигриране на миниатюрите за елементи и лица към най-новата структура на папките",
"nightly_tasks_cluster_faces_setting_description": "Изпълни разпознаване на лице за открити нови лица",
"nightly_tasks_cluster_new_faces_setting": "Разпознаване на нови лица",
"nightly_tasks_database_cleanup_setting": "Задачи по почистване на базата данни",
"nightly_tasks_database_cleanup_setting_description": "Премахни стари, ненужни записи от базата данни",
"nightly_tasks_generate_memories_setting": "Създаване на спомени",
"nightly_tasks_generate_memories_setting_description": "Създаване на нови спомени от съществуващи обекти",
"nightly_tasks_missing_thumbnails_setting": "Генериране на липсващи миниатюри",
"nightly_tasks_missing_thumbnails_setting_description": "Добавяне на обекти без миниатюра в опашката за създаване на миниатюра",
"nightly_tasks_settings": "Настройка на задачи за през нощта",
"nightly_tasks_settings_description": "Управление на задачите, изпълнявани през нощта",
"nightly_tasks_start_time_setting": "Време за начало",
"nightly_tasks_start_time_setting_description": "Време, когато сървъра ще започне изпълнение на нощни задачи",
"nightly_tasks_sync_quota_usage_setting": "Квота за синхронизация",
"nightly_tasks_sync_quota_usage_setting_description": "Обновяване на квотата според текущото потребление",
"no_paths_added": "Няма добавени пътища",
"no_pattern_added": "Няма добавен модел",
"note_apply_storage_label_previous_assets": "Забележка: За да приложите етикета за съхранение към предварително качени файлове, стартирайте",
"note_cannot_be_changed_later": "ВНИМАНИЕ: Това не може да бъде променено по-късно!",
"notification_email_from_address": "От адрес",
"notification_email_from_address_description": "Електронна поща на изпращача, например: \"Immich Photo Server <noreply@example.com>\". Използвайте адрес, от който може да изпращате имейли.",
"notification_email_from_address_description": "Електронна поща на изпращача, например: \"Immich Photo Server <noreply@example.com>\". Използвай адрес, от който може да изпращаш имейли.",
"notification_email_host_description": "Хост на сървъра за електронна поща (например: smtp.immich.app)",
"notification_email_ignore_certificate_errors": "Игнорирайте сертификационни грешки",
"notification_email_ignore_certificate_errors_description": "Игнорирай грешки свързани с валидация на TLS сертификат (не се препоръчва)",
@@ -194,7 +179,7 @@
"notification_email_sent_test_email_button": "Изпрати тестов имейл и запази",
"notification_email_setting_description": "Настройки за изпращане на имейл известия",
"notification_email_test_email": "Изпрати тестов имейл",
"notification_email_test_email_failed": "Неуспешно изпращане на тестов имейл, проверете настройките",
"notification_email_test_email_failed": "Неуспешно изпращане на тестов имейл, провери променливите",
"notification_email_test_email_sent": "Тестов имейл беше изпратен на {email}. Проверете входящата си пощa.",
"notification_email_username_description": "Потребителско име за удостоверяване пред имейл сървъра",
"notification_enable_email_notifications": "Включване на имейл известията",
@@ -210,8 +195,6 @@
"oauth_mobile_redirect_uri": "URI за мобилно пренасочване",
"oauth_mobile_redirect_uri_override": "URI пренасочване за мобилни устройства",
"oauth_mobile_redirect_uri_override_description": "Разреши когато доставчика за OAuth удостоверяване не позволява за мобилни URI идентификатори, като ''{callback}''",
"oauth_role_claim": "Потвърждение на роля",
"oauth_role_claim_description": "Автоматично предоставяне на административни права при наличие на това потвържение. Потвърждението може да има стойност 'user' или 'admin'.",
"oauth_settings": "OAuth",
"oauth_settings_description": "Управление на настройките за вход с OAuth",
"oauth_settings_more_details": "За повече информация за функционалността, се потърсете в <link>docs</link>.",
@@ -220,7 +203,7 @@
"oauth_storage_quota_claim": "Заявка за квота за съхранение",
"oauth_storage_quota_claim_description": "Автоматично задайте квотата за съхранение на потребителя със стойността от тази заявка.",
"oauth_storage_quota_default": "Стандартна квота за съхранение (GiB)",
"oauth_storage_quota_default_description": "Квота в GiB, която да се използва, когато не е посочено друго.",
"oauth_storage_quota_default_description": "Квота в GiB, която да се използва, когато не е предоставена заявка (Въведете 0 за неограничена квота).",
"oauth_timeout": "Време на изчакване при заявка",
"oauth_timeout_description": "Време за изчакване на отговор на заявка, в милисекунди",
"password_enable_description": "Влизане с имейл и парола",
@@ -260,7 +243,7 @@
"storage_template_migration_info": "Шаблона ще преобразува всички разширения на имената на файловете в долен регистър. Промените в шаблоните ще се прилагат само за нови елементи. За да приложите принудително шаблона към вече качени елементи, изпълнете <link>{job}</link>.",
"storage_template_migration_job": "Задача за миграция на шаблона за съхранение",
"storage_template_more_details": "За повече подробности относно тази функция се обърнете към шаблона <template-link>Storage Template</template-link> и неговите <implications-link> последствия </implications-link>",
"storage_template_onboarding_description_v2": "Когато е разрешена, тази функция ще организира автоматично файловете, според шаблон, дефиниран от потребителя. За допълнителна информация, моля вижте <link>документацията</link>.",
"storage_template_onboarding_description": "Когато е активирана, тази функция ще организира автоматично файлове въз основа на дефиниран от потребителя шаблон. Поради проблеми със стабилността, функцията е изключена по подразбиране. За повече информация, моля, вижте <link>документацията</link>.",
"storage_template_path_length": "Ограничение на дължината на пътя: <b>{length, number}</b>/{limit, number}",
"storage_template_settings": "Шаблон за съхранение",
"storage_template_settings_description": "Управление на структурата на папките и името на файла за качване",
@@ -373,9 +356,7 @@
"admin_password": "Администраторска парола",
"administration": "Администрация",
"advanced": "Разширено",
"advanced_settings_beta_timeline_subtitle": "Опитайте новите функции на приложението",
"advanced_settings_beta_timeline_title": "Бета версия на времевата линия",
"advanced_settings_enable_alternate_media_filter_subtitle": "При синхронизация, използвайте тази опция като филтър, основан на промяна на даден критерии. Опитайте само в случай, че приложението има проблем с откриване на всички албуми.",
"advanced_settings_enable_alternate_media_filter_subtitle": "При синхронизация, използвай тази опция като филтър, основан на промяна на даден критерии. Опитай само в случай, че приложението има проблем с откриване на всички албуми.",
"advanced_settings_enable_alternate_media_filter_title": "[ЕКСПЕРИМЕНТАЛНО] Използвай филтъра на алтернативното устройство за синхронизация на албуми",
"advanced_settings_log_level_title": "Ниво на запис в дневника: {level}",
"advanced_settings_prefer_remote_subtitle": "Някои устройства са твърде бавни за да генерират миниатюри. Активирай тази опция за да се зареждат винаги от сървъра.",
@@ -411,7 +392,7 @@
"album_updated_setting_description": "Получавайте известие по имейл, когато споделен албум има нови файлове",
"album_user_left": "Напусна {album}",
"album_user_removed": "Премахнат {user}",
"album_viewer_appbar_delete_confirm": "Сигурни ли сте, че искате да изтриете този албум от своя профил?",
"album_viewer_appbar_delete_confirm": "Сигурен ли си, че искаш да изтриеш този албум от своя профил?",
"album_viewer_appbar_share_err_delete": "Неуспешно изтриване на албум",
"album_viewer_appbar_share_err_leave": "Неуспешно напускане на албум",
"album_viewer_appbar_share_err_remove": "Проблем при пермахване на обекти от албума",
@@ -445,7 +426,6 @@
"app_settings": "Настройки ма приложението",
"appears_in": "Излиза в",
"archive": "Архив",
"archive_action_prompt": "{count} са добавени в Архива",
"archive_or_unarchive_photo": "Архивиране или деархивиране на снимка",
"archive_page_no_archived_assets": "Не са намерени обекти в архива",
"archive_page_title": "Архив ({count})",
@@ -483,12 +463,10 @@
"assets": "Елементи",
"assets_added_count": "Добавено {count, plural, one {# asset} other {# assets}}",
"assets_added_to_album_count": "Добавен(и) са {count, plural, one {# актив} other {# актива}} в албума",
"assets_cannot_be_added_to_album_count": "{count, plural, one {Обекта не може да се добави} other {Обектите не може да се добавят}} в албума",
"assets_added_to_name_count": "Добавен(и) са {count, plural, one {# актив} other {# актива}} към {hasName, select, true {<b>{name}</b>} other {нов албум}}",
"assets_count": "{count, plural, one {# актив} other {# актива}}",
"assets_deleted_permanently": "{count} обекта са изтрити завинаги",
"assets_deleted_permanently_from_server": "{count} обекта са изтити от Immich сървъра завинаги",
"assets_downloaded_failed": "{count, plural, one {Зареден # файл} many {Заредени # файла} other {заредени # файла}}, {error} - неуспешно",
"assets_downloaded_successfully": "Успешно {count, plural, one {е качен # файл} many {са качени # файла} other {са качени # файла}}",
"assets_moved_to_trash_count": "Преместен(и) са {count, plural, one {# актив} other {# актива}} в кошчето",
"assets_permanently_deleted_count": "Постоянно изтрит(и) са {count, plural, one {# актив} other {# актива}}",
"assets_removed_count": "Премахнат(и) са {count, plural, one {# актив} other {# актива}}",
@@ -527,7 +505,7 @@
"backup_controller_page_background_app_refresh_disabled_title": "Фоново обновяване е изключено",
"backup_controller_page_background_app_refresh_enable_button_text": "Иди в настройки",
"backup_controller_page_background_battery_info_link": "Покажи ми как",
"backup_controller_page_background_battery_info_message": "За успешно архивиране във фонов режим, моля изключете оптимизациите на батерията, ограничаващи фоновата активност на Immich.\n\nТази настройка е според устройството, моля потърсете информация според производителя на устройството.",
"backup_controller_page_background_battery_info_message": "За успешно архивиране във фонов режим, моля изключи оптимизациите на батерията, ограничаващи фоновата активност на Immich.\n\nТази настройка е според устройството, моля потърси информация според производителя на устройството.",
"backup_controller_page_background_battery_info_ok": "Ок",
"backup_controller_page_background_battery_info_title": "Оптимизация на батерията",
"backup_controller_page_background_charging": "Само при зареждане",
@@ -608,8 +586,8 @@
"cannot_merge_people": "Не може да обединява хора",
"cannot_undo_this_action": "Не можете да отмените това действие!",
"cannot_update_the_description": "Описанието не може да бъде актуализирано",
"cast": оточно предаване",
"cast_description": "Настройка на наличните цели за предаване",
"cast": ромяна на регистъра",
"cast_description": "Настройка на наличните цели за промяна на регистъра",
"change_date": "Промени датата",
"change_description": "Промени описанието",
"change_display_order": "Промени реда на показване",
@@ -620,11 +598,11 @@
"change_password": "Промени паролата",
"change_password_description": "Това е или първият път, когато влизате в системата, или е направена заявка за промяна на паролата ви. Моля, въведете новата парола по-долу.",
"change_password_form_confirm_password": "Потвърди паролата",
"change_password_form_description": "Здравейте {name},\n\nТова или е първото ви вписване в системата или има подадена заявка за смяна на паролата. Моля, въведете нова парола в полето по-долу.",
"change_password_form_description": "Здравей {name},\n\nТова или е първото ти вписване в системата или има подадена заявка за смяна на паролата. Моля, въведи нова парола в полето по-долу.",
"change_password_form_new_password": "Нова парола",
"change_password_form_password_mismatch": "Паролите не съвпадат",
"change_password_form_reenter_new_password": "Повтори новата парола",
"change_pin_code": "Смени PIN кода",
"change_pin_code": "Смени PIN-кода",
"change_your_password": "Променете паролата си",
"changed_visibility_successfully": "Видимостта е променена успешно",
"check_corrupt_asset_backup": "Провери за повредени архивни копия",
@@ -657,17 +635,17 @@
"comments_and_likes": "Коментари и харесвания",
"comments_are_disabled": "Коментарите са деактивирани",
"common_create_new_album": "Създай нов албум",
"common_server_error": "Моля, проверете мрежовата връзка, убедете се, че сървъра е достъпен и версиите на сървъра и приложението са съвместими.",
"common_server_error": "Моля, провери мрежовата връзка, убеди се, че сървъра е достъпен и версиите на сървъра и приложението са съвместими.",
"completed": "Завършено",
"confirm": "Потвърди",
"confirm_admin_password": "Потвърждаване на паролата на администратора",
"confirm_delete_face": "Сигурни ли сте, че искате да изтриете лицето на {name} от актива?",
"confirm_delete_shared_link": "Сигурни ли сте, че искате да изтриете тази споделена връзка?",
"confirm_keep_this_delete_others": "Всички останали файлове в стека ще бъдат изтрити, с изключение на този файл. Сигурни ли сте, че искате да продължите?",
"confirm_new_pin_code": "Потвърди новия PIN код",
"confirm_new_pin_code": "Потвърди новия PIN-код",
"confirm_password": "Потвърдете паролата",
"confirm_tag_face": "Искате ли да отбележите това лице като {name}?",
"confirm_tag_face_unnamed": "Искате ли да отбележите това лице?",
"confirm_tag_face": "Искаш ли да отбележиш това лице като {name}?",
"confirm_tag_face_unnamed": "Искаш ли да отбележиш това лице?",
"connected_device": "Свързано устройство",
"connected_to": "Свързан към",
"contain": "В рамките на",
@@ -714,14 +692,14 @@
"crop": "Изрежи",
"curated_object_page_title": "Неща",
"current_device": "Текущо устройство",
"current_pin_code": "Сегашен PIN код",
"current_pin_code": "Сегашен PIN-код",
"current_server_address": "Настоящ адрес на сървъра",
"custom_locale": "Персонализиран локал",
"custom_locale_description": "Форматиране на дати и числа в зависимост от езика и региона",
"daily_title_text_date": "E, dd MMM",
"daily_title_text_date_year": "E, dd MMM yyyy",
"dark": "Тъмен",
"dark_theme": "Тъмна тема",
"darkTheme": "Превключи на тъмна тема",
"date_after": "Дата след",
"date_and_time": "Дата и час",
"date_before": "Дата преди",
@@ -735,9 +713,8 @@
"deduplication_info": "Информация за дедупликацията",
"deduplication_info_description": "За автоматично предварително избиране на ресурси и премахване на дубликати на едро, разглеждаме:",
"default_locale": "Локализация по подразбиране",
"default_locale_description": "Форматиране на дати и числа в зависимост от езиковата настройка на браузъра",
"default_locale_description": "Форматиране на дати и числа в зависимост от местоположението на браузъра",
"delete": "Изтрий",
"delete_action_prompt": "{count} са изтрити завинаги",
"delete_album": "Изтрий албум",
"delete_api_key_prompt": "Сигурни ли сте, че искате да изтриете този API ключ?",
"delete_dialog_alert": "Тези обекти ще бъдат изтрити завинаги и от Immich сървъра и от устройството",
@@ -751,20 +728,19 @@
"delete_key": "Изтрий ключ",
"delete_library": "Изтрий библиотека",
"delete_link": "Изтрий линк",
"delete_local_action_prompt": "{count} са изтрити локално",
"delete_local_dialog_ok_backed_up_only": "Изтрий локално само архивираните",
"delete_local_dialog_ok_force": "Въпреки това изтрий",
"delete_others": "Изтрий останалите",
"delete_shared_link": "Изтриване на споделен линк",
"delete_shared_link_dialog_title": "Изтрий споделената връзка",
"delete_tag": "Изтрий таг",
"delete_tag_confirmation_prompt": "Сигурни ли сте, че искате да изтриете тага {tagName}?",
"delete_tag_confirmation_prompt": "Сигурни ли сте, че искате да изтриете таг {tagName}?",
"delete_user": "Изтрий потребител",
"deleted_shared_link": "Изтрит споделен линк",
"deletes_missing_assets": "Изтрива файлове, които липсват на диска",
"description": "Описание",
"description_input_hint_text": "Добави описание...",
"description_input_submit_error": "Неуспешно обновяване на описанието. За подробности вижте в дневника",
"description_input_submit_error": "Неуспешно обновяване на описанието. За подробности виж в дневника",
"details": "Детайли",
"direction": "Посока",
"disabled": "Изключено",
@@ -782,7 +758,6 @@
"documentation": "Документация",
"done": "Готово",
"download": "Изтегли",
"download_action_prompt": "Зареждане на {count} обекта",
"download_canceled": "Изтеглянето е отменено",
"download_complete": "Изтеглянето завърши",
"download_enqueue": "Изтеглянето е добавено в опашката",
@@ -820,7 +795,6 @@
"edit_key": "Редактиране на ключ",
"edit_link": "Редактиране на линк",
"edit_location": "Редактиране на местоположението",
"edit_location_action_prompt": "{count} локации са редактирани",
"edit_location_dialog_title": "Местоположение",
"edit_name": "Редактиране на име",
"edit_people": "Редактиране на хора",
@@ -839,13 +813,13 @@
"empty_trash": "Изпразване на кош",
"empty_trash_confirmation": "Сигурни ли сте, че искате да изпразните кошчето? Това ще премахне всичко в кошчето за постоянно от Immich.\nНе можете да отмените това действие!",
"enable": "Включване",
"enable_biometric_auth_description": "Въведете вашия PIN код, за да разрешите биометрично удостоверяване",
"enable_biometric_auth_description": "Въведи своя ПИН-код, за да разрешиш биометрично удостоверяване",
"enabled": "Включено",
"end_date": "Крайна дата",
"enqueued": "Наредено в опашката",
"enter_wifi_name": "Въведи име на Wi-Fi",
"enter_your_pin_code": "Въведете вашия PIN код",
"enter_your_pin_code_subtitle": "Въвеждане на PIN код, за достъп до заключена папка",
"enter_your_pin_code": "Въведи твоя PIN-код",
"enter_your_pin_code_subtitle": "Въведи твоя PIN-код, за да достъпиш заключена папка",
"error": "Грешка",
"error_change_sort_album": "Неуспешна промяна на реда на сортиране на албум",
"error_delete_face": "Грешка при изтриване на лице от актива",
@@ -945,7 +919,7 @@
"unable_to_remove_partner": "Неуспешно премахване на партньор",
"unable_to_remove_reaction": "Неуспешно премахване на реакцията",
"unable_to_reset_password": "Неуспешно смяна на паролата",
"unable_to_reset_pin_code": "Неуспешно нулиране на PIN кода",
"unable_to_reset_pin_code": "Неуспешно нулиране на PIN-кода",
"unable_to_resolve_duplicate": "Неуспешно справяне с дублирането",
"unable_to_restore_assets": "Неуспешно възстановяване на елементи",
"unable_to_restore_trash": "Неуспешно възстановяване от кошчето",
@@ -1006,7 +980,6 @@
"failed_to_load_assets": "Неуспешно зареждане на елементи",
"failed_to_load_folder": "Неуспешно зареждане на папка",
"favorite": "Любим",
"favorite_action_prompt": "{count} са добавени в Любими",
"favorite_or_unfavorite_photo": "Добави или премахни снимка от Любими",
"favorites": "Любими",
"favorites_page_no_favorites": "Не са намерени любими обекти",
@@ -1031,7 +1004,7 @@
"gcast_enabled_description": "За да работи тази функция зарежда външни ресурси от Google.",
"general": "Общи",
"get_help": "Помощ",
"get_wifiname_error": "Неуспешно получаване името на Wi-Fi мрежата. Моля, убедете се, че са предоставени нужните разрешения на приложението и има връзка с Wi-Fi",
"get_wifiname_error": "Неуспешно получаване името на Wi-Fi мрежата. Моля, убеди се, че са предоставени нужните разрешения на приложението и има връзка с Wi-Fi",
"getting_started": "Как да започнем",
"go_back": "Връщане назад",
"go_to_folder": "Отиди в папката",
@@ -1070,7 +1043,7 @@
"home_page_delete_remote_err_local": "Локални обекти не могат да се изтриват от сървъра, пропускане",
"home_page_favorite_err_local": "Локални обекти все още не могат да се правят любими, пропускане",
"home_page_favorite_err_partner": "Партньорски обекти все още не могат да се правят любими, пропускане",
"home_page_first_time_notice": "Ако за първи път използвате приложението, моля изберете албум за архивиране, за да може обектите от времевата линия да се записват в него",
"home_page_first_time_notice": "Ако за първи път използваш приложението, моля избери албум за архивиране, за да може обектите от времевата линия да се записват в него",
"home_page_locked_error_local": "Локални обекти не могат да се преместят в заключена папка, пропускане",
"home_page_locked_error_partner": "Партньорски обекти не могат да се преместят в заключена папка, пропускане",
"home_page_share_err_local": "Локални обекти не могат да се споделят чрез връзка, пропускане",
@@ -1121,7 +1094,6 @@
"ios_debug_info_last_sync_at": "Синхронизирано на {dateTime}",
"ios_debug_info_no_processes_queued": "Няма фонови процеси в опашката",
"ios_debug_info_no_sync_yet": "Все още не е изпълнявана задача за фонова синхронизация",
"ios_debug_info_processes_queued": "{count, plural, one {{count} фонов процес} many {{count} фонови процеса} other {{count} фонови процеса}} в опашката",
"ios_debug_info_processing_ran_at": "Започната обработка на {dateTime}",
"items_count": "{count, plural, one {# елемент} other {# елементи}}",
"jobs": "Задачи",
@@ -1131,7 +1103,7 @@
"kept_this_deleted_others": "Запази този елемент и другите изтрити {count, plural, one {# елемент} other {# елемента}}",
"keyboard_shortcuts": "Бързи клавишни комбинации",
"language": "Език",
"language_no_results_subtitle": "Опитайте да коригирате термина си за търсене",
"language_no_results_subtitle": "Опитай да коригираш термина си за търсене",
"language_no_results_title": "Не са намерени езици",
"language_search_hint": "Търсене на езици...",
"language_setting_description": "Изберете предпочитан език",
@@ -1150,7 +1122,6 @@
"library_page_sort_created": "Дата на създаване",
"library_page_sort_last_modified": "Последна промяна",
"library_page_sort_title": "Заглавие на албума",
"licenses": "Лицензи",
"light": "Светло",
"like_deleted": "Като изтрит",
"link_motion_video": "Линк към видео",
@@ -1167,14 +1138,13 @@
"location_permission_content": "За да работи функцията автоматично превключване, Immich се нуждае от разрешение за точно местоположение, за да може да чете името на текущата Wi-Fi мрежа",
"location_picker_choose_on_map": "Избери на карта",
"location_picker_latitude_error": "Въведи правилна ширина",
"location_picker_latitude_hint": "Въведете географска ширина тук",
"location_picker_latitude_hint": "Въведи ширината тук",
"location_picker_longitude_error": "Въведи правилна дължина",
"location_picker_longitude_hint": "Въведете географска дължина тук",
"location_picker_longitude_hint": "Въведи дължината тук",
"lock": "Заключи",
"locked_folder": "Заключена папка",
"log_out": "Излизане",
"log_out_all_devices": "Излизане с всички устройства",
"logged_in_as": "Вписан като {user}",
"logged_out_all_devices": "Успешно излизане от всички устройства",
"logged_out_device": "Успешно излизане от устройство",
"login": "Вписване",
@@ -1191,8 +1161,8 @@
"login_form_err_trailing_whitespace": "Интервал в края",
"login_form_failed_get_oauth_server_config": "Грешка при вписване с OAuth, провери URL на сървъра",
"login_form_failed_get_oauth_server_disable": "На този сървър OAuth не е достъпна",
"login_form_failed_login": "Грешка при вписване, проверете URL, имейла и паролата",
"login_form_handshake_exception": "Грешка при договаряне на връзката със сървъра. Ако използвате самоподписан сертификат, разрешете в настройкте използване на самоподписан сертификат.",
"login_form_failed_login": "Грешка при вписване, провери URL, имейла и паролата",
"login_form_handshake_exception": "Грешка при договаряне на връзката със сървъра. Ако използваш самоподписан сертификат, разреши в настройкте използване на самоподписан сертификат.",
"login_form_password_hint": "парола",
"login_form_save_login": "Остани вписан",
"login_form_server_empty": "Въведи URL на сървъра.",
@@ -1222,12 +1192,12 @@
"map_cannot_get_user_location": "Не можах да получа местоположението",
"map_location_dialog_yes": "Да",
"map_location_picker_page_use_location": "Използвай това местоположение",
"map_location_service_disabled_content": "За да се показват обектите от текущото място, трябва да бъде включена услугата за местоположение. Искате ли да я включите сега?",
"map_location_service_disabled_content": "За да се показват обектите от текущото място, трябва да бъде включена услугата за местоположение. Искаш ли да я включиш сега?",
"map_location_service_disabled_title": "Услугата за местоположение е изключена",
"map_marker_for_images": "Маркери на картата за снимки направени в {city}, {country}",
"map_marker_with_image": "Маркер на картата с изображение",
"map_no_assets_in_bounds": "Няма снимки от този район",
"map_no_location_permission_content": "За да се показват обектите от текущото място, трябва разрешение за определяне на местоположението. Искате ли да предоставите разрешение сега?",
"map_no_location_permission_content": "За да се показват обектите от текущото място, трябва разрешение за определяне на местоположението. Искаш ли да предоставиш разрешение сега?",
"map_no_location_permission_title": "Отказан достъп до местоположение",
"map_settings": "Настройки на картата",
"map_settings_dark_mode": "Тъмен режим",
@@ -1270,11 +1240,8 @@
"more": "Още",
"move": "Премести",
"move_off_locked_folder": "Извади от заключената папка",
"move_to_lock_folder_action_prompt": "{count} са добавени в заключената папка",
"move_to_locked_folder": "Премести в заключена папка",
"move_to_locked_folder_confirmation": "Тези снимки и видеа ще бъдат изтрити от всички албуми и ще са достъпни само в заключената папка",
"moved_to_archive": "{count, plural, one {# обект е преместен} many {# обекта са преместени} other {# обекта са преместени}} в архива",
"moved_to_library": "{count, plural, one {# обект е преместен} many {# обекта са преместени} other {# обекта са преместени}} в библиотеката",
"moved_to_trash": "Преместено в кошчето",
"multiselect_grid_edit_date_time_err_read_only": "Не може да се редактира датата на обект само за четене, пропускане",
"multiselect_grid_edit_gps_err_read_only": "Не може да се редактира местоположението на обект само за четене, пропускане",
@@ -1290,14 +1257,14 @@
"new_password": "Нова парола",
"new_person": "Нов човек",
"new_pin_code": "Нов PIN код",
"new_pin_code_subtitle": "Това е първи достъп до заключена папка. Създайте PIN код за защитен достъп до тази страница",
"new_pin_code_subtitle": "Това е първи достъп до заключена папка. Създай PIN код за защитен достъп до тази страница",
"new_user_created": "Създаден нов потребител",
"new_version_available": "НАЛИЧНА НОВА ВЕРСИЯ",
"newest_first": "Най-новите първи",
"next": "Следващо",
"next_memory": "Следващ спомен",
"no": "Не",
"no_albums_message": "Създайте албум за организиране на снимки и видеоклипове",
"no_albums_message": "Създаване на албум за организиране на снимки и видеоклипове",
"no_albums_with_name_yet": "Изглежда, че все още нямате албуми с това име.",
"no_albums_yet": "Изглежда, че все още нямате албуми.",
"no_archived_assets_message": "Архивирайте снимки и видеоклипове, за да ги скриете от изгледа на Снимки",
@@ -1307,8 +1274,8 @@
"no_duplicates_found": "Не бяха открити дубликати.",
"no_exif_info_available": "Няма exif информация",
"no_explore_results_message": "Качете още снимки, за да разгледате колекцията си.",
"no_favorites_message": "Добавете в любими, за да намирате бързо най-добрите си снимки и видеоклипове",
"no_libraries_message": "Създайте външна библиотека за да разглеждате снимки и видеоклипове",
"no_favorites_message": "Добавяне на любими, за да намерите бързо най-добрите си снимки и видеоклипове",
"no_libraries_message": "Създаване на външна библиотека за разглеждане на снимки и видеоклипове",
"no_locked_photos_message": "Снимките и видеата в заключената папка са скрити и не се показват при разглеждане на библиотеката.",
"no_name": "Без име",
"no_notifications": "Няма известия",
@@ -1336,7 +1303,7 @@
"oldest_first": "Най-старите първи",
"on_this_device": "На това устройство",
"onboarding": "Въвеждане",
"onboarding_locale_description": "Изберете предпочитан език. По-късно може да го промените в Настройки.",
"onboarding_locale_description": "Избери предпочитан език. По-късно може да го промениш в Настройки.",
"onboarding_privacy_description": "Следните (незадължителни) функции разчитат на външни услуги и могат да бъдат деактивирани по всяко време в настройките.",
"onboarding_server_welcome_description": "Да направим общите настройки на вашата инсталация.",
"onboarding_theme_description": "Изберете цветова тема. Може да я промените по-късно в настройките.",
@@ -1368,7 +1335,7 @@
"partner_page_partner_add_failed": "Неуспешно добавяне на партньор",
"partner_page_select_partner": "Избери партньор",
"partner_page_shared_to_title": "Споделено с",
"partner_page_stop_sharing_content": "{partner} вече няма да има достъп до вашите снимки.",
"partner_page_stop_sharing_content": "{partner} вече няма да има достъп до твоите снимки.",
"partner_sharing": "Споделяне с партньори",
"partners": "Партньори",
"password": "Парола",
@@ -1405,7 +1372,7 @@
"permission_onboarding_go_to_settings": "Отиди в настройки",
"permission_onboarding_permission_denied": "Отказан достъп. За да ползваш Immich, разреши в настройките достъп до снимки и видео.",
"permission_onboarding_permission_granted": "Предоставено е разрешение! Всичко е готово.",
"permission_onboarding_permission_limited": "Ограничен достъп. За да може Immich да архивира и управлява галерията, предоставете достъп до снимки и видео в настройките.",
"permission_onboarding_permission_limited": "Ограничен достъп. За да може Immich да архивира и управлява галерията, предостави достъп до снимки и видео в настройките.",
"permission_onboarding_request": "Immich се нуждае от разрешение за преглед на снимки и видео.",
"person": "Човек",
"person_birthdate": "Дата на раждане {date}",
@@ -1416,10 +1383,10 @@
"photos_count": "{count, plural, one {{count, number} Снимка} other {{count, number} Снимки}}",
"photos_from_previous_years": "Снимки от предходни години",
"pick_a_location": "Избери локация",
"pin_code_changed_successfully": "Успешно сменен PIN код",
"pin_code_reset_successfully": "Успешно нулиран PIN код",
"pin_code_setup_successfully": "Успешно зададен PIN код",
"pin_verification": "Проверка на PIN кода",
"pin_code_changed_successfully": "Успешно сменен PIN-код",
"pin_code_reset_successfully": "Успешно нулиран PIN-код",
"pin_code_setup_successfully": "Успешно зададен PIN-код",
"pin_verification": "Проверка на PIN-кода",
"place": "Местоположение",
"places": "Местоположения",
"places_count": "{count, plural, one {{count, number} Място} other {{count, number} Места}}",
@@ -1473,7 +1440,7 @@
"purchase_lifetime_description": "Покупка за цял живот",
"purchase_option_title": "ОПЦИИ ЗА ЗАКУПУВАНЕ",
"purchase_panel_info_1": "Създаването на Immich отнема много време и усилия, и имаме инженери на пълно работно време, които работят по него, за да го направим възможно най-добро. Нашата мисия е софтуерът с отворен код и етичните бизнес практики да се превърнат в устойчив източник на доходи за разработчиците и да създадем екосистема, която уважава личната неприкосновеност и предлага истински алтернативи на експлоататорските облачни услуги.",
"purchase_panel_info_2": "Тъй като сме обещали да не добавяме платени функции, тази покупка няма да ви предостави допълнителни функции в Immich. Ние разчитаме на потребители като вас, за да подкрепите продължаване на развитието на Immich.",
"purchase_panel_info_2": "Тъй като сме ангажирани да не добавяме платени стени, тази покупка няма да ви предостави допълнителни функции в Immich. Ние разчитаме на потребители като вас, за да подкрепяте продължаващото развитие на Immich.",
"purchase_panel_title": "Поддържайте проекта",
"purchase_per_server": "на сървър",
"purchase_per_user": "на потребител",
@@ -1520,11 +1487,9 @@
"remove_custom_date_range": "Премахни зададения диапазон от дати",
"remove_deleted_assets": "Премахни Изтритите Елементи",
"remove_from_album": "Премахни от албума",
"remove_from_album_action_prompt": "{count} са премахнати от албума",
"remove_from_favorites": "Премахни от Любими",
"remove_from_lock_folder_action_prompt": "{count} са премахнати от заключената папка",
"remove_from_locked_folder": "Махни от заключената папка",
"remove_from_locked_folder_confirmation": "Сигурни ли си, че искате тези снимки и видеа да бъдат извадени от заключената папка? Те ще бъдат видими в библиотеката.",
"remove_from_locked_folder_confirmation": "Сигурен ли си, че искаш тези снимки и видеа да бъдат извадени от заключената папка? Те ще бъдат видими в библиотеката.",
"remove_from_shared_link": "Премахни от споделения линк",
"remove_memory": "Премахни спомените",
"remove_photo_from_memory": "Премахни снимките от спомените",
@@ -1549,7 +1514,7 @@
"reset": "Нулиране",
"reset_password": "Нулиране на паролата",
"reset_people_visibility": "Нулиране на видимостта на хората",
"reset_pin_code": "Нулирай PIN кода",
"reset_pin_code": "Нулирай PIN-кода",
"reset_to_default": "Връщане на фабрични настройки",
"resolve_duplicates": "Реши дубликатите",
"resolved_all_duplicates": "Всички дубликати са решени",
@@ -1614,8 +1579,8 @@
"search_page_selfies": "Селфита",
"search_page_things": "Предмети",
"search_page_view_all_button": "Виж всички",
"search_page_your_activity": "Вашите действия",
"search_page_your_map": "Вашата карта",
"search_page_your_activity": "Твоите действия",
"search_page_your_map": "Твоята карта",
"search_people": "Търсете на хора",
"search_places": "Търсене на места",
"search_rating": "Търси по рейтинг…",
@@ -1623,7 +1588,7 @@
"search_settings": "Търсене на настройки",
"search_state": "Търсене на щат...",
"search_suggestion_list_smart_search_hint_1": "Умното търсене е включено по подразбиране, за търсене в метаданните използвай специален синтакс ",
"search_suggestion_list_smart_search_hint_2": "m:вашия-термин-за-търсене",
"search_suggestion_list_smart_search_hint_2": "m:твоя-термин-за-търсене",
"search_tags": "Търсене на етикети...",
"search_timezone": "Търсене на часова зона...",
"search_type": "Тип на търсене",
@@ -1635,7 +1600,6 @@
"select_album_cover": "Изберете обложка на албум",
"select_all": "Изберете всички",
"select_all_duplicates": "Избери всички дубликати",
"select_all_in": "Избери всички от групата {group}",
"select_avatar_color": "Изберете цвят на аватара",
"select_face": "Изберете лице",
"select_featured_photo": "Избери представителна снимка",
@@ -1692,16 +1656,15 @@
"settings": "Настройки",
"settings_require_restart": "Моля, за да се приложи настройката рестартирай Immich",
"settings_saved": "Настройките са запазени",
"setup_pin_code": "Задай PIN код",
"setup_pin_code": "Задай PIN-код",
"share": "Споделяне",
"share_action_prompt": "{count} споделени обекта",
"share_add_photos": "Добави снимки",
"share_assets_selected": "{count} избрани",
"share_dialog_preparing": "Подготовка...",
"share_link": "Връзка за споделяне",
"shared": "Споделено",
"shared_album_activities_input_disable": "Коментарите са изключени",
"shared_album_activity_remove_content": "Искате ли да изтриете тази активност?",
"shared_album_activity_remove_content": "Искаш ли да изтриеш тази активност?",
"shared_album_activity_remove_title": "Изтрий",
"shared_album_section_people_action_error": "Грешка при напускане/премахване от албума",
"shared_album_section_people_action_leave": "Премахване на потребител от албума",
@@ -1709,7 +1672,7 @@
"shared_album_section_people_title": "ХОРА",
"shared_by": "Споделено от",
"shared_by_user": "Споделено от {user}",
"shared_by_you": "Споделено от вас",
"shared_by_you": "Споделено от теб",
"shared_from_partner": "Снимки от {partner}",
"shared_intent_upload_button_progress_text": "{current} / {total} Заредено",
"shared_link_app_bar_title": "Споделени връзки",
@@ -1749,7 +1712,7 @@
"sharing": "Споделени",
"sharing_enter_password": "Моля, въведете паролата, за да видите тази страница.",
"sharing_page_album": "Споделени албуми",
"sharing_page_description": "Създайте споделени албуми за да споделите снимки и видеа с хора от вашата мрежа.",
"sharing_page_description": "Създай споделени албуми за да споделиш снимки и видеа с хора от твоята мрежа.",
"sharing_page_empty_list": "ПРАЗЕН СПИСЪК",
"sharing_sidebar_description": "Покажи връзка към Споделяне в страничната лента",
"sharing_silver_appbar_create_shared_album": "Нов споделен албум",
@@ -1796,7 +1759,6 @@
"sort_title": "Заглавие",
"source": "Код",
"stack": "Събери",
"stack_action_prompt": "{count} са групирани",
"stack_duplicates": "Подреждане на дубликати",
"stack_select_one_photo": "Избери една главна снимка за събраните снимки",
"stack_selected_photos": "Подреждане на избрани снимки",
@@ -1825,7 +1787,6 @@
"sync": "Синхронизиране",
"sync_albums": "Синхронизиране на албуми",
"sync_albums_manual_subtitle": "Синхронизирай всички заредени видеа и снимки в избраните архивни албуми",
"sync_upload_album_setting_subtitle": "Създавайте и зареждайте снимки и видеа в избрани албуми в Immich",
"tag": "Таг",
"tag_assets": "Тагни елементи",
"tag_created": "Създаден етикет: {tag}",
@@ -1839,19 +1800,6 @@
"theme": "Тема",
"theme_selection": "Избор на тема",
"theme_selection_description": "Автоматично задаване на светла или тъмна тема въз основа на системните предпочитания на вашия браузър",
"theme_setting_asset_list_storage_indicator_title": "Показвай индикатор за хранилището в заглавията на обектите",
"theme_setting_asset_list_tiles_per_row_title": "Брой обекти на ред ({count})",
"theme_setting_colorful_interface_subtitle": "Нанеси основен цвят върху фоновите повърхности.",
"theme_setting_colorful_interface_title": "Цветове на интерфейса",
"theme_setting_image_viewer_quality_subtitle": "Регулиране на качеството на програмата за преглед на детайлни изображения",
"theme_setting_image_viewer_quality_title": "Качество на прегледа на изображения",
"theme_setting_primary_color_subtitle": "Избери цвят за основните действия и акценти.",
"theme_setting_primary_color_title": "Основен цвят",
"theme_setting_system_primary_color_title": "Използвай от системата",
"theme_setting_system_theme_switch": "Автоматично (Според системната настройка)",
"theme_setting_theme_subtitle": "Задай настройки на цветовата тема на приложението",
"theme_setting_three_stage_loading_subtitle": "Три-степенното зареждане може да увеличи производителността, но ще увеличи значително и мрежовия трафик",
"theme_setting_three_stage_loading_title": "Включи три-степенно зареждане",
"they_will_be_merged_together": "Те ще бъдат обединени",
"third_party_resources": "Ресурси от трети страни",
"time_based_memories": "Спомени, базирани на времето",
@@ -1867,29 +1815,15 @@
"total": "Общо",
"total_usage": "Общо използвано",
"trash": "Кошче",
"trash_action_prompt": "{count} са преместени в коша",
"trash_all": "Изхвърли всички",
"trash_count": "В Кошчето {count, number}",
"trash_delete_asset": "Вкарай в Кошчето/Изтрий елемент",
"trash_emptied": "Коша е изпразнен",
"trash_no_results_message": "Изтритите снимки и видеоклипове ще се показват тук.",
"trash_page_delete_all": "Изтрий всичко",
"trash_page_empty_trash_dialog_content": "Искате ли да изпразня коша? Тези обекти ще бъдат изтрити завинаги от Immich",
"trash_page_info": "Обектите в коша ще бъдат изтривани завинаги след {days} дни",
"trash_page_no_assets": "Коша е празен",
"trash_page_restore_all": "Възстановяване на всички",
"trash_page_select_assets_btn": "Избери обекти",
"trash_page_title": "В коша ({count})",
"trashed_items_will_be_permanently_deleted_after": "Изхвърлените в кошчето елементи ще бъдат изтрити за постоянно след {days, plural, one {# ден} other {# дни}}.",
"type": "Тип",
"unable_to_change_pin_code": "Невъзможна промяна на PIN кода",
"unable_to_setup_pin_code": "Неуспешно задаване на PIN кода",
"unarchive": "Разархивирай",
"unarchive_action_prompt": "{count} са премахнати от Архива",
"unarchived_count": "{count, plural, other {Неархивирани #}}",
"undo": "Отмени",
"unfavorite": "Премахване от любимите",
"unfavorite_action_prompt": "{count} са премахнати от Любими",
"unhide_person": "Покажи отново човека",
"unknown": "Неизвестно",
"unknown_country": "Непозната Държава",
@@ -1905,18 +1839,12 @@
"unsaved_change": "Незапазена промяна",
"unselect_all": "Деселектирайте всички",
"unselect_all_duplicates": "От маркирай всички дубликати",
"unselect_all_in": "Премахни избора на всички от групата {group}",
"unstack": "Разкачи",
"unstack_action_prompt": "{count} са разгрупирани",
"unstacked_assets_count": "Разкачени {count, plural, one {# елемент} other {# елементи}}",
"untagged": "Немаркирани",
"up_next": "Следващ",
"updated_at": "Обновено",
"updated_password": "Паролата е актуализирана",
"upload": "Качване",
"upload_concurrency": "Успоредни качвания",
"upload_dialog_info": "Искате ли да архивирате на сървъра избраните обекти?",
"upload_dialog_title": "Качи обект",
"upload_errors": "Качването е завъшено с {count, plural, one {# грешка} other {# грешки}}, обновете страницата за да видите новите елементи.",
"upload_progress": "Остават {remaining, number} - Обработени {processed, number}/{total, number}",
"upload_skipped_duplicates": "Прескочени {count, plural, one {# дублиран елемент} other {# дублирани елементи}}",
@@ -1924,22 +1852,13 @@
"upload_status_errors": "Грешки",
"upload_status_uploaded": "Качено",
"upload_success": "Качването е успешно, опреснете страницата, за да видите новите файлове.",
"upload_to_immich": "Казване в Immich ({count})",
"uploading": "Качваме",
"url": "URL",
"usage": "Потребление",
"use_biometric": "Използвай биометрия",
"use_current_connection": "използвай текущата връзка",
"use_custom_date_range": "Използвайте собствен диапазон от дати вместо това",
"user": "Потребител",
"user_has_been_deleted": "Този потребител е премахнат.",
"user_id": "Потребител ИД",
"user_liked": "{user} хареса {type, select, photo {тази снимка} video {това видео} asset {този елемент} other {}}",
"user_pin_code_settings": "PIN код",
"user_pin_code_settings_description": "Управлявайте настройките на PIN кода",
"user_privacy": "Поверителност на потребителите",
"user_purchase_settings": "Покупка",
"user_purchase_settings_description": "Управлявайте покупката си",
"user_purchase_settings_description": "Управлявай покупката си",
"user_role_set": "Задай {user} като {role}",
"user_usage_detail": "Подробности за използването на потребителя",
"user_usage_stats": "Статистика за използването на акаунта",
@@ -1948,7 +1867,6 @@
"users": "Потребители",
"utilities": "Инструменти",
"validate": "Валидиране",
"validate_endpoint_error": "Моля, въведи правилен URL",
"variables": "Променливи",
"version": "Версия",
"version_announcement_closing": "Твой приятел, Алекс",
@@ -1970,24 +1888,16 @@
"view_name": "Прегледай",
"view_next_asset": "Преглед на следващия файл",
"view_previous_asset": "Преглед на предишния файл",
"view_qr_code": "Виж QR кода",
"view_stack": "Покажи в стек",
"view_user": "Виж потребителя",
"viewer_remove_from_stack": "Премахване от опашката",
"viewer_stack_use_as_main_asset": "Използвай като основен",
"viewer_unstack": "Премахни от опашката",
"visibility_changed": "Видимостта е променена за {count, plural, one {# човек} other {# човека}}",
"waiting": "в изчакване",
"warning": "Внимание",
"week": "Седмица",
"welcome": "Добре дошли",
"welcome_to_immich": "Добре дошли в Immich",
"wifi_name": "Wi-Fi мрежа",
"wrong_pin_code": "Грешен PIN код",
"year": "Година",
"years_ago": "преди {years, plural, one {# година} other {# години}}",
"yes": "Да",
"you_dont_have_any_shared_links": "Нямате споделени връзки",
"your_wifi_name": "Вашата Wi-Fi мрежа",
"zoom_image": "Увеличаване на изображението"
}

View File

@@ -8,92 +8,10 @@
"actions": "কর্ম",
"active": "সচল",
"activity": "কার্যকলাপ",
"activity_changed": "একটিভিটি এখন {enabled, select, true {চালু} other {বন্ধ}} আছে",
"add": "যোগ করুন",
"add_a_description": "একটি বিবরণ যোগ করুন",
"add_a_location": "একটি অবস্থান যোগ করুন",
"add_a_name": "একটি নাম যোগ করুন",
"add_a_title": "একটি শিরোনাম যোগ করুন",
"add_endpoint": "এন্ডপয়েন্ট যোগ করুন",
"add_exclusion_pattern": "বহির্ভূতকরণ নমুনা",
"add_import_path": "ইমপোর্ট করার পাথ যুক্ত করুন",
"add_location": "অবস্থান যুক্ত করুন",
"add_more_users": "আরো ব্যবহারকারী যুক্ত করুন",
"add_partner": "অংশীদার যোগ করুন",
"add_path": "পাথ যুক্ত করুন",
"add_photos": "ছবি যুক্ত করুন",
"add_tag": "ট্যাগ যুক্ত করুন",
"add_to": "যুক্ত করুন…",
"add_to_album": "এলবাম এ যোগ করুন",
"add_to_album_bottom_sheet_added": "{album} এ যোগ করা হয়েছে",
"add_to_album_bottom_sheet_already_exists": "{album} এ আগে থেকেই আছে",
"add_to_shared_album": "শেয়ার করা অ্যালবামে যোগ করুন",
"add_url": "লিঙ্ক যোগ করুন",
"added_to_archive": "আর্কাইভ এ যোগ করা হয়েছে",
"added_to_favorites": "ফেভারিটে যোগ করা হয়েছে",
"added_to_favorites_count": "পছন্দের তালিকায় {count, number} যোগ করা হয়েছে",
"admin": {
"add_exclusion_pattern_description": "এক্সক্লুশন প্যাটার্ন যোগ করুন। *, **, এবং ? ব্যবহার করে গ্লোবিং করা সম্ভব। \"Raw\" নামের যেকোনো ডিরেক্টরিতে থাকা সমস্ত ফাইল বাদ দিতে \"**/Raw/**\" ব্যবহার করুন। \".tif\" দিয়ে শেষ হওয়া সমস্ত ফাইল বাদ দিতে \"**/*.tif\" ব্যবহার করুন। একটি সম্পূর্ণ পাথ বাদ দিতে, \"/path/to/ignore/**\" ব্যবহার করুন।",
"admin_user": "এডমিন ইউজার",
"asset_offline_description": "এই বহিরাগত লাইব্রেরি সম্পদটি আর ডিস্কে পাওয়া যাচ্ছে না এবং ট্র্যাশে সরানো হয়েছে। যদি ফাইলটি লাইব্রেরির মধ্যে সরানো হয়ে থাকে, তাহলে নতুন সংশ্লিষ্ট সম্পদের জন্য আপনার টাইমলাইন পরীক্ষা করুন। এই সম্পদটি পুনরুদ্ধার করতে, দয়া করে নিশ্চিত করুন যে নীচের ফাইল পাথটি Immich দ্বারা অ্যাক্সেস করা যেতে পারে এবং লাইব্রেরিটি স্ক্যান করুন।",
"authentication_settings": "প্রমাণীকরণ সেটিংস",
"authentication_settings_description": "পাসওয়ার্ড, OAuth এবং অন্যান্য প্রমাণীকরণ সেটিংস পরিচালনা করুন",
"authentication_settings_disable_all": "আপনি কি নিশ্চিত যে আপনি সমস্ত লগইন পদ্ধতি অক্ষম করতে চান? লগইন সম্পূর্ণরূপে অক্ষম করা হবে।",
"authentication_settings_reenable": "পুনরায় সক্ষম করতে, একটি <link>সার্ভার কমান্ড</link> ব্যবহার করুন।",
"background_task_job": "ব্যাকগ্রাউন্ড টাস্ক",
"backup_database": "ডাটাবেস ডাম্প তৈরি করুন",
"backup_database_enable_description": "ডাটাবেস ডাম্প সক্রিয় করুন",
"backup_keep_last_amount": "আগের ডাম্পের পরিমাণ রাখা হবে",
"backup_settings": "ডাটাবেস ডাম্প সেটিংস",
"backup_settings_description": "ডাটাবেস ডাম্প সেটিংস পরিচালনা করুন।",
"cleared_jobs": "{job} এর জন্য jobs খালি করা হয়েছে",
"config_set_by_file": "কনফিগ বর্তমানে একটি কনফিগ ফাইল দ্বারা সেট করা আছে",
"confirm_delete_library": "আপনি কি নিশ্চিত যে আপনি {library} লাইব্রেরি মুছে ফেলতে চান?",
"confirm_delete_library_assets": "আপনি কি নিশ্চিত যে আপনি এই লাইব্রেরিটি মুছে ফেলতে চান? এটি Immich থেকে {count, plural, one {# contained asset} other {all # contained asset}} মুছে ফেলবে এবং পূর্বাবস্থায় ফেরানো যাবে না। ফাইলগুলি ডিস্কে থাকবে।",
"confirm_email_below": "নিশ্চিত করতে, নিচে \"{email}\" টাইপ করুন",
"confirm_reprocess_all_faces": "আপনি কি নিশ্চিত যে আপনি সমস্ত মুখ পুনরায় প্রক্রিয়া করতে চান? এটি নামযুক্ত ব্যক্তিদেরও মুছে ফেলবে।",
"confirm_user_password_reset": "আপনি কি নিশ্চিত যে আপনি {user} এর পাসওয়ার্ড রিসেট করতে চান?",
"confirm_user_pin_code_reset": "আপনি কি নিশ্চিত যে আপনি {user} এর পিন কোড রিসেট করতে চান?",
"create_job": "job তৈরি করুন",
"cron_expression": "ক্রোন এক্সপ্রেশন",
"cron_expression_description": "ক্রোন ফর্ম্যাট ব্যবহার করে স্ক্যানিং ব্যবধান সেট করুন। আরও তথ্যের জন্য দয়া করে দেখুন যেমন <link>Crontab Guru</link>",
"cron_expression_presets": "ক্রোন এক্সপ্রেশন প্রিসেট",
"disable_login": "লগইন অক্ষম করুন",
"duplicate_detection_job_description": "অনুরূপ ছবি সনাক্ত করতে সম্পদগুলিতে মেশিন লার্নিং চালান। স্মার্ট অনুসন্ধানের উপর নির্ভর করে",
"exclusion_pattern_description": "এক্সক্লুশন প্যাটার্ন ব্যবহার করে আপনি আপনার লাইব্রেরি স্ক্যান করার সময় ফাইল এবং ফোল্ডারগুলিকে উপেক্ষা করতে পারবেন। যদি আপনার এমন ফোল্ডার থাকে যেখানে এমন ফাইল থাকে যা আপনি আমদানি করতে চান না, যেমন RAW ফাইল।",
"external_library_management": "বহিরাগত গ্রন্থাগার ব্যবস্থাপনা",
"face_detection": "মুখ সনাক্তকরণ",
"face_detection_description": "মেশিন লার্নিং ব্যবহার করে অ্যাসেটে থাকা মুখগুলি সনাক্ত করুন। ভিডিওগুলির জন্য, শুধুমাত্র থাম্বনেইল বিবেচনা করা হয়। \"রিফ্রেশ\" (পুনরায়) সমস্ত অ্যাসেট প্রক্রিয়া করে। \"রিসেট\" অতিরিক্তভাবে সমস্ত বর্তমান মুখের ডেটা সাফ করে। \"অনুপস্থিত\" অ্যাসেটগুলিকে সারিবদ্ধ করে যা এখনও প্রক্রিয়া করা হয়নি। সনাক্ত করা মুখগুলিকে ফেসিয়াল রিকগনিশনের জন্য সারিবদ্ধ করা হবে, ফেসিয়াল ডিটেকশন সম্পূর্ণ হওয়ার পরে, বিদ্যমান বা নতুন ব্যক্তিদের মধ্যে গোষ্ঠীবদ্ধ করে।",
"facial_recognition_job_description": "শনাক্ত করা মুখগুলিকে মানুষের মধ্যে গোষ্ঠীভুক্ত করুন। মুখ সনাক্তকরণ সম্পূর্ণ হওয়ার পরে এই ধাপটি চলে। \"রিসেট\" (পুনরায়) সমস্ত মুখকে ক্লাস্টার করে। \"অনুপস্থিত\" মুখগুলিকে সারিতে রাখে যেখানে কোনও ব্যক্তিকে বরাদ্দ করা হয়নি।",
"failed_job_command": "কমান্ড {command} কাজের জন্য ব্যর্থ হয়েছে: {job}",
"force_delete_user_warning": "সতর্কতা: এটি ব্যবহারকারী এবং সমস্ত সম্পদ অবিলম্বে সরিয়ে ফেলবে। এটি পূর্বাবস্থায় ফেরানো যাবে না এবং ফাইলগুলি পুনরুদ্ধার করা যাবে না।",
"image_format": "ফরম্যাট",
"image_format_description": "WebP JPEG এর তুলনায় ছোট ফাইল তৈরি করে, কিন্তু এনকোড করতে ধীর।",
"image_fullsize_description": "জুম ইন করার সময় ব্যবহৃত স্ট্রিপড মেটাডেটা সহ পূর্ণ আকারের ছবি",
"image_fullsize_enabled": "পূর্ণ-আকারের ছবি তৈরি সক্ষম করুন",
"image_fullsize_enabled_description": "ওয়েব-বান্ধব নয় এমন ফর্ম্যাটের জন্য পূর্ণ-আকারের ছবি তৈরি করুন। \"এমবেডেড প্রিভিউ পছন্দ করুন\" সক্ষম করা থাকলে, রূপান্তর ছাড়াই এমবেডেড প্রিভিউ সরাসরি ব্যবহার করা হয়। JPEG-এর মতো ওয়েব-বান্ধব ফর্ম্যাটগুলিকে প্রভাবিত করে না।",
"image_fullsize_quality_description": "পূর্ণ-আকারের ছবির মান ১-১০০। উচ্চতর হলে ভালো, কিন্তু আরও বড় ফাইল তৈরি হয়।",
"image_fullsize_title": "পূর্ণ-আকারের চিত্র সেটিংস",
"image_prefer_embedded_preview": "এম্বেড করা প্রিভিউ পছন্দ করুন",
"image_prefer_embedded_preview_setting_description": "ছবি প্রক্রিয়াকরণের জন্য এবং যখনই উপলব্ধ থাকবে তখন RAW ফটোতে এমবেডেড প্রিভিউ ব্যবহার করুন। এটি কিছু ছবির জন্য আরও সঠিক রঙ তৈরি করতে পারে, তবে প্রিভিউয়ের মান ক্যামেরা-নির্ভর এবং ছবিতে আরও কম্প্রেশন আর্টিফ্যাক্ট থাকতে পারে।",
"image_prefer_wide_gamut": "প্রশস্ত পরিসর পছন্দ করুন",
"image_prefer_wide_gamut_setting_description": "থাম্বনেইলের জন্য ডিসপ্লে P3 ব্যবহার করুন। এটি প্রশস্ত রঙের স্থান সহ ছবির প্রাণবন্ততা আরও ভালভাবে সংরক্ষণ করে, তবে পুরানো ব্রাউজার সংস্করণ সহ পুরানো ডিভাইসগুলিতে ছবিগুলি ভিন্নভাবে প্রদর্শিত হতে পারে। রঙের পরিবর্তন এড়াতে sRGB ছবিগুলিকে sRGB হিসাবে রাখা হয়।",
"image_preview_description": "স্ট্রিপড মেটাডেটা সহ মাঝারি আকারের ছবি, একটি একক সম্পদ দেখার সময় এবং মেশিন লার্নিংয়ের জন্য ব্যবহৃত হয়",
"image_preview_quality_description": "১-১০০ এর মধ্যে প্রিভিউ কোয়ালিটি। বেশি হলে ভালো, কিন্তু বড় ফাইল তৈরি হয় এবং অ্যাপের প্রতিক্রিয়াশীলতা কমাতে পারে। কম মান সেট করলে মেশিন লার্নিং কোয়ালিটির উপর প্রভাব পড়তে পারে।",
"image_preview_title": "প্রিভিউ সেটিংস",
"image_quality": "গুণমান",
"image_resolution": "রেজোলিউশন",
"image_resolution_description": "উচ্চ রেজোলিউশনের ক্ষেত্রে আরও বিস্তারিত তথ্য সংরক্ষণ করা সম্ভব কিন্তু এনকোড করতে বেশি সময় লাগে, ফাইলের আকার বড় হয় এবং অ্যাপের প্রতিক্রিয়াশীলতা কমাতে পারে।",
"image_settings": "চিত্র সেটিংস",
"image_settings_description": "তৈরি করা ছবির মান এবং রেজোলিউশন পরিচালনা করুন",
"image_thumbnail_description": "মেটাডেটা বাদ দেওয়া ছোট থাম্বনেইল, মূল টাইমলাইনের মতো ছবির গ্রুপ দেখার সময় ব্যবহৃত হয়",
"image_thumbnail_quality_description": "থাম্বনেইলের মান ১-১০০। বেশি হলে ভালো, কিন্তু বড় ফাইল তৈরি হয় এবং অ্যাপের প্রতিক্রিয়াশীলতা কমাতে পারে।",
"image_thumbnail_title": "থাম্বনেল সেটিংস",
"job_concurrency": "{job} কনকারেন্সি",
"job_created": "Job তৈরি হয়েছে",
"job_not_concurrency_safe": "এই কাজটি সমকালীন-নিরাপদ নয়।",
"job_settings": "কাজের সেটিংস",
"job_settings_description": "কাজের সমান্তরালতা পরিচালনা করুন",
"job_status": "চাকরির অবস্থা"
}
"add_endpoint": "এন্ডপয়েন্ট যোগ করুন"
}

View File

@@ -34,7 +34,6 @@
"added_to_favorites_count": "{count, number} afegits als preferits",
"admin": {
"add_exclusion_pattern_description": "Afegeix patrons d'exclusió. Es permet englobar fent ús de *, **, i ?. Per a ignorar els fitxers de qualsevol directori anomenat \"Raw\" introduïu \"**/Raw/**\". Per a ignorar els fitxers acabats en \".tif\" introduïu \"**/*.tif\". Per a ignorar una ruta absoluta, utilitzeu \"/ruta/a/ignorar/**\".",
"admin_user": "Administrador",
"asset_offline_description": "Aquest recurs de la biblioteca externa ja no es troba al disc i s'ha mogut a la paperera. Si el fitxer s'ha mogut dins de la biblioteca, comproveu la vostra línia de temps per trobar el nou recurs corresponent. Per restaurar aquest recurs, assegureu-vos que Immich pugui accedir a la ruta del fitxer següent i escanegeu la biblioteca.",
"authentication_settings": "Configuració de l'autenticació",
"authentication_settings_description": "Gestiona la contrasenya, OAuth i altres configuracions de l'autenticació",
@@ -59,7 +58,7 @@
"cron_expression_description": "Estableix l'interval d'escaneig amb el format cron. Per obtenir més informació, consulteu, p.e <link>Crontab Guru</link>",
"cron_expression_presets": "Ajustos predefinits d'expressions Cron",
"disable_login": "Deshabiliteu l'inici de sessió",
"duplicate_detection_job_description": "Executa l'aprenentatge automàtic en els elements per a detectar imatges semblants. Fa servir la cerca intel·ligent",
"duplicate_detection_job_description": "Executa l'aprenentatge automàtic en els elements per a detectar imatges semblants. Fa servir l'Smart Search",
"exclusion_pattern_description": "Els patrons d'exclusió permeten ignorar fitxers i carpetes quan escanegeu una llibreria. Això és útil si teniu carpetes que contenen fitxer que no voleu importar, com els fitxers RAW.",
"external_library_management": "Gestió de llibreries externes",
"face_detection": "Detecció de cares",
@@ -89,7 +88,7 @@
"image_thumbnail_description": "Miniatura petita amb metadades eliminades, que s'utilitza quan es visualitzen grups de fotos com la línia de temps principal",
"image_thumbnail_quality_description": "Qualitat de miniatura d'1 a 100. Més alt és millor, però produeix fitxers més grans i pot reduir la capacitat de resposta de l'aplicació.",
"image_thumbnail_title": "Configuració de miniatures",
"job_concurrency": "{job} simultàniament",
"job_concurrency": "{job} concurrència",
"job_created": "Tasca creada",
"job_not_concurrency_safe": "Aquesta tasca no és segura per a la conconcurrència.",
"job_settings": "Configuració de les tasques",
@@ -105,7 +104,7 @@
"library_scanning_enable_description": "Habilita l'escaneig periòdic de biblioteques",
"library_settings": "Llibreria externes",
"library_settings_description": "Gestiona la configuració de les llibreries externes",
"library_tasks_description": "Escaneja les biblioteques externes per trobar arxius nous o canviats",
"library_tasks_description": "Escaneja biblioteques externes per arxius nous o canviats",
"library_watching_enable_description": "Consultar llibreries externes per detectar canvis en fitxers",
"library_watching_settings": "Monitoratge de la llibreria (EXPERIMENTAL)",
"library_watching_settings_description": "Monitorització automàtica de fitxers modificats",
@@ -113,19 +112,19 @@
"logging_level_description": "Quan està habilitat, quin nivell de registre es vol emprar.",
"logging_settings": "Registre",
"machine_learning_clip_model": "Model CLIP",
"machine_learning_clip_model_description": "El nom d'un model CLIP que apareix a <link>aquí</link>. Tingues en compte que has de tornar a executar la cerca intel·ligent per a totes les imatges quan es canvia de model.",
"machine_learning_clip_model_description": "El nom d'un model CLIP que apareix a <link>aquí</link>. Tingues en compte que has de tornar a executar l'Smart Search' per a totes les imatges quan es canvia de model.",
"machine_learning_duplicate_detection": "Detecció de duplicats",
"machine_learning_duplicate_detection_enabled": "Activa la detecció de duplicats",
"machine_learning_duplicate_detection_enabled_description": "Si es desactivada, els elements idèntics encara es desduplicaran.",
"machine_learning_duplicate_detection_enabled": "Activa detecció de duplicats",
"machine_learning_duplicate_detection_enabled_description": "Si es deshabilitat, els elements exactament idèntics encara es desduplicaran.",
"machine_learning_duplicate_detection_setting_description": "Usa incrustacions CLIP per a trobar prossibles duplicats",
"machine_learning_enabled": "Activa l'aprenentatge automàtic",
"machine_learning_enabled_description": "Si està desactivat, totes les funcions ML es deshabilitaran sense tenir en compte la configuració següent.",
"machine_learning_enabled_description": "Si està deshabilitat, totes les funcions ML es deshabilitaran sense tenir en compte la configuració següent.",
"machine_learning_facial_recognition": "Reconeixement facial",
"machine_learning_facial_recognition_description": "Detecta, reconeix i agrupa cares a les imatges",
"machine_learning_facial_recognition_model": "Model de reconeixement facial",
"machine_learning_facial_recognition_model_description": "Els models es llisten en ordre descent segons la mida. Els models més grans són més lents i usen més memòria però produeixen millors resultats. Tingueu en compte que després de canviar un model haureu de tornar a executar la tasca de detecció de cares per a totes les imatges.",
"machine_learning_facial_recognition_setting": "Activa el reconeixement facial",
"machine_learning_facial_recognition_setting_description": "Si està desactivat, les imatges no es codificaran pel reconeixement facial i no s'afegiran a la secció Persones de la pàgina Explorar.",
"machine_learning_facial_recognition_setting": "Activa reconeixement facial",
"machine_learning_facial_recognition_setting_description": "Si està deshabilitat, les imatges no es codificaran pel reconeixement facial i no s'afegiran a la secció Persones de la pàgina Explorar.",
"machine_learning_max_detection_distance": "Distància màxima de detecció",
"machine_learning_max_detection_distance_description": "Diferència màxima entre dues imatges per a considerar-les duplicades, en un rang d'entre 0.001-0.1. Com més elevat el valor més detecció de duplicats, però pot resultar en falsos positius.",
"machine_learning_max_recognition_distance": "Màxima diferència de reconeixement",
@@ -136,17 +135,17 @@
"machine_learning_min_recognized_faces_description": "El nombre mínim de cares reconegudes per crear una persona. Augmentar aquest valor fa que el reconeixement facial sigui més precís, però augmenta la possibilitat que una cara no sigui assignada a una persona.",
"machine_learning_settings": "Configuració d'aprenentatge automàtic",
"machine_learning_settings_description": "Gestiona funcions i configuració d'aprenentatge automàtic",
"machine_learning_smart_search": "Cerca intel·ligent",
"machine_learning_smart_search": "Cerca Intel·ligent",
"machine_learning_smart_search_description": "Cerca imatges semànticament emprant incrustacions CLIP",
"machine_learning_smart_search_enabled": "Activa la cerca intel·ligent",
"machine_learning_smart_search_enabled_description": "Si està desactivada, les imatges no es codificaran per la cerca intel·ligent.",
"machine_learning_smart_search_enabled_description": "Si està deshabilitat les imatges no es codificaran per la cerca intel·ligent.",
"machine_learning_url_description": "L'URL del servidor d'aprenentatge automàtic. Si es proporciona més d'un URL, s'intentarà accedir a cada servidor en ordre fins que un d'ells respongui correctament.",
"manage_concurrency": "Gestiona la concurrència",
"manage_log_settings": "Gestiona la configuració del registre",
"map_dark_style": "Tema fosc",
"map_enable_description": "Habilita característiques del mapa",
"map_gps_settings": "Configuració del mapa i GPS",
"map_gps_settings_description": "Gestiona la configuració del mapa i GPS (Geocodificació inversa)",
"map_gps_settings": "Configuració de mapa i GPS",
"map_gps_settings_description": "Gestiona la configuració de mapa i GPS (Geocodificació inversa)",
"map_implications": "La funció mapa depèn del servei extern de tesel·les (tiles.immich.cloud)",
"map_light_style": "Tema clar",
"map_manage_reverse_geocoding_settings": "Gestiona els paràmetres de <link>geocodificació inversa</link>",
@@ -166,10 +165,6 @@
"metadata_settings_description": "Administrar la configuració de les metadades",
"migration_job": "Migració",
"migration_job_description": "Migra les miniatures d'elements i cares cap a la nova estructura de carpetes",
"nightly_tasks_cluster_new_faces_setting": "Agrupa cares noves",
"nightly_tasks_database_cleanup_setting": "Tasques de neteja de la base de dades",
"nightly_tasks_database_cleanup_setting_description": "Netegeu les dades antigues i caducades de la base de dades",
"nightly_tasks_missing_thumbnails_setting": "Generar les miniatures restants",
"no_paths_added": "No s'ha afegit cap ruta",
"no_pattern_added": "Cap patró aplicat",
"note_apply_storage_label_previous_assets": "Nota: Per aplicar l'etiquetatge d'emmagatzematge a elements pujats prèviament, executeu la",
@@ -200,8 +195,6 @@
"oauth_mobile_redirect_uri": "URI de redirecció mòbil",
"oauth_mobile_redirect_uri_override": "Sobreescriu l'URI de redirecció mòbil",
"oauth_mobile_redirect_uri_override_description": "Habilita quan el proveïdor d'OAuth no permet una URI mòbil, com ara ''{callback}''",
"oauth_role_claim": "Concessió de rol",
"oauth_role_claim_description": "Atorgar accés d'administrador automàticament segons la presència d'aquesta concessió. La concessió pot ser 'usuari' o 'admin'.",
"oauth_settings": "OAuth",
"oauth_settings_description": "Gestiona la configuració de l'inici de sessió OAuth",
"oauth_settings_more_details": "Per a més detalls sobre aquesta funcionalitat, consulteu la <link>documentació</link>.",
@@ -250,7 +243,7 @@
"storage_template_migration_info": "Les extensions es convertiran a minúscules. Els canvis de plantilla només s'aplicaran a nous elements. Per aplicar la plantilla rectroactivament a elements pujats prèviament, executeu la <link>{job}</link>.",
"storage_template_migration_job": "Tasca de migració de la plantilla d'emmagatzematge",
"storage_template_more_details": "Per obtenir més detalls sobre aquesta funció, consulteu la <template-link>Storage Template</template-link> i les seves <implications-link>implications</implications-link>",
"storage_template_onboarding_description_v2": "Un cop habilitada, aquesta funció organitzarà automàticament els fitxers a partir d'una plantilla definida per l'usuari. Per a més informació, podeu consultar la <link>documentació</link>.",
"storage_template_onboarding_description": "Quan està activada, aquesta funció organitzarà automàticament els fitxers en funció d'una plantilla definida per l'usuari. A causa de problemes d'estabilitat, la funció s'ha desactivat de manera predeterminada. Per obtenir més informació, consulteu la <link>documentation</link>.",
"storage_template_path_length": "Límit aproximat de longitud de la ruta: <b>{length, number}</b>/{limit, number}",
"storage_template_settings": "Plantilla d'emmagatzematge",
"storage_template_settings_description": "Gestiona l'estructura de les carpetes i el nom del fitxers dels elements pujats",
@@ -267,7 +260,7 @@
"template_settings": "Plantilles de notificació",
"template_settings_description": "Gestiona les plantilles personalitzades per les notificacions",
"theme_custom_css_settings": "CSS personalitzat",
"theme_custom_css_settings_description": "Els fulls d'estil en cascada permeten personalitzar el disseny d'Immich.",
"theme_custom_css_settings_description": "Els Fulls d'Estil en Cascada permeten personalitzar el disseny d'Immich.",
"theme_settings": "Configuració del tema",
"theme_settings_description": "Gestiona la personalització de la interfície web Immich",
"thumbnail_generation_job": "Generar miniatures",
@@ -361,12 +354,12 @@
},
"admin_email": "Correu de l'administrador",
"admin_password": "Contrasenya de l'administrador",
"administration": "Administració",
"administration": "Administrador",
"advanced": "Avançat",
"advanced_settings_enable_alternate_media_filter_subtitle": "Feu servir aquesta opció per filtrar els continguts multimèdia durant la sincronització segons criteris alternatius. Només proveu-ho si teniu problemes amb l'aplicació per detectar tots els àlbums.",
"advanced_settings_enable_alternate_media_filter_title": "Utilitza el filtre de sincronització d'àlbums de dispositius alternatius",
"advanced_settings_log_level_title": "Nivell de registre: {level}",
"advanced_settings_prefer_remote_subtitle": "Alguns dispositius són molt lents en carregar miniatures dels elements locals. Activeu aquest paràmetre per carregar imatges remotes en el seu lloc.",
"advanced_settings_prefer_remote_subtitle": "Alguns dispositius són molt lents en carregar miniatures dels elements del dispositiu. Activeu aquest paràmetre per carregar imatges remotes en el seu lloc.",
"advanced_settings_prefer_remote_title": "Prefereix imatges remotes",
"advanced_settings_proxy_headers_subtitle": "Definiu les capçaleres de proxy que Immich per enviar amb cada sol·licitud de xarxa",
"advanced_settings_proxy_headers_title": "Capçaleres de proxy",
@@ -433,7 +426,6 @@
"app_settings": "Configuració de l'app",
"appears_in": "Apareix a",
"archive": "Arxiu",
"archive_action_prompt": "{count} afegit a Arxiu",
"archive_or_unarchive_photo": "Arxivar o desarxivar fotografia",
"archive_page_no_archived_assets": "No s'ha trobat res arxivat",
"archive_page_title": "Arxiu({count})",
@@ -471,12 +463,11 @@
"assets": "Elements",
"assets_added_count": "{count, plural, one {Afegit un element} other {Afegits # elements}}",
"assets_added_to_album_count": "{count, plural, one {Afegit un element} other {Afegits # elements}} a l'àlbum",
"assets_added_to_name_count": "{count, plural, one {S'ha afegit # recurs} other {S'han afegit # recursos}} a {hasName, select, true {<b>{name}</b>} other {new album}}",
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} no es pot afegir a l'àlbum",
"assets_count": "{count, plural, one {# recurs} other {# recursos}}",
"assets_deleted_permanently": "{count} element(s) esborrats permanentment",
"assets_deleted_permanently_from_server": "{count} element(s) esborrats permanentment del servidor d'Immich",
"assets_downloaded_failed": "{count, plural, one {S'ha baixat un arxiu - {error} l'arxiu ha fallat} other {S'han baixat # arxius - {error} els arxius han fallat}}",
"assets_downloaded_successfully": "{count, plural, one {S'ha baixat un arxiu amb èxit} other {S'han baixat # arxius amb èxit}}",
"assets_moved_to_trash_count": "{count, plural, one {# recurs mogut} other {# recursos moguts}} a la paperera",
"assets_permanently_deleted_count": "{count, plural, one {# recurs esborrat} other {# recursos esborrats}} permanentment",
"assets_removed_count": "{count, plural, one {# element eliminat} other {# elements eliminats}}",
@@ -560,7 +551,7 @@
"backup_setting_subtitle": "Gestiona la configuració de càrrega en segon pla i en primer pla",
"backward": "Enrere",
"biometric_auth_enabled": "Autentificació biomètrica activada",
"biometric_locked_out": "Esteu bloquejats fora de l'autenticació biomètrica",
"biometric_locked_out": "Esteu bloquejat fora de l'autenticació biomètrica",
"biometric_no_options": "No hi ha opcions biomètriques disponibles",
"biometric_not_available": "L'autenticació biomètrica no està disponible en aquest dispositiu",
"birthdate_saved": "Data de naixement guardada amb èxit",
@@ -709,7 +700,7 @@
"daily_title_text_date": "E, dd MMM",
"daily_title_text_date_year": "E, dd MMM, yyyy",
"dark": "Fosc",
"dark_theme": "Canviar a tema fosc",
"darkTheme": "Activa/desactiva el tema fosc",
"date_after": "Data posterior a",
"date_and_time": "Data i hora",
"date_before": "Data anterior a",
@@ -725,7 +716,6 @@
"default_locale": "Localització predeterminada",
"default_locale_description": "Format de dates i números segons la configuració del navegador",
"delete": "Esborra",
"delete_action_prompt": "{count} eliminats permanentment",
"delete_album": "Esborra l'àlbum",
"delete_api_key_prompt": "Esteu segurs que voleu eliminar aquesta clau API?",
"delete_dialog_alert": "Aquests elements seran eliminats de manera permanent d'Immich i del vostre dispositiu",
@@ -806,7 +796,6 @@
"edit_key": "Edita clau",
"edit_link": "Edita enllaç",
"edit_location": "Edita ubicació",
"edit_location_action_prompt": "{count} ubicacions editades",
"edit_location_dialog_title": "Ubicació",
"edit_name": "Edita el nom",
"edit_people": "Edita la gent",
@@ -992,7 +981,6 @@
"failed_to_load_assets": "Error carregant recursos",
"failed_to_load_folder": "No s'ha pogut carregar la carpeta",
"favorite": "Preferit",
"favorite_action_prompt": "{count} afegit a Favorits",
"favorite_or_unfavorite_photo": "Foto preferida o no preferida",
"favorites": "Preferits",
"favorites_page_no_favorites": "No s'han trobat preferits",
@@ -1107,7 +1095,6 @@
"ios_debug_info_last_sync_at": "Darrera sincronització {dateTime}",
"ios_debug_info_no_processes_queued": "No hi ha processos en segon pla en cua",
"ios_debug_info_no_sync_yet": "Encara no s'ha executat cap tasca de sincronització en segon pla",
"ios_debug_info_processes_queued": "{count, plural, one {Un procés en segon pla a la cua} other {{count} processos en segon pla a la cua}}",
"ios_debug_info_processing_ran_at": "El processament s'ha executat {dateTime}",
"items_count": "{count, plural, one {# element} other {# elements}}",
"jobs": "Tasques",
@@ -1145,7 +1132,7 @@
"list": "Llista",
"loading": "Carregant",
"loading_search_results_failed": "No s'han pogut carregar els resultats de la cerca",
"local_asset_cast_failed": "No es pot convertir un actiu que no s'ha penjat al servidor",
"local_asset_cast_failed": "No es pot convertir un actiu que no s'ha penjat al servidor.",
"local_network": "Xarxa local",
"local_network_sheet_info": "L'aplicació es connectarà al servidor mitjançant aquest URL quan utilitzeu la xarxa Wi-Fi especificada",
"location_permission": "Permís d'ubicació",
@@ -1159,7 +1146,6 @@
"locked_folder": "Carpeta bloquejada",
"log_out": "Tanca la sessió",
"log_out_all_devices": "Tanqueu la sessió de tots els dispositius",
"logged_in_as": "Sessió iniciada com a {user}",
"logged_out_all_devices": "S'ha tancat la sessió de tots els dispositius",
"logged_out_device": "Dispositiu tancat",
"login": "Iniciar sessió",
@@ -1255,7 +1241,6 @@
"more": "Més",
"move": "Moure",
"move_off_locked_folder": "Moure fora de la carpeta bloquejada",
"move_to_lock_folder_action_prompt": "{count} afegides a la carpeta protegida",
"move_to_locked_folder": "Moure a la carpeta bloquejada",
"move_to_locked_folder_confirmation": "Aquestes fotos i vídeos seran eliminades de tots els àlbums, i només podran ser vistes des de la carpeta bloquejada",
"moved_to_archive": "S'han mogut {count, plural, one {# asset} other {# assets}} a l'arxiu",
@@ -1506,7 +1491,6 @@
"remove_deleted_assets": "Suprimeix fitxers fora de línia",
"remove_from_album": "Treu de l'àlbum",
"remove_from_favorites": "Eliminar dels preferits",
"remove_from_lock_folder_action_prompt": "{count} eliminades de la carpeta protegida",
"remove_from_locked_folder": "Elimina de la carpeta bloquejada",
"remove_from_locked_folder_confirmation": "Segur que vols moure aquestes fotos i vídeos fora de la carpeta bloquejada? Seran visibles a la teva biblioteca.",
"remove_from_shared_link": "Eliminar de l'enllaç compartit",
@@ -1619,7 +1603,6 @@
"select_album_cover": "Seleccionar la portada de l'àlbum",
"select_all": "Selecciona-ho tot",
"select_all_duplicates": "Seleccioneu tots els duplicats",
"select_all_in": "Selecciona tot en {group}",
"select_avatar_color": "Tria color de l'avatar",
"select_face": "Selecciona cara",
"select_featured_photo": "Selecciona foto principal",
@@ -1849,7 +1832,6 @@
"total": "Total",
"total_usage": "Ús total",
"trash": "Paperera",
"trash_action_prompt": "{count} mogudes a la brossa",
"trash_all": "Envia-ho tot a la paperera",
"trash_count": "Paperera {count, number}",
"trash_delete_asset": "Esborra/Elimina element",
@@ -1867,11 +1849,9 @@
"unable_to_change_pin_code": "No es pot canviar el codi PIN",
"unable_to_setup_pin_code": "No s'ha pogut configurar el codi PIN",
"unarchive": "Desarxivar",
"unarchive_action_prompt": "{count} eliminades de l'arxiu",
"unarchived_count": "{count, plural, other {# elements desarxivats}}",
"undo": "Desfer",
"unfavorite": "Reverteix preferit",
"unfavorite_action_prompt": "{count} eliminades de preferits",
"unhide_person": "Mostra persona",
"unknown": "Desconegut",
"unknown_country": "País Desconegut",
@@ -1887,7 +1867,6 @@
"unsaved_change": "Canvi no desat",
"unselect_all": "Deselecciona-ho tot",
"unselect_all_duplicates": "Desmarqueu tots els duplicats",
"unselect_all_in": "Desseleccionar tots els elements de {group}",
"unstack": "Desapila",
"unstacked_assets_count": "No apilat {count, plural, one {# recurs} other {# recursos}}",
"up_next": "Pròxim",

Some files were not shown because too many files have changed in this diff Show More