Compare commits

..

21 Commits

Author SHA1 Message Date
mertalev
930961825e queue assets without detected faces 2025-05-14 20:36:32 -04:00
mertalev
fdc8f91b18 revert image size change 2025-05-13 23:45:59 -04:00
mertalev
016a760dda use original image for ml 2025-05-13 23:41:57 -04:00
mertalev
c15507baad remove nesting 2025-05-13 13:20:41 -04:00
mertalev
1691706666 avoid always printing "vector reindexing complete" 2025-05-13 12:56:03 -04:00
mertalev
a96026c821 tighten range 2025-05-13 12:48:38 -04:00
Mert
5740928843 Update docs/docs/administration/postgres-standalone.md
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-05-13 12:26:56 -04:00
mertalev
6126ac77b5 update docker compose files 2025-05-12 20:57:09 -04:00
mertalev
8c166b9381 outdated message 2025-05-12 20:57:09 -04:00
mertalev
d656cc2198 redundant switch 2025-05-12 20:57:09 -04:00
mertalev
32f25580ec revert refreshfaces sql change 2025-05-12 20:57:09 -04:00
mertalev
34f72a8251 maybe fix sql generation 2025-05-12 20:57:09 -04:00
mertalev
e851884f88 handle different db name 2025-05-12 20:57:09 -04:00
mertalev
db2493d003 preexisiting pg docs 2025-05-12 20:57:09 -04:00
mertalev
595f4c6d2e simplify dummy 2025-05-12 20:57:09 -04:00
mertalev
36481d037f accurate dummy vector 2025-05-12 20:57:09 -04:00
mertalev
217f6fe4fa fix new instance 2025-05-12 20:57:09 -04:00
mertalev
e90f28985a cascade 2025-05-12 20:57:09 -04:00
mertalev
0c9890b70f update image for sql checker
update images for gha
2025-05-12 20:57:09 -04:00
mertalev
b750440f90 set probes 2025-05-12 20:57:09 -04:00
mertalev
c80b16d24e wip
auto-detect available extensions

auto-recovery, fix reindexing check

use original image for ml
2025-05-12 20:57:08 -04:00
1322 changed files with 37752 additions and 71470 deletions

2
.devcontainer/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
library

16
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22@sha256:a20b8a3538313487ac9266875bbf733e544c1aa2091df2bb99ab592a6d4f7399
FROM ${BASEIMAGE}
# Flutter SDK
# https://flutter.dev/docs/development/tools/sdk/releases?tab=linux
ENV FLUTTER_CHANNEL="stable"
ENV FLUTTER_VERSION="3.29.1"
ENV FLUTTER_HOME=/flutter
ENV PATH=${PATH}:${FLUTTER_HOME}/bin
# Flutter SDK
RUN mkdir -p ${FLUTTER_HOME} \
&& curl -C - --output flutter.tar.xz https://storage.googleapis.com/flutter_infra_release/releases/${FLUTTER_CHANNEL}/linux/flutter_linux_${FLUTTER_VERSION}-${FLUTTER_CHANNEL}.tar.xz \
&& tar -xf flutter.tar.xz --strip-components=1 -C ${FLUTTER_HOME} \
&& rm flutter.tar.xz \
&& chown -R 1000:1000 ${FLUTTER_HOME}

View File

@@ -1,67 +1,26 @@
{ {
"name": "Immich - Backend, Frontend and ML", "name": "Immich",
"service": "immich-server", "service": "immich-devcontainer",
"runServices": [
"immich-server",
"redis",
"database",
"immich-machine-learning"
],
"dockerComposeFile": [ "dockerComposeFile": [
"../docker/docker-compose.dev.yml", "docker-compose.yml",
"./server/container-compose-overrides.yml" "../docker/docker-compose.dev.yml"
], ],
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": [
"Dart-Code.dart-code",
"Dart-Code.flutter",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"dcmdev.dcm-vscode-extension",
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
"svelte.svelte-vscode", "svelte.svelte-vscode"
"ms-vscode-remote.remote-containers",
"foxundermoon.shell-format",
"timonwong.shellcheck",
"rvest.vs-code-prettier-eslint",
"bluebrown.yamlfmt",
"vkrishna04.cspell-sync",
"vitest.explorer",
"ms-playwright.playwright",
"ms-azuretools.vscode-docker"
] ]
} }
}, },
"forwardPorts": [3000, 9231, 9230, 2283], "forwardPorts": [],
"portsAttributes": { "initializeCommand": "bash .devcontainer/scripts/initializeCommand.sh",
"3000": { "onCreateCommand": "bash .devcontainer/scripts/onCreateCommand.sh",
"label": "Immich - Frontend HTTP",
"description": "The frontend of the Immich project",
"onAutoForward": "openBrowserOnce"
},
"2283": {
"label": "Immich - API Server - HTTP",
"description": "The API server of the Immich project"
},
"9231": {
"label": "Immich - API Server - DEBUG",
"description": "The API server of the Immich project"
},
"9230": {
"label": "Immich - Workers - DEBUG",
"description": "The workers of the Immich project"
}
},
"overrideCommand": true, "overrideCommand": true,
"workspaceFolder": "/workspaces/immich", "workspaceFolder": "/immich",
"remoteUser": "node", "remoteUser": "node"
"userEnvProbe": "loginInteractiveShell",
"remoteEnv": {
// The location where your uploaded files are stored
"UPLOAD_LOCATION": "${localEnv:UPLOAD_LOCATION:./library}",
// Connection secret for postgres. You should change it to a random password
// Please use only the characters `A-Za-z0-9`, without special characters or spaces
"DB_PASSWORD": "${localEnv:DB_PASSWORD:postgres}",
// The database username
"DB_USERNAME": "${localEnv:DB_USERNAME:postgres}",
// The database name
"DB_DATABASE_NAME": "${localEnv:DB_DATABASE_NAME:immich}"
}
} }

View File

@@ -0,0 +1,8 @@
services:
immich-devcontainer:
build:
dockerfile: Dockerfile
extra_hosts:
- 'host.docker.internal:host-gateway'
volumes:
- ..:/immich:cached

View File

@@ -1,34 +0,0 @@
services:
immich-server:
build:
target: dev-container-mobile
environment:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
volumes: !override # bind mount host to /workspaces/immich
- ..:/workspaces/immich
- cli_node_modules:/workspaces/immich/cli/node_modules
- e2e_node_modules:/workspaces/immich/e2e/node_modules
- 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:/workspaces/immich/server/upload
- ${UPLOAD_LOCATION}/photos/upload:/workspaces/immich/server/upload/upload
- /etc/localtime:/etc/localtime:ro
database:
volumes:
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
volumes:
# Node modules for each service to avoid conflicts and ensure consistent dependencies
cli_node_modules:
e2e_node_modules:
open_api_node_modules:
server_node_modules:
web_node_modules:
# UPLOAD_LOCATION must be set to a absolute path or vol-upload
vol-upload:
# DB_DATA_LOCATION must be set to a absolute path or vol-database
vol-database:

View File

@@ -1,52 +0,0 @@
{
"name": "Immich - Mobile",
"service": "immich-server",
"runServices": [
"immich-server",
"redis",
"database",
"immich-machine-learning"
],
"dockerComposeFile": [
"../../docker/docker-compose.dev.yml",
"./container-compose-overrides.yml"
],
"customizations": {
"vscode": {
"extensions": [
"Dart-Code.dart-code",
"Dart-Code.flutter",
"dcmdev.dcm-vscode-extension",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"svelte.svelte-vscode",
"ms-vscode-remote.remote-containers",
"foxundermoon.shell-format",
"timonwong.shellcheck",
"rvest.vs-code-prettier-eslint",
"bluebrown.yamlfmt",
"vkrishna04.cspell-sync",
"vitest.explorer",
"ms-playwright.playwright",
"ms-azuretools.vscode-docker"
]
}
},
"forwardPorts": [],
"overrideCommand": true,
"workspaceFolder": "/workspaces/immich",
"remoteUser": "node",
"userEnvProbe": "loginInteractiveShell",
"remoteEnv": {
// The location where your uploaded files are stored
"UPLOAD_LOCATION": "${localEnv:UPLOAD_LOCATION:./Library}",
// Connection secret for postgres. You should change it to a random password
// Please use only the characters `A-Za-z0-9`, without special characters or spaces
"DB_PASSWORD": "${localEnv:DB_PASSWORD:postgres}",
// The database username
"DB_USERNAME": "${localEnv:DB_USERNAME:postgres}",
// The database name
"DB_DATABASE_NAME": "${localEnv:DB_DATABASE_NAME:immich}"
}
}

View File

@@ -0,0 +1,6 @@
#!/bin/bash
# If .env file does not exist, create it by copying example.env from the docker folder
if [ ! -f ".devcontainer/.env" ]; then
cp docker/example.env .devcontainer/.env
fi

View File

@@ -0,0 +1,25 @@
#!/bin/bash
# Enable multiarch for arm64 if necessary
if [ "$(dpkg --print-architecture)" = "arm64" ]; then
sudo dpkg --add-architecture amd64 && \
sudo apt-get update && \
sudo apt-get install -y --no-install-recommends \
qemu-user-static \
libc6:amd64 \
libstdc++6:amd64 \
libgcc1:amd64
fi
# Install DCM
wget -qO- https://dcm.dev/pgp-key.public | sudo gpg --dearmor -o /usr/share/keyrings/dcm.gpg
sudo 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
dart --disable-analytics
# Install immich
cd /immich || exit
make install-all

View File

@@ -1,82 +0,0 @@
#!/bin/bash
export IMMICH_PORT="${DEV_SERVER_PORT:-2283}"
export DEV_PORT="${DEV_PORT:-3000}"
# search for immich directory inside workspace.
# /workspaces/immich is the bind mount, but other directories can be mounted if runing
# Devcontainer: Clone [repository|pull request] in container volumne
WORKSPACES_DIR="/workspaces"
IMMICH_DIR="$WORKSPACES_DIR/immich"
IMMICH_DEVCONTAINER_LOG="$HOME/immich-devcontainer.log"
log() {
# Display command on console, log with timestamp to file
echo "$*"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >>"$IMMICH_DEVCONTAINER_LOG"
}
run_cmd() {
# Ensure log directory exists
mkdir -p "$(dirname "$IMMICH_DEVCONTAINER_LOG")"
log "$@"
# Execute command: display normally on console, log with timestamps to file
"$@" 2>&1 | tee >(while IFS= read -r line; do
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $line" >>"$IMMICH_DEVCONTAINER_LOG"
done)
# Preserve exit status
return "${PIPESTATUS[0]}"
}
# Find directories excluding /workspaces/immich
mapfile -t other_dirs < <(find "$WORKSPACES_DIR" -mindepth 1 -maxdepth 1 -type d ! -path "$IMMICH_DIR" ! -name ".*")
if [ ${#other_dirs[@]} -gt 1 ]; then
log "Error: More than one directory found in $WORKSPACES_DIR other than $IMMICH_DIR."
exit 1
elif [ ${#other_dirs[@]} -eq 1 ]; then
export IMMICH_WORKSPACE="${other_dirs[0]}"
else
export IMMICH_WORKSPACE="$IMMICH_DIR"
fi
log "Found immich workspace in $IMMICH_WORKSPACE"
log ""
fix_permissions() {
log "Fixing permissions for ${IMMICH_WORKSPACE}"
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" \
"${IMMICH_WORKSPACE}/cli/node_modules" \
"${IMMICH_WORKSPACE}/e2e/node_modules" \
"${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \
"${IMMICH_WORKSPACE}/server/node_modules" \
"${IMMICH_WORKSPACE}/server/dist" \
"${IMMICH_WORKSPACE}/web/node_modules" \
"${IMMICH_WORKSPACE}/web/dist"; do
if [ -d "$dir" ]; then
run_cmd sudo chown node -R "$dir"
fi
done
log ""
}
install_dependencies() {
log "Installing dependencies"
(
cd "${IMMICH_WORKSPACE}" || exit 1
run_cmd make ci-server
run_cmd make ci-sdk
run_cmd make build-sdk
run_cmd make ci-web
)
log ""
}

View File

@@ -1,49 +0,0 @@
services:
immich-server:
build:
target: dev-container-server
env_file: !reset []
hostname: immich-dev
environment:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
volumes: !override
- ..:/workspaces/immich
- cli_node_modules:/workspaces/immich/cli/node_modules
- e2e_node_modules:/workspaces/immich/e2e/node_modules
- 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}:/workspaces/immich/server/upload
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/workspaces/immich/server/upload/upload
- /etc/localtime:/etc/localtime:ro
immich-web:
env_file: !reset []
immich-machine-learning:
env_file: !reset []
database:
env_file: !reset []
environment: !override
POSTGRES_PASSWORD: ${DB_PASSWORD-postgres}
POSTGRES_USER: ${DB_USERNAME-postgres}
POSTGRES_DB: ${DB_DATABASE_NAME-immich}
POSTGRES_INITDB_ARGS: '--data-checksums'
POSTGRES_HOST_AUTH_METHOD: md5
volumes:
- ${UPLOAD_LOCATION:-postgres-devcontainer-volume}${UPLOAD_LOCATION:+/postgres}:/var/lib/postgresql/data
redis:
env_file: !reset []
volumes:
# Node modules for each service to avoid conflicts and ensure consistent dependencies
cli_node_modules:
e2e_node_modules:
open_api_node_modules:
server_node_modules:
web_node_modules:
upload1-devcontainer-volume:
upload2-devcontainer-volume:
postgres-devcontainer-volume:

View File

@@ -1,17 +0,0 @@
#!/bin/bash
# shellcheck source=common.sh
# shellcheck disable=SC1091
source /immich-devcontainer/container-common.sh
log "Starting Nest API Server"
log ""
cd "${IMMICH_WORKSPACE}/server" || (
log "Immich 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 ..."
sleep 3
done

View File

@@ -1,22 +0,0 @@
#!/bin/bash
# shellcheck source=common.sh
# shellcheck disable=SC1091
source /immich-devcontainer/container-common.sh
log "Starting Immich Web Frontend"
log ""
cd "${IMMICH_WORKSPACE}/web" || (
log "Immich Workspace not found"
exit 1
)
until curl --output /dev/null --silent --head --fail "http://127.0.0.1:${IMMICH_PORT}/api/server/config"; do
log "Waiting for api server..."
sleep 1
done
while true; do
run_cmd node ./node_modules/.bin/vite dev --host 0.0.0.0 --port "${DEV_PORT}"
log "Web crashed with exit code $?. Respawning in 3s ..."
sleep 3
done

View File

@@ -1,20 +0,0 @@
#!/bin/bash
# shellcheck source=common.sh
# shellcheck disable=SC1091
source /immich-devcontainer/container-common.sh
log "Setting up Immich dev container..."
fix_permissions
log "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

@@ -6,11 +6,7 @@ design/
docker/ docker/
!docker/scripts !docker/scripts
docs/ docs/
!docs/package.json
!docs/package-lock.json
e2e/ e2e/
!e2e/package.json
!e2e/package-lock.json
fastlane/ fastlane/
machine-learning/ machine-learning/
misc/ misc/

3
.gitattributes vendored
View File

@@ -9,9 +9,6 @@ mobile/lib/**/*.g.dart linguist-generated=true
mobile/lib/**/*.drift.dart -diff -merge mobile/lib/**/*.drift.dart -diff -merge
mobile/lib/**/*.drift.dart linguist-generated=true 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
open-api/typescript-sdk/fetch-client.ts -diff -merge open-api/typescript-sdk/fetch-client.ts -diff -merge
open-api/typescript-sdk/fetch-client.ts linguist-generated=true open-api/typescript-sdk/fetch-client.ts linguist-generated=true

2
.github/.nvmrc vendored
View File

@@ -1 +1 @@
22.16.0 22.14.0

View File

@@ -14,6 +14,7 @@ body:
label: I have searched the existing feature requests, both open and closed, to make sure this is not a duplicate request. label: I have searched the existing feature requests, both open and closed, to make sure this is not a duplicate request.
options: options:
- label: 'Yes' - label: 'Yes'
required: true
- type: textarea - type: textarea
id: feature id: feature

View File

@@ -6,6 +6,7 @@ body:
label: I have searched the existing issues, both open and closed, to make sure this is not a duplicate report. label: I have searched the existing issues, both open and closed, to make sure this is not a duplicate report.
options: options:
- label: 'Yes' - label: 'Yes'
required: true
- type: markdown - type: markdown
attributes: attributes:

118
.github/actions/image-build/action.yml vendored Normal file
View File

@@ -0,0 +1,118 @@
name: 'Single arch image build'
description: 'Build single-arch image on platform appropriate runner'
inputs:
image:
description: 'Name of the image to build'
required: true
ghcr-token:
description: 'GitHub Container Registry token'
required: true
platform:
description: 'Platform to build for'
required: true
artifact-key-base:
description: 'Base key for artifact name'
required: true
context:
description: 'Path to build context'
required: true
dockerfile:
description: 'Path to Dockerfile'
required: true
build-args:
description: 'Docker build arguments'
required: false
runs:
using: 'composite'
steps:
- name: Prepare
id: prepare
shell: bash
env:
PLATFORM: ${{ inputs.platform }}
run: |
echo "platform-pair=${PLATFORM//\//-}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Login to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ inputs.ghcr-token }}
- name: Generate cache key suffix
id: cache-key-suffix
shell: bash
env:
REF: ${{ github.ref_name }}
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "cache-key-suffix=pr-${{ github.event.number }}" >> $GITHUB_OUTPUT
else
SUFFIX=$(echo "${REF}" | sed 's/[^a-zA-Z0-9]/-/g')
echo "suffix=${SUFFIX}" >> $GITHUB_OUTPUT
fi
- name: Generate cache target
id: cache-target
shell: bash
env:
BUILD_ARGS: ${{ inputs.build-args }}
IMAGE: ${{ inputs.image }}
SUFFIX: ${{ steps.cache-key-suffix.outputs.suffix }}
PLATFORM_PAIR: ${{ steps.prepare.outputs.platform-pair }}
run: |
HASH=$(sha256sum <<< "${BUILD_ARGS}" | cut -d' ' -f1)
CACHE_KEY="${PLATFORM_PAIR}-${HASH}"
echo "cache-key-base=${CACHE_KEY}" >> $GITHUB_OUTPUT
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
# Essentially just ignore the cache output (forks can't write to registry cache)
echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT
else
echo "cache-to=type=registry,ref=${IMAGE}-build-cache:${CACHE_KEY}-${SUFFIX},mode=max,compression=zstd" >> $GITHUB_OUTPUT
fi
- name: Generate docker image tags
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
env:
DOCKER_METADATA_PR_HEAD_SHA: 'true'
- name: Build and push image
id: build
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
platforms: ${{ inputs.platform }}
labels: ${{ steps.meta.outputs.labels }}
cache-to: ${{ steps.cache-target.outputs.cache-to }}
cache-from: |
type=registry,ref=${{ inputs.image }}-build-cache:${{ steps.cache-target.outputs.cache-key-base }}-${{ steps.cache-key-suffix.outputs.suffix }}
type=registry,ref=${{ inputs.image }}-build-cache:${{ steps.cache-target.outputs.cache-key-base }}-main
outputs: type=image,"name=${{ inputs.image }}",push-by-digest=true,name-canonical=true,push=${{ !github.event.pull_request.head.repo.fork }}
build-args: |
BUILD_ID=${{ github.run_id }}
BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.meta.outputs.tags }}
BUILD_SOURCE_REF=${{ github.ref_name }}
BUILD_SOURCE_COMMIT=${{ github.sha }}
${{ inputs.build-args }}
- name: Export digest
shell: bash
run: | # zizmor: ignore[template-injection]
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ${{ inputs.artifact-key-base }}-${{ steps.cache-target.outputs.cache-key-base }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1

View File

@@ -35,12 +35,12 @@ jobs:
should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- id: found_paths - id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
with: with:
filters: | filters: |
mobile: mobile:
@@ -58,54 +58,32 @@ jobs:
contents: read contents: read
# Skip when PR from a fork # 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' }} 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: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
ref: ${{ inputs.ref || github.sha }} ref: ${{ inputs.ref || github.sha }}
persist-credentials: false persist-credentials: false
- name: Install missing deps - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4
run: |
sudo add-apt-repository ppa:rmescandon/yq
sudo apt-get update
sudo apt-get install -y yq xz-utils ninja-build zstd
- 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: with:
distribution: 'zulu' distribution: 'zulu'
java-version: '17' java-version: '17'
cache: 'gradle'
- 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
- name: Setup Flutter SDK - name: Setup Flutter SDK
uses: subosito/flutter-action@395322a6cded4e9ed503aebd4cc1965625f8e59a # v2.20.0 uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2
with: with:
channel: 'stable' channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml flutter-version-file: ./mobile/pubspec.yaml
cache: true cache: true
- name: Setup Android SDK - name: Create the Keystore
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3.2.2 env:
with: KEY_JKS: ${{ secrets.KEY_JKS }}
packages: '' working-directory: ./mobile
run: echo $KEY_JKS | base64 -d > android/key.jks
- name: Get Packages - name: Get Packages
working-directory: ./mobile working-directory: ./mobile
@@ -115,40 +93,18 @@ jobs:
run: make translation run: make translation
working-directory: ./mobile working-directory: ./mobile
- name: Generate platform APIs
run: make pigeon
working-directory: ./mobile
- name: Build Android App Bundle - name: Build Android App Bundle
working-directory: ./mobile working-directory: ./mobile
env: env:
ALIAS: ${{ secrets.ALIAS }} ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }} ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
run: | run: |
if [[ $IS_MAIN == 'true' ]]; then flutter build apk --release
flutter build apk --release flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
else
flutter build apk --debug --split-per-abi --target-platform android-arm64
fi
- name: Publish Android Artifact - name: Publish Android Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: release-apk-signed name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk 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

@@ -19,7 +19,7 @@ jobs:
actions: write actions: write
steps: steps:
- name: Check out code - name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false

View File

@@ -29,18 +29,15 @@ jobs:
working-directory: ./cli working-directory: ./cli
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
# Setup .npmrc file to publish to npm # Setup .npmrc file to publish to npm
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: './cli/.nvmrc' node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Prepare SDK - name: Prepare SDK
run: npm ci --prefix ../open-api/typescript-sdk/ run: npm ci --prefix ../open-api/typescript-sdk/
- name: Build SDK - name: Build SDK
@@ -62,7 +59,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
@@ -70,10 +67,10 @@ jobs:
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx - 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 - name: Login to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
if: ${{ !github.event.pull_request.head.repo.fork }} if: ${{ !github.event.pull_request.head.repo.fork }}
with: with:
registry: ghcr.io registry: ghcr.io
@@ -88,7 +85,7 @@ jobs:
- name: Generate docker image tags - name: Generate docker image tags
id: metadata id: metadata
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
with: with:
flavor: | flavor: |
latest=false latest=false
@@ -99,7 +96,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }} type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with: with:
file: cli/Dockerfile file: cli/Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View File

@@ -44,13 +44,13 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0 uses: github/codeql-action/init@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # 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). # 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) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0 uses: github/codeql-action/autobuild@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3
# Command-line programs to run using the OS shell. # 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 # 📚 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 # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0 uses: github/codeql-action/analyze@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'

View File

@@ -24,11 +24,11 @@ jobs:
should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- id: found_paths - id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
with: with:
filters: | filters: |
server: server:
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn'] suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps: steps:
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -89,7 +89,7 @@ jobs:
suffix: [''] suffix: ['']
steps: steps:
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -131,7 +131,7 @@ jobs:
tag-suffix: '-rocm' tag-suffix: '-rocm'
platforms: linux/amd64 platforms: linux/amd64
runner-mapping: '{"linux/amd64": "mich"}' runner-mapping: '{"linux/amd64": "mich"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@094bfb927b8cd75b343abaac27b3241be0fccfe9 # multi-runner-build-workflow-0.1.0 uses: ./.github/workflows/multi-runner-build.yml
permissions: permissions:
contents: read contents: read
actions: read actions: read
@@ -154,7 +154,7 @@ jobs:
name: Build and Push Server name: Build and Push Server
needs: pre-job needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }} if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@094bfb927b8cd75b343abaac27b3241be0fccfe9 # multi-runner-build-workflow-0.1.0 uses: ./.github/workflows/multi-runner-build.yml
permissions: permissions:
contents: read contents: read
actions: read actions: read
@@ -177,9 +177,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: always() if: always()
steps: steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4 - name: Any jobs failed?
with: if: ${{ contains(needs.*.result, 'failure') }}
needs: ${{ toJSON(needs) }} run: exit 1
- name: All jobs passed or skipped
if: ${{ !(contains(needs.*.result, 'failure')) }}
# zizmor: ignore[template-injection]
run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}"
success-check-ml: success-check-ml:
name: Docker Build & Push ML Success name: Docker Build & Push ML Success
@@ -188,6 +192,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: always() if: always()
steps: steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4 - name: Any jobs failed?
with: if: ${{ contains(needs.*.result, 'failure') }}
needs: ${{ toJSON(needs) }} run: exit 1
- name: All jobs passed or skipped
if: ${{ !(contains(needs.*.result, 'failure')) }}
# zizmor: ignore[template-injection]
run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}"

View File

@@ -21,11 +21,11 @@ jobs:
should_run: ${{ steps.found_paths.outputs.docs == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run: ${{ steps.found_paths.outputs.docs == 'true' || steps.should_force.outputs.should_force == 'true' }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- id: found_paths - id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
with: with:
filters: | filters: |
docs: docs:
@@ -49,16 +49,14 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: './docs/.nvmrc' node-version-file: './docs/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install - name: Run npm install
run: npm ci run: npm ci
@@ -70,7 +68,7 @@ jobs:
run: npm run build run: npm run build
- name: Upload build output - name: Upload build output
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: docs-build-output name: docs-build-output
path: docs/build/ path: docs/build/

View File

@@ -20,7 +20,7 @@ jobs:
run: echo 'The triggering workflow did not succeed' && exit 1 run: echo 'The triggering workflow did not succeed' && exit 1
- name: Get artifact - name: Get artifact
id: get-artifact id: get-artifact
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with: with:
script: | script: |
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
@@ -38,7 +38,7 @@ jobs:
return { found: true, id: matchArtifact.id }; return { found: true, id: matchArtifact.id };
- name: Determine deploy parameters - name: Determine deploy parameters
id: parameters id: parameters
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
env: env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }} HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
with: with:
@@ -108,13 +108,13 @@ jobs:
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }} if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Load parameters - name: Load parameters
id: parameters id: parameters
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
env: env:
PARAM_JSON: ${{ needs.checks.outputs.parameters }} PARAM_JSON: ${{ needs.checks.outputs.parameters }}
with: with:
@@ -125,7 +125,7 @@ jobs:
core.setOutput("shouldDeploy", parameters.shouldDeploy); core.setOutput("shouldDeploy", parameters.shouldDeploy);
- name: Download artifact - name: Download artifact
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
env: env:
ARTIFACT_JSON: ${{ needs.checks.outputs.artifact }} ARTIFACT_JSON: ${{ needs.checks.outputs.artifact }}
with: with:
@@ -150,7 +150,7 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
uses: gruntwork-io/terragrunt-action@aee21a7df999be8b471c2a8564c6cd853cb674e1 # v2.1.8 uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2
with: with:
tg_version: '0.58.12' tg_version: '0.58.12'
tofu_version: '1.7.1' tofu_version: '1.7.1'
@@ -165,7 +165,7 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
uses: gruntwork-io/terragrunt-action@aee21a7df999be8b471c2a8564c6cd853cb674e1 # v2.1.8 uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2
with: with:
tg_version: '0.58.12' tg_version: '0.58.12'
tofu_version: '1.7.1' tofu_version: '1.7.1'
@@ -181,8 +181,7 @@ jobs:
echo "output=$CLEANED" >> $GITHUB_OUTPUT echo "output=$CLEANED" >> $GITHUB_OUTPUT
- name: Publish to Cloudflare Pages - name: Publish to Cloudflare Pages
# TODO: Action is deprecated uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1
uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1.5.0
with: with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }} apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -199,7 +198,7 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
uses: gruntwork-io/terragrunt-action@aee21a7df999be8b471c2a8564c6cd853cb674e1 # v2.1.8 uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2
with: with:
tg_version: '0.58.12' tg_version: '0.58.12'
tofu_version: '1.7.1' tofu_version: '1.7.1'
@@ -207,7 +206,7 @@ jobs:
tg_command: 'apply' tg_command: 'apply'
- name: Comment - name: Comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0 uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3
if: ${{ steps.parameters.outputs.event == 'pr' }} if: ${{ steps.parameters.outputs.event == 'pr' }}
with: with:
number: ${{ fromJson(needs.checks.outputs.parameters).pr_number }} number: ${{ fromJson(needs.checks.outputs.parameters).pr_number }}

View File

@@ -14,7 +14,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
@@ -25,7 +25,7 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
uses: gruntwork-io/terragrunt-action@aee21a7df999be8b471c2a8564c6cd853cb674e1 # v2.1.8 uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2
with: with:
tg_version: '0.58.12' tg_version: '0.58.12'
tofu_version: '1.7.1' tofu_version: '1.7.1'
@@ -33,7 +33,7 @@ jobs:
tg_command: 'destroy -refresh=false' tg_command: 'destroy -refresh=false'
- name: Comment - name: Comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0 uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3
with: with:
number: ${{ github.event.number }} number: ${{ github.event.number }}
delete: true delete: true

View File

@@ -16,36 +16,34 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: 'Checkout' - name: 'Checkout'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
ref: ${{ github.event.pull_request.head.ref }} ref: ${{ github.event.pull_request.head.ref }}
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true persist-credentials: true
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Fix formatting - name: Fix formatting
run: make install-all && make format-all run: make install-all && make format-all
- name: Commit and push - name: Commit and push
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4 uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9
with: with:
default_author: github_actions default_author: github_actions
message: 'chore: fix formatting' message: 'chore: fix formatting'
- name: Remove label - name: Remove label
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
if: always() if: always()
with: with:
script: | script: |

185
.github/workflows/multi-runner-build.yml vendored Normal file
View File

@@ -0,0 +1,185 @@
name: 'Multi-runner container image build'
on:
workflow_call:
inputs:
image:
description: 'Name of the image'
type: string
required: true
context:
description: 'Path to build context'
type: string
required: true
dockerfile:
description: 'Path to Dockerfile'
type: string
required: true
tag-suffix:
description: 'Suffix to append to the image tag'
type: string
default: ''
dockerhub-push:
description: 'Push to Docker Hub'
type: boolean
default: false
build-args:
description: 'Docker build arguments'
type: string
required: false
platforms:
description: 'Platforms to build for'
type: string
runner-mapping:
description: 'Mapping from platforms to runners'
type: string
secrets:
DOCKERHUB_USERNAME:
required: false
DOCKERHUB_TOKEN:
required: false
env:
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ inputs.image }}
DOCKERHUB_IMAGE: altran1502/${{ inputs.image }}
jobs:
matrix:
name: 'Generate matrix'
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
key: ${{ steps.artifact-key.outputs.base }}
steps:
- name: Generate build matrix
id: matrix
shell: bash
env:
PLATFORMS: ${{ inputs.platforms || 'linux/amd64,linux/arm64' }}
RUNNER_MAPPING: ${{ inputs.runner-mapping || '{"linux/amd64":"ubuntu-latest","linux/arm64":"ubuntu-24.04-arm"}' }}
run: |
matrix=$(jq -R -c \
--argjson runner_mapping "${RUNNER_MAPPING}" \
'split(",") | map({platform: ., runner: $runner_mapping[.]})' \
<<< "${PLATFORMS}")
echo "${matrix}"
echo "matrix=${matrix}" >> $GITHUB_OUTPUT
- name: Determine artifact key
id: artifact-key
shell: bash
env:
IMAGE: ${{ inputs.image }}
SUFFIX: ${{ inputs.tag-suffix }}
run: |
if [[ -n "${SUFFIX}" ]]; then
base="${IMAGE}${SUFFIX}-digests"
else
base="${IMAGE}-digests"
fi
echo "${base}"
echo "base=${base}" >> $GITHUB_OUTPUT
build:
needs: matrix
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.matrix.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- uses: ./.github/actions/image-build
with:
context: ${{ inputs.context }}
dockerfile: ${{ inputs.dockerfile }}
image: ${{ env.GHCR_IMAGE }}
ghcr-token: ${{ secrets.GITHUB_TOKEN }}
platform: ${{ matrix.platform }}
artifact-key-base: ${{ needs.matrix.outputs.key }}
build-args: ${{ inputs.build-args }}
merge:
needs: [matrix, build]
runs-on: ubuntu-latest
if: ${{ !github.event.pull_request.head.repo.fork }}
permissions:
contents: read
actions: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
with:
path: ${{ runner.temp }}/digests
pattern: ${{ needs.matrix.outputs.key }}-*
merge-multiple: true
- name: Login to Docker Hub
if: ${{ inputs.dockerhub-push }}
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
- name: Generate docker image tags
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
env:
DOCKER_METADATA_PR_HEAD_SHA: 'true'
with:
flavor: |
# Disable latest tag
latest=false
suffix=${{ inputs.tag-suffix }}
images: |
name=${{ env.GHCR_IMAGE }}
name=${{ env.DOCKERHUB_IMAGE }},enable=${{ inputs.dockerhub-push }}
tags: |
# Tag with branch name
type=ref,event=branch
# Tag with pr-number
type=ref,event=pr
# Tag with long commit sha hash
type=sha,format=long,prefix=commit-
# Tag with git tag on release
type=ref,event=tag
type=raw,value=release,enable=${{ github.event_name == 'release' }}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
# Process annotations
declare -a ANNOTATIONS=()
if [[ -n "$DOCKER_METADATA_OUTPUT_JSON" ]]; then
while IFS= read -r annotation; do
# Extract key and value by removing the manifest: prefix
if [[ "$annotation" =~ ^manifest:(.+)=(.+)$ ]]; then
key="${BASH_REMATCH[1]}"
value="${BASH_REMATCH[2]}"
# Use array to properly handle arguments with spaces
ANNOTATIONS+=(--annotation "index:$key=$value")
fi
done < <(jq -r '.annotations[]' <<< "$DOCKER_METADATA_OUTPUT_JSON")
fi
TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
SOURCE_ARGS=$(printf "${GHCR_IMAGE}@sha256:%s " *)
docker buildx imagetools create $TAGS "${ANNOTATIONS[@]}" $SOURCE_ARGS

View File

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

View File

@@ -11,4 +11,4 @@ jobs:
pull-requests: write pull-requests: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0 - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5

View File

@@ -32,19 +32,19 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true persist-credentials: true
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Bump version - name: Bump version
env: env:
@@ -54,7 +54,7 @@ jobs:
- name: Commit and tag - name: Commit and tag
id: push-tag id: push-tag
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4 uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9
with: with:
default_author: github_actions default_author: github_actions
message: 'chore: version ${{ env.IMMICH_VERSION }}' message: 'chore: version ${{ env.IMMICH_VERSION }}'
@@ -83,24 +83,24 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false persist-credentials: false
- name: Download APK - name: Download APK
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with: with:
name: release-apk-signed name: release-apk-signed
- name: Create draft release - name: Create draft release
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2 uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
with: with:
draft: true draft: true
tag_name: ${{ env.IMMICH_VERSION }} tag_name: ${{ env.IMMICH_VERSION }}

View File

@@ -13,7 +13,7 @@ jobs:
permissions: permissions:
pull-requests: write pull-requests: write
steps: steps:
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2 - uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2
with: with:
message-id: 'preview-status' message-id: 'preview-status'
message: 'Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.cloud/' message: 'Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.cloud/'
@@ -24,7 +24,7 @@ jobs:
permissions: permissions:
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with: with:
script: | script: |
github.rest.issues.removeLabel({ github.rest.issues.removeLabel({

View File

@@ -16,17 +16,15 @@ jobs:
run: run:
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
# Setup .npmrc file to publish to npm # Setup .npmrc file to publish to npm
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: './open-api/typescript-sdk/.nvmrc' node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install deps - name: Install deps
run: npm ci run: npm ci
- name: Build - name: Build

View File

@@ -20,11 +20,11 @@ jobs:
should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- id: found_paths - id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
with: with:
filters: | filters: |
mobile: mobile:
@@ -44,12 +44,12 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Flutter SDK - name: Setup Flutter SDK
uses: subosito/flutter-action@395322a6cded4e9ed503aebd4cc1965625f8e59a # v2.20.0 uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2
with: with:
channel: 'stable' channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml flutter-version-file: ./mobile/pubspec.yaml
@@ -58,28 +58,16 @@ jobs:
run: dart pub get run: dart pub get
working-directory: ./mobile working-directory: ./mobile
- name: Install DCM
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 - name: Generate translation file
run: make translation run: make translation; dart format lib/generated/codegen_loader.g.dart
working-directory: ./mobile working-directory: ./mobile
- name: Run Build Runner - name: Run Build Runner
run: make build run: make build
working-directory: ./mobile working-directory: ./mobile
- name: Generate platform API
run: make pigeon
working-directory: ./mobile
- name: Find file changes - name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20
id: verify-changed-files id: verify-changed-files
with: with:
files: | files: |
@@ -108,10 +96,6 @@ jobs:
run: dart run custom_lint run: dart run custom_lint
working-directory: ./mobile working-directory: ./mobile
- name: Run DCM
run: dcm analyze lib --fatal-style --fatal-warnings
working-directory: ./mobile
zizmor: zizmor:
name: zizmor name: zizmor
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -121,12 +105,12 @@ jobs:
actions: read actions: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Install the latest version of uv - name: Install the latest version of uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Run zizmor 🌈 - name: Run zizmor 🌈
run: uvx zizmor --format=sarif . > results.sarif run: uvx zizmor --format=sarif . > results.sarif
@@ -134,7 +118,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload SARIF file - name: Upload SARIF file
uses: github/codeql-action/upload-sarif@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0 uses: github/codeql-action/upload-sarif@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3
with: with:
sarif_file: results.sarif sarif_file: results.sarif
category: zizmor category: zizmor

View File

@@ -17,7 +17,6 @@ jobs:
permissions: permissions:
contents: read contents: read
outputs: outputs:
should_run_i18n: ${{ steps.found_paths.outputs.i18n == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run_web: ${{ steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run_web: ${{ steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run_cli: ${{ steps.found_paths.outputs.cli == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run_cli: ${{ steps.found_paths.outputs.cli == 'true' || steps.should_force.outputs.should_force == 'true' }}
@@ -29,16 +28,14 @@ jobs:
should_run_.github: ${{ steps.found_paths.outputs['.github'] == 'true' || steps.should_force.outputs.should_force == 'true' }} # redundant to have should_force but if someone changes the trigger then this won't have to be changed should_run_.github: ${{ steps.found_paths.outputs['.github'] == 'true' || steps.should_force.outputs.should_force == 'true' }} # redundant to have should_force but if someone changes the trigger then this won't have to be changed
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- id: found_paths - id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
with: with:
filters: | filters: |
i18n:
- 'i18n/**'
web: web:
- 'web/**' - 'web/**'
- 'i18n/**' - 'i18n/**'
@@ -76,16 +73,14 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install - name: Run npm install
run: npm ci run: npm ci
@@ -103,7 +98,7 @@ jobs:
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run small tests & coverage - name: Run small tests & coverage
run: npm test run: npm run test:cov
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
cli-unit-tests: cli-unit-tests:
@@ -119,16 +114,14 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: './cli/.nvmrc' node-version-file: './cli/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Setup typescript-sdk - name: Setup typescript-sdk
run: npm ci && npm run build run: npm ci && npm run build
@@ -150,7 +143,7 @@ jobs:
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run unit tests & coverage - name: Run unit tests & coverage
run: npm run test run: npm run test:cov
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
cli-unit-tests-win: cli-unit-tests-win:
@@ -166,16 +159,14 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: './cli/.nvmrc' node-version-file: './cli/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Setup typescript-sdk - name: Setup typescript-sdk
run: npm ci && npm run build run: npm ci && npm run build
@@ -190,7 +181,7 @@ jobs:
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run unit tests & coverage - name: Run unit tests & coverage
run: npm run test run: npm run test:cov
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
web-lint: web-lint:
@@ -206,16 +197,14 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: './web/.nvmrc' node-version-file: './web/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk - name: Run setup typescript-sdk
run: npm ci && npm run build run: npm ci && npm run build
@@ -249,16 +238,14 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: './web/.nvmrc' node-version-file: './web/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk - name: Run setup typescript-sdk
run: npm ci && npm run build run: npm ci && npm run build
@@ -272,51 +259,9 @@ jobs:
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run unit tests & coverage - name: Run unit tests & coverage
run: npm run test run: npm run test:cov
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
i18n-tests:
name: Test i18n
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_i18n == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Setup Node
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
- name: Format
run: npm --prefix=web run format:i18n
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files
with:
files: |
i18n/**
- name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
env:
CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }}
run: |
echo "ERROR: i18n files not up to date!"
echo "Changed files: ${CHANGED_FILES}"
exit 1
e2e-tests-lint: e2e-tests-lint:
name: End-to-End Lint name: End-to-End Lint
needs: pre-job needs: pre-job
@@ -330,16 +275,14 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: './e2e/.nvmrc' node-version-file: './e2e/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk - name: Run setup typescript-sdk
run: npm ci && npm run build run: npm ci && npm run build
@@ -375,16 +318,14 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install - name: Run npm install
run: npm ci run: npm ci
@@ -409,17 +350,15 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
submodules: 'recursive' submodules: 'recursive'
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: './e2e/.nvmrc' node-version-file: './e2e/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk - name: Run setup typescript-sdk
run: npm ci && npm run build run: npm ci && npm run build
@@ -459,17 +398,15 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
submodules: 'recursive' submodules: 'recursive'
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: './e2e/.nvmrc' node-version-file: './e2e/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk - name: Run setup typescript-sdk
run: npm ci && npm run build run: npm ci && npm run build
@@ -481,7 +418,7 @@ jobs:
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: npx playwright install chromium --only-shell run: npx playwright install --with-deps chromium
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Docker build - name: Docker build
@@ -499,9 +436,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: always() if: always()
steps: steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4 - name: Any jobs failed?
with: if: ${{ contains(needs.*.result, 'failure') }}
needs: ${{ toJSON(needs) }} run: exit 1
- name: All jobs passed or skipped
if: ${{ !(contains(needs.*.result, 'failure')) }}
# zizmor: ignore[template-injection]
run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}"
mobile-unit-tests: mobile-unit-tests:
name: Unit Test Mobile name: Unit Test Mobile
@@ -511,20 +452,15 @@ jobs:
permissions: permissions:
contents: read contents: read
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Flutter SDK - name: Setup Flutter SDK
uses: subosito/flutter-action@395322a6cded4e9ed503aebd4cc1965625f8e59a # v2.20.0 uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2
with: with:
channel: 'stable' channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml flutter-version-file: ./mobile/pubspec.yaml
- name: Generate translation file
run: make translation
working-directory: ./mobile
- name: Run tests - name: Run tests
working-directory: ./mobile working-directory: ./mobile
run: flutter test -j 1 run: flutter test -j 1
@@ -540,13 +476,13 @@ jobs:
run: run:
working-directory: ./machine-learning working-directory: ./machine-learning
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818) # TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
# with: # with:
# python-version: 3.11 # python-version: 3.11
@@ -580,16 +516,14 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: './.github/.nvmrc' node-version-file: './.github/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install - name: Run npm install
run: npm ci run: npm ci
@@ -604,12 +538,12 @@ jobs:
permissions: permissions:
contents: read contents: read
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Run ShellCheck - name: Run ShellCheck
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0 uses: ludeeus/action-shellcheck@master
with: with:
ignore_paths: >- ignore_paths: >-
**/open-api/** **/open-api/**
@@ -623,16 +557,14 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install server dependencies - name: Install server dependencies
run: npm --prefix=server ci run: npm --prefix=server ci
@@ -644,7 +576,7 @@ jobs:
run: make open-api run: make open-api
- name: Find file changes - name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20
id: verify-changed-files id: verify-changed-files
with: with:
files: | files: |
@@ -668,7 +600,7 @@ jobs:
contents: read contents: read
services: services:
postgres: postgres:
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3@sha256:1f5583fe3397210a0fbc7f11b0cec18bacc4a99e3e8ea0548e9bd6bcf26ec37a image: tensorchord/vchord-postgres:pg14-v0.3.0
env: env:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres POSTGRES_USER: postgres
@@ -686,16 +618,14 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install server dependencies - name: Install server dependencies
run: npm ci run: npm ci
@@ -714,7 +644,7 @@ jobs:
run: npm run migrations:generate src/TestMigration run: npm run migrations:generate src/TestMigration
- name: Find file changes - name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20
id: verify-changed-files id: verify-changed-files
with: with:
files: | files: |
@@ -735,7 +665,7 @@ jobs:
DB_URL: postgres://postgres:postgres@localhost:5432/immich DB_URL: postgres://postgres:postgres@localhost:5432/immich
- name: Find file changes - name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20
id: verify-changed-sql-files id: verify-changed-sql-files
with: with:
files: | files: |
@@ -748,7 +678,6 @@ jobs:
run: | run: |
echo "ERROR: Generated SQL files not up to date!" echo "ERROR: Generated SQL files not up to date!"
echo "Changed files: ${CHANGED_FILES}" echo "Changed files: ${CHANGED_FILES}"
git diff
exit 1 exit 1
# mobile-integration-tests: # mobile-integration-tests:

View File

@@ -15,11 +15,11 @@ jobs:
should_run: ${{ steps.found_paths.outputs.i18n == 'true' && github.head_ref != 'chore/translations'}} should_run: ${{ steps.found_paths.outputs.i18n == 'true' && github.head_ref != 'chore/translations'}}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- id: found_paths - id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
with: with:
filters: | filters: |
i18n: i18n:
@@ -38,7 +38,7 @@ jobs:
exit 1 exit 1
fi fi
- name: Find Pull Request - name: Find Pull Request
uses: juliangruber/find-pull-request-action@48b6133aa6c826f267ebd33aa2d29470f9d9e7d0 # v1.9.0 uses: juliangruber/find-pull-request-action@48b6133aa6c826f267ebd33aa2d29470f9d9e7d0 # v1
id: find-pr id: find-pr
with: with:
branch: chore/translations branch: chore/translations
@@ -52,6 +52,10 @@ jobs:
permissions: {} permissions: {}
if: always() if: always()
steps: steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4 - name: Any jobs failed?
with: if: ${{ contains(needs.*.result, 'failure') }}
needs: ${{ toJSON(needs) }} run: exit 1
- name: All jobs passed or skipped
if: ${{ !(contains(needs.*.result, 'failure')) }}
# zizmor: ignore[template-injection]
run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}"

1
.gitignore vendored
View File

@@ -3,7 +3,6 @@
.DS_Store .DS_Store
.vscode/* .vscode/*
!.vscode/launch.json !.vscode/launch.json
!.vscode/extensions.json
.idea .idea
docker/upload docker/upload

View File

@@ -1,10 +0,0 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"svelte.svelte-vscode",
"dbaeumer.vscode-eslint",
"dart-code.flutter",
"dart-code.dart-code",
"dcmdev.dcm-vscode-extension"
]
}

72
.vscode/tasks.json vendored
View File

@@ -1,72 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Fix Permissions, Install Dependencies",
"type": "shell",
"command": "[ -f /immich-devcontainer/container-start.sh ] && /immich-devcontainer/container-start.sh || exit 0",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "dedicated",
"showReuseMessage": true,
"clear": false,
"group": "Devcontainer tasks",
"close": true
},
"runOptions": {
"runOn": "default"
},
"problemMatcher": []
},
{
"label": "Immich API Server (Nest)",
"dependsOn": ["Fix Permissions, Install Dependencies"],
"type": "shell",
"command": "[ -f /immich-devcontainer/container-start-backend.sh ] && /immich-devcontainer/container-start-backend.sh || exit 0",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "dedicated",
"showReuseMessage": true,
"clear": false,
"group": "Devcontainer tasks",
"close": true
},
"runOptions": {
"runOn": "default"
},
"problemMatcher": []
},
{
"label": "Immich Web Server (Vite)",
"dependsOn": ["Fix Permissions, Install Dependencies"],
"type": "shell",
"command": "[ -f /immich-devcontainer/container-start-frontend.sh ] && /immich-devcontainer/container-start-frontend.sh || exit 0",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "dedicated",
"showReuseMessage": true,
"clear": false,
"group": "Devcontainer tasks",
"close": true
},
"runOptions": {
"runOn": "default"
},
"problemMatcher": []
},
{
"label": "Immich Server and Web",
"dependsOn": ["Immich Web Server (Vite)", "Immich API Server (Nest)"],
"runOptions": {
"runOn": "folderOpen"
},
"problemMatcher": []
}
]
}

View File

@@ -48,8 +48,6 @@ audit-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
install-%: install-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) i npm --prefix $(subst sdk,open-api/typescript-sdk,$*) i
ci-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) ci
build-cli: build-sdk build-cli: build-sdk
build-web: build-sdk build-web: build-sdk
build-%: install-% build-%: install-%
@@ -84,7 +82,6 @@ test-medium-dev:
build-all: $(foreach M,$(filter-out e2e .github,$(MODULES)),build-$M) ; build-all: $(foreach M,$(filter-out e2e .github,$(MODULES)),build-$M) ;
install-all: $(foreach M,$(MODULES),install-$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) ; 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) ; lint-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),lint-$M) ;
format-all: $(foreach M,$(filter-out sdk,$(MODULES)),format-$M) ; format-all: $(foreach M,$(filter-out sdk,$(MODULES)),format-$M) ;

View File

@@ -1 +1 @@
22.16.0 22.14.0

View File

@@ -1,4 +1,4 @@
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS core FROM node:22.15.0-alpine3.20@sha256:686b8892b69879ef5bfd6047589666933508f9a5451c67320df3070ba0e9807b AS core
WORKDIR /usr/src/open-api/typescript-sdk WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./

1528
cli/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.72", "version": "2.2.65",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
@@ -21,7 +21,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.15.32", "@types/node": "^22.14.1",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
@@ -29,7 +29,7 @@
"eslint": "^9.14.0", "eslint": "^9.14.0",
"eslint-config-prettier": "^10.0.0", "eslint-config-prettier": "^10.0.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^59.0.0", "eslint-plugin-unicorn": "^57.0.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
@@ -69,6 +69,6 @@
"micromatch": "^4.0.8" "micromatch": "^4.0.8"
}, },
"volta": { "volta": {
"node": "22.16.0" "node": "22.14.0"
} }
} }

View File

@@ -43,7 +43,6 @@ export interface UploadOptionsDto {
concurrency: number; concurrency: number;
progress?: boolean; progress?: boolean;
watch?: boolean; watch?: boolean;
jsonOutput?: boolean;
} }
class UploadFile extends File { class UploadFile extends File {
@@ -66,14 +65,8 @@ class UploadFile extends File {
const uploadBatch = async (files: string[], options: UploadOptionsDto) => { const uploadBatch = async (files: string[], options: UploadOptionsDto) => {
const { newFiles, duplicates } = await checkForDuplicates(files, options); const { newFiles, duplicates } = await checkForDuplicates(files, options);
const newAssets = await uploadFiles(newFiles, options); const newAssets = await uploadFiles(newFiles, options);
if (options.jsonOutput) {
console.log(JSON.stringify({ newFiles, duplicates, newAssets }, undefined, 4));
}
await updateAlbums([...newAssets, ...duplicates], options); await updateAlbums([...newAssets, ...duplicates], options);
await deleteFiles( await deleteFiles(newFiles, options);
newAssets.map(({ filepath }) => filepath),
options,
);
}; };
export const startWatch = async ( export const startWatch = async (

View File

@@ -68,11 +68,6 @@ program
.env('IMMICH_UPLOAD_CONCURRENCY') .env('IMMICH_UPLOAD_CONCURRENCY')
.default(4), .default(4),
) )
.addOption(
new Option('-j, --json-output', 'Output detailed information in json format')
.env('IMMICH_JSON_OUTPUT')
.default(false),
)
.addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS')) .addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
.addOption(new Option('--no-progress', 'Hide progress bars').env('IMMICH_PROGRESS_BAR').default(true)) .addOption(new Option('--no-progress', 'Hide progress bars').env('IMMICH_PROGRESS_BAR').default(true))
.addOption( .addOption(

View File

@@ -48,7 +48,7 @@ services:
IMMICH_THIRD_PARTY_SOURCE_URL: https://github.com/immich-app/immich/ IMMICH_THIRD_PARTY_SOURCE_URL: https://github.com/immich-app/immich/
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://immich.app/docs IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://immich.app/docs
IMMICH_THIRD_PARTY_SUPPORT_URL: https://immich.app/docs/community-guides IMMICH_THIRD_PARTY_SUPPORT_URL: https://immich.app/docs/third-party
ulimits: ulimits:
nofile: nofile:
soft: 1048576 soft: 1048576
@@ -116,13 +116,13 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:fec42f399876eb6faf9e008570597741c87ff7662a54185593e74b09ce83d177 image: docker.io/valkey/valkey:8-bookworm@sha256:4a9f847af90037d59b34cd4d4ad14c6e055f46540cf4ff757aaafb266060fa28
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
database: database:
container_name: immich_postgres container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:5f6a838e4e44c8e0e019d0ebfe3ee8952b69afc2809b2c25f7b0119641978e91 image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvectors0.2.0
env_file: env_file:
- .env - .env
environment: environment:
@@ -134,7 +134,7 @@ services:
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data - ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
ports: ports:
- 5432:5432 - 5432:5432
shm_size: 128mb
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics # set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
# immich-prometheus: # immich-prometheus:
# container_name: immich_prometheus # container_name: immich_prometheus

View File

@@ -56,14 +56,14 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:fec42f399876eb6faf9e008570597741c87ff7662a54185593e74b09ce83d177 image: docker.io/valkey/valkey:8-bookworm@sha256:4a9f847af90037d59b34cd4d4ad14c6e055f46540cf4ff757aaafb266060fa28
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
database: database:
container_name: immich_postgres container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:5f6a838e4e44c8e0e019d0ebfe3ee8952b69afc2809b2c25f7b0119641978e91 image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvectors0.2.0
env_file: env_file:
- .env - .env
environment: environment:
@@ -75,7 +75,6 @@ services:
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data - ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
ports: ports:
- 5432:5432 - 5432:5432
shm_size: 128mb
restart: always restart: always
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics # set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
@@ -83,7 +82,7 @@ services:
container_name: immich_prometheus container_name: immich_prometheus
ports: ports:
- 9090:9090 - 9090:9090
image: prom/prometheus@sha256:9abc6cf6aea7710d163dbb28d8eeb7dc5baef01e38fa4cd146a406dd9f07f70d image: prom/prometheus@sha256:e2b8aa62b64855956e3ec1e18b4f9387fb6203174a4471936f4662f437f04405
volumes: volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml - ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus - prometheus-data:/prometheus
@@ -92,10 +91,10 @@ services:
# add data source for http://immich-prometheus:9090 to get started # add data source for http://immich-prometheus:9090 to get started
immich-grafana: immich-grafana:
container_name: immich_grafana container_name: immich_grafana
command: ['./run.sh', '-disable-reporting'] command: [ './run.sh', '-disable-reporting' ]
ports: ports:
- 3000:3000 - 3000:3000
image: grafana/grafana:12.0.2-ubuntu@sha256:0512d81cdeaaff0e370a9aa66027b465d1f1f04379c3a9c801a905fabbdbc7a5 image: grafana/grafana:11.6.1-ubuntu@sha256:6fc273288470ef499dd3c6b36aeade093170d4f608f864c5dd3a7fabeae77b50
volumes: volumes:
- grafana-data:/var/lib/grafana - grafana-data:/var/lib/grafana

View File

@@ -49,25 +49,24 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:fec42f399876eb6faf9e008570597741c87ff7662a54185593e74b09ce83d177 image: docker.io/valkey/valkey:8-bookworm@sha256:4a9f847af90037d59b34cd4d4ad14c6e055f46540cf4ff757aaafb266060fa28
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
database: database:
container_name: immich_postgres container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:5f6a838e4e44c8e0e019d0ebfe3ee8952b69afc2809b2c25f7b0119641978e91 image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvectors0.2.0
environment: environment:
POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME} POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME} POSTGRES_DB: ${DB_DATABASE_NAME}
POSTGRES_INITDB_ARGS: '--data-checksums' POSTGRES_INITDB_ARGS: '--data-checksums'
# Uncomment the DB_STORAGE_TYPE: 'HDD' var if your database isn't stored on SSDs
# DB_STORAGE_TYPE: 'HDD'
volumes: 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 # 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 - ${DB_DATA_LOCATION}:/var/lib/postgresql/data
shm_size: 128mb # change ssd below to hdd if you are using a hard disk drive or other slow storage
command: postgres -c config_file=/etc/postgresql/postgresql.ssd.conf
restart: always restart: always
volumes: volumes:

View File

@@ -1 +1 @@
22.16.0 22.14.0

View File

@@ -490,7 +490,7 @@ You can also scan the Postgres database file structure for errors:
<details> <details>
<summary>Scan for file structure errors</summary> <summary>Scan for file structure errors</summary>
```bash ```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`: 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> <TabItem value="Linux system" label="Linux system" default>
```bash title='Backup' ```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' ```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)"> <TabItem value="Windows system (PowerShell)" label="Windows system (PowerShell)">
```powershell title='Backup' ```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' ```powershell title='Restore'
@@ -219,10 +219,3 @@ When you turn off the storage template engine, it will leave the assets in `UPLO
Do not touch the files inside these folders under any circumstances except taking a backup. Changing or removing an asset can cause untracked and missing files. Do not touch the files inside these folders under any circumstances except taking a backup. Changing or removing an asset can cause untracked and missing files.
You can think of it as App-Which-Must-Not-Be-Named, the only access to viewing, changing and deleting assets is only through the mobile or browser interface. You can think of it as App-Which-Must-Not-Be-Named, the only access to viewing, changing and deleting assets is only through the mobile or browser interface.
::: :::
## Backup ordering
A backup of Immich should contain both the database and the asset files. When backing these up it's possible for them to get out of sync, potentially resulting in broken assets after you restore.
The best way of dealing with this is to stop the immich-server container while you take a backup. If nothing is changing then the backup will always be in sync.
If stopping the container is not an option, then the recommended order is to back up the database first, and the filesystem second. This way, the worst case scenario is that there are files on the filesystem that the database doesn't know about. If necessary, these can be (re)uploaded manually after a restore. If the backup is done the other way around, with the filesystem first and the database second, it's possible for the restored database to reference files that aren't in the filesystem backup, thus resulting in broken assets.

View File

@@ -93,7 +93,6 @@ The `.well-known/openid-configuration` part of the url is optional and will be a
## Auto Launch ## Auto Launch
When Auto Launch is enabled, the login page will automatically redirect the user to the OAuth authorization url, to login with OAuth. To access the login screen again, use the browser's back button, or navigate directly to `/auth/login?autoLaunch=0`. When Auto Launch is enabled, the login page will automatically redirect the user to the OAuth authorization url, to login with OAuth. To access the login screen again, use the browser's back button, or navigate directly to `/auth/login?autoLaunch=0`.
Auto Launch can also be enabled on a per-request basis by navigating to `/auth/login?authLaunch=1`, this can be useful in situations where Immich is called from e.g. Nextcloud using the _External sites_ app and the _oidc_ app so as to enable users to directly interact with a logged-in instance of Immich.
## Mobile Redirect URI ## Mobile Redirect URI

View File

@@ -10,16 +10,12 @@ Running with a pre-existing Postgres server can unlock powerful administrative f
## Prerequisites ## Prerequisites
You must install `pgvector` (`>= 0.7.0, < 1.0.0`), as it is a prerequisite for `vchord`.
The easiest way to do this on Debian/Ubuntu is 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`).
You must install VectorChord into your instance of Postgres using their [instructions][vchord-install]. After installation, add `shared_preload_libraries = 'vchord.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vchord.so'`. You must install VectorChord into your instance of Postgres using their [instructions][vchord-install]. After installation, add `shared_preload_libraries = 'vchord.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vchord.so'`.
:::note :::note
Immich is known to work with Postgres versions `>= 14, < 18`. Immich is known to work with Postgres versions 14, 15, 16 and 17. Earlier versions are unsupported.
Make sure the installed version of VectorChord is compatible with your version of Immich. The current accepted range for VectorChord is `>= 0.3.0, < 0.5.0`. Make sure the installed version of VectorChord is compatible with your version of Immich. The current accepted range for VectorChord is `>= 0.3.0, < 0.4.0`.
::: :::
## Specifying the connection URL ## Specifying the connection URL
@@ -64,13 +60,7 @@ COMMIT;
### Updating VectorChord ### 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: 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;`.
```
ALTER EXTENSION vchord UPDATE;
REINDEX INDEX face_index;
REINDEX INDEX clip_index;
```
## Migrating to VectorChord ## Migrating to VectorChord
@@ -82,27 +72,16 @@ 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: 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 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`) 2. [Install VectorChord][vchord-install]
3. [Install VectorChord][vchord-install] 3. Add `shared_preload_libraries= 'vchord.so, vectors.so'` to your `postgresql.conf`, making sure to include _both_ `vchord.so` and `vectors.so`. You may include other libraries here as well if needed
4. Add `shared_preload_libraries= 'vchord.so, vectors.so'` to your `postgresql.conf`, making sure to include _both_ `vchord.so` and `vectors.so`. You may include other libraries here as well if needed 4. If Immich does not have superuser permissions, run the SQL command `CREATE EXTENSION vchord CASCADE;` using psql or your choice of database client
5. Restart the Postgres database 5. Start Immich and wait for the logs `Reindexed face_index` and `Reindexed clip_index` to be output
6. If Immich does not have superuser permissions, run the SQL command `CREATE EXTENSION vchord CASCADE;` using psql or your choice of database client 6. Remove the `vectors.so` entry from the `shared_preload_libraries` setting
7. Start Immich and wait for the logs `Reindexed face_index` and `Reindexed clip_index` to be output 7. Uninstall pgvecto.rs (e.g. `apt-get purge vectors-pg14` on Debian-based environments, replacing `pg14` as appropriate)
8. If Immich does not have superuser permissions, run the SQL command `DROP EXTENSION vectors;`
9. Drop the old schema by running `DROP SCHEMA vectors;`
10. Remove the `vectors.so` entry from the `shared_preload_libraries` setting
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 pgvector.s installed at the same time, you can perform the migration with more manual steps:
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 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 ```sql
@@ -135,21 +114,19 @@ ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE vector(512);
5. Start Immich and let it create new indices using VectorChord 5. Start Immich and let it create new indices using VectorChord
</details>
### Migrating from pgvector ### 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 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 2. Follow the Prerequisites to install VectorChord
3. If Immich does not have superuser permissions, run the SQL command `CREATE EXTENSION vchord CASCADE;` 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 4. Start Immich and let it create new indices using VectorChord
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. Note that VectorChord itself uses pgvector types, so you should not uninstall pgvector after following these steps.
### Common errors
#### Permission denied for view
If you get the error `driverError: error: permission denied for view pg_vector_index_stat`, you can fix this by connecting to the Immich database and running `GRANT SELECT ON TABLE pg_vector_index_stat TO <immichdbusername>;`.
[vchord-install]: https://docs.vectorchord.ai/vectorchord/getting-started/installation.html [vchord-install]: https://docs.vectorchord.ai/vectorchord/getting-started/installation.html
[pg-apt]: https://www.postgresql.org/download/linux/#generic

View File

@@ -1,481 +0,0 @@
---
title: Devcontainers
sidebar_position: 3
---
# Development with Dev Containers
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.
[![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/)
[Learn more about Dev Containers](https://docs.github.com/en/codespaces/setting-up-your-project-for-codespaces/adding-a-dev-container-configuration/introduction-to-dev-containers)
## Prerequisites
Before getting started, ensure you have:
- **Docker Desktop** (latest version)
- [Mac](https://docs.docker.com/desktop/install/mac-install/)
- [Windows](https://docs.docker.com/desktop/install/windows-install/) (with WSL2 backend recommended)
- [Linux](https://docs.docker.com/desktop/install/linux-install/)
- **Visual Studio Code** with the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
- **Git** for cloning the repository
- At least **8GB of RAM** (16GB recommended)
- **20GB of free disk space**
:::tip Alternative Development Environments
While this guide focuses on VS Code, you have many options for Dev Container development:
**Local Editors:**
- [IntelliJ IDEA](https://www.jetbrains.com/help/idea/connect-to-devcontainer.html) - Full JetBrains IDE support
- [neovim](https://github.com/jamestthompson3/nvim-remote-containers) - Lightweight terminal-based editor
- [Emacs](https://github.com/emacs-lsp/lsp-docker) - Extensible text editor
- [DevContainer CLI](https://github.com/devcontainers/cli) - Command-line interface
**Cloud-Based Solutions:**
- [GitHub Codespaces](https://github.com/features/codespaces) - Fully integrated with GitHub, excellent devcontainer.json support
- [GitPod](https://www.gitpod.io) - SaaS platform with recent Dev Container support (historically used gitpod.yml)
**Self-Hostable Options:**
- [Coder](https://coder.com) - Enterprise-focused, requires Terraform knowledge, self-managed
- [DevPod](https://devpod.sh) - Client-only tool with excellent devcontainer.json support, works with any provider (local, cloud, or on-premise)
:::
## Dev Container Services
The Dev Container environment consists of the following services:
| Service | Container Name | Description | Ports |
| ---------------- | ------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------- |
| Server & Web | `immich-server` | Runs both API server and web frontend in development mode | 2283 (API)<br/>3000 (Web)<br/>9230 (Workers Debug)<br/>9231 (API Debug) |
| Database | `database` | PostgreSQL database | 5432 |
| Cache | `redis` | Valkey cache server | 6379 |
| Machine Learning | `immich-machine-learning` | Immich ML model inference server | 3003 |
## Getting Started
### Step 1: Clone the Repository
```bash
git clone https://github.com/immich-app/immich.git
cd immich
```
### Step 2: Configure Environment Variables
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 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
# Set temporarily for current session
export UPLOAD_LOCATION=/opt/dev_upload_folder
# Or add to your shell profile for persistence
# (~/.bashrc, ~/.zshrc, ~/.bash_profile, etc.)
echo 'export UPLOAD_LOCATION=/opt/dev_upload_folder' >> ~/.bashrc
source ~/.bashrc
```
:::
### Step 3: Launch the Dev Container
#### Using VS Code UI:
1. Open the cloned repository in VS Code
2. Press `F1` or `Ctrl/Cmd+Shift+P` to open the command palette
3. Type and select "Dev Containers: Rebuild and Reopen in Container"
4. Select "Immich - Backend, Frontend and ML" from the list
5. Wait for the container to build and start (this may take several minutes on first run)
#### Using VS Code Quick Actions:
1. Open the repository in VS Code
2. You should see a popup asking if you want to reopen in a container
3. Click "Reopen in Container"
#### Using Command Line:
```bash
# Using the DevContainer CLI
devcontainer up --workspace-folder .
```
## Environment Variable Details
### How Dev Containers Handle Environment Variables
Unlike the Immich developer setup based on Docker Compose which uses `.env` files, Immich Dev Containers read environment variables from your shell environment. This is configured in `.devcontainer/devcontainer.json`:
```json
"remoteEnv": {
"UPLOAD_LOCATION": "${localEnv:UPLOAD_LOCATION:./Library}",
"DB_PASSWORD": "${localEnv:DB_PASSWORD:postgres}",
"DB_USERNAME": "${localEnv:DB_USERNAME:postgres}",
"DB_DATABASE_NAME": "${localEnv:DB_DATABASE_NAME:immich}"
}
```
The `${localEnv:VARIABLE:default}` syntax reads from your shell environment with optional defaults.
### Upload Location Path Resolution
The `UPLOAD_LOCATION` environment variable controls where files are stored:
**Default:** `./Library` (relative to the `docker` directory)
**Resolved to:** `<immich-root>/docker/Library`
**Bind Mounts Created:**
```yaml
# From .devcontainer/server/container-compose-overrides.yml
- ${UPLOAD_LOCATION-./Library}/photos:/workspaces/immich/server/upload
- ${UPLOAD_LOCATION-./Library}/postgres:/var/lib/postgresql/data
```
### Database Configuration
These variables have sensible defaults (for development) but can be customized:
| Variable | Default | Description |
| ------------------ | ---------- | ------------------- |
| `DB_PASSWORD` | `postgres` | PostgreSQL password |
| `DB_USERNAME` | `postgres` | PostgreSQL username |
| `DB_DATABASE_NAME` | `immich` | Database name |
### Setting Environment Variables
Add these to your shell profile (`~/.bashrc`, `~/.zshrc`, `~/.bash_profile`, etc.):
```bash
# Required
export UPLOAD_LOCATION=./Library # or absolute path
# Optional (only if using non-default values)
export DB_PASSWORD=your_password
export DB_USERNAME=your_username
export DB_DATABASE_NAME=your_database
```
Remember to reload your shell configuration:
```bash
source ~/.bashrc # or ~/.zshrc, etc.
```
## Git Configuration
### SSH Keys and Authentication
To use your SSH keys for GitHub access inside the Dev Container:
1. **Start SSH Agent** on your host machine:
```bash
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_rsa # or your key path
```
2. **VS Code automatically forwards your SSH agent** to the container
For detailed instructions, see the [VS Code guide on sharing Git credentials](https://code.visualstudio.com/remote/advancedcontainers/sharing-git-credentials).
### Commit Signing
To use your SSH key for commit signing, see the [GitHub guide on SSH commit signing](https://docs.github.com/en/authentication/managing-commit-signature-verification/telling-git-about-your-signing-key#telling-git-about-your-ssh-key).
## Development Workflow
### Automatic Setup
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
3. **Configures port forwarding**:
- Web UI: http://localhost:3000 (opens automatically)
- API: http://localhost:2283
- Debug ports: 9230 (workers), 9231 (API)
:::info
The Dev Container setup replaces the `make dev` command from the traditional setup. All services start automatically when you open the container.
:::
### Accessing Services
Once running, you can access:
| Service | URL | Description |
| -------- | --------------------- | ---------------------------------------------------------------------------------------------- |
| Web UI | http://localhost:3000 | Main web interface |
| API | http://localhost:2283 | REST API endpoints (Not used directly, web UI will expose this over http://localhost:3000/api) |
| Database | localhost:5432 | PostgreSQL (username: `postgres`) (Not used directly) |
### Connecting Mobile Apps
To connect the mobile app to your Dev Container:
1. Find your machine's IP address
2. In the mobile app, use: `http://YOUR_IP:3000/api`
3. Ensure your firewall allows connections on port 2283
### Making Code Changes
- **Server code** (`/server`): Changes trigger automatic restart
- **Web code** (`/web`): Changes trigger hot module replacement
- **Database migrations**: Run `npm run sync:sql` in the server directory
- **API changes**: Regenerate TypeScript SDK with `make open-api`
## Testing
### Running Tests
The Dev Container supports multiple ways to run tests:
#### Using Make Commands (Recommended)
```bash
# Run tests for specific components
make test-server # Server unit tests
make test-web # Web unit tests
make test-e2e # End-to-end tests
make test-cli # CLI tests
# Run all tests
make test-all # Runs tests for all components
# Medium tests (integration tests)
make test-medium-dev # End-to-end tests
```
#### Using NPM Directly
```bash
# Server tests
cd /workspaces/immich/server
npm test # Run all tests
npm run test:watch # Watch mode
npm run test:cov # Coverage report
# Web tests
cd /workspaces/immich/web
npm test # Run all tests
npm run test:watch # Watch mode
# E2E tests
cd /workspaces/immich/e2e
npm run test # Run API tests
npm run test:web # Run web UI tests
```
### Code Quality Commands
```bash
# Linting
make lint-server # Lint server code
make lint-web # Lint web code
make lint-all # Lint all components
# Formatting
make format-server # Format server code
make format-web # Format web code
make format-all # Format all code
# Type checking
make check-server # Type check server
make check-web # Type check web
make check-all # Check all components
# Complete hygiene check
make hygiene-all # Runs lint, format, check, SQL sync, and audit
```
### Additional Make Commands
```bash
# Build commands
make build-server # Build server
make build-web # Build web app
make build-all # Build everything
# API generation
make open-api # Generate OpenAPI specs
make open-api-typescript # Generate TypeScript SDK
make open-api-dart # Generate Dart SDK
# Database
make sql # Sync database schema
# Dependencies
make install-server # Install server dependencies
make install-web # Install web dependencies
make install-all # Install all dependencies
```
### Debugging
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
3. **Web Debugging**:
- Use browser DevTools
- VS Code debugger for Chrome/Edge extensions supported
## Troubleshooting
### Common Issues
#### Permission Errors
**Problem**: `EACCES` or permission denied errors
**Solution**:
- The Dev Container runs as the `node` user (UID 1000)
- If your host UID differs, you may see permission issues
- Try rebuilding the container: "Dev Containers: Rebuild Container"
#### Container Won't Start
**Problem**: Dev Container fails to start or build
**Solution**:
1. Check Docker is running: `docker ps`
2. Clean Docker resources: `docker system prune -a`
3. Check available disk space
4. Review Docker Desktop resource limits
#### Port Already in Use
**Problem**: "Port 3000/2283 is already in use"
**Solution**:
1. Check for conflicting services: `lsof -i :3000` (macOS/Linux)
2. Stop conflicting services or change port mappings
3. Restart Docker Desktop
#### Upload Location Not Set
**Problem**: Errors about missing UPLOAD_LOCATION
**Solution**:
1. Set the environment variable: `export UPLOAD_LOCATION=./Library`
2. Add to your shell profile for persistence
3. Restart your terminal and VS Code
#### Database Connection Failed
**Problem**: Cannot connect to PostgreSQL
**Solution**:
1. Ensure all containers are running: `docker ps`
2. Check logs: "Dev Containers: Show Container Log"
3. Verify database credentials match environment variables
### Getting Help
If you encounter issues:
1. Check container logs: View → Output → Select "Dev Containers"
2. Rebuild without cache: "Dev Containers: Rebuild Container Without Cache"
3. Review [common Docker issues](https://docs.docker.com/desktop/troubleshoot/)
4. Ask in [Discord](https://discord.immich.app) `#help-desk-support` channel
## Mobile Development
While the Dev Container focuses on server and web development, you can connect mobile apps for testing:
### Connecting iOS/Android Apps
1. **Ensure API is accessible**:
```bash
# Find your machine's IP
# macOS
ipconfig getifaddr en0
# Linux
hostname -I
# Windows (in WSL2)
ip addr show eth0
```
2. **Configure mobile app**:
- Server URL: `http://YOUR_IP:2283/api`
- Ensure firewall allows port 2283
3. **For full mobile development**, see the [mobile development guide](/docs/developer/setup) which covers:
- Flutter setup
- Running on simulators/devices
- Mobile-specific debugging
## Advanced Configuration
### Custom VS Code Extensions
Add extensions to `.devcontainer/devcontainer.json`:
```json
"customizations": {
"vscode": {
"extensions": [
"your.extension-id"
]
}
}
```
### Additional Services
To add services (e.g., Redis Commander), modify:
1. `/docker/docker-compose.dev.yml` - Add service definition
2. `/.devcontainer/server/container-compose-overrides.yml` - Add overrides if needed
### Resource Limits
Adjust Docker Desktop resources:
- **macOS/Windows**: Docker Desktop → Settings → Resources
- **Linux**: Modify Docker daemon configuration
Recommended minimums:
- CPU: 4 cores
- Memory: 8GB
- Disk: 20GB
## Next Steps
- Read the [architecture overview](/docs/developer/architecture)
- Learn about [database migrations](/docs/developer/database-migrations)
- Explore [API documentation](/docs/api)
- Join `#immich` on [Discord](https://discord.immich.app)

View File

@@ -75,12 +75,11 @@ npm run dev
To see local changes to `@immich/ui` in Immich, do the following: To see local changes to `@immich/ui` in Immich, do the following:
1. Install `@immich/ui` as a sibling to `immich/`, for example `/home/user/immich` and `/home/user/ui` 1. Install `@immich/ui` as a sibling to `immich/`, for example `/home/user/immich` and `/home/user/ui`
2. Build the `@immich/ui` project via `npm run build` 1. Build the `@immich/ui` project via `npm run build`
3. Uncomment the corresponding volume in web service of the `docker/docker-compose.dev.yaml` file (`../../ui:/usr/ui`) 1. Uncomment the corresponding volume in web service of the `docker/docker-compose.dev.yaml` file (`../../ui:/usr/ui`)
4. Uncomment the corresponding alias in the `web/vite.config.js` file (`'@immich/ui': path.resolve(\_\_dirname, '../../ui')`) 1. Uncomment the corresponding alias in the `web/vite.config.js` file (`'@immich/ui': path.resolve(\_\_dirname, '../../ui')`)
5. Uncomment the import statement in `web/src/app.css` file `@import '/usr/ui/dist/theme/default.css';` and comment out `@import '@immich/ui/theme/default.css';` 1. Start up the stack via `make dev`
6. Start up the stack via `make dev` 1. After making changes in `@immich/ui`, rebuild it (`npm run build`)
7. After making changes in `@immich/ui`, rebuild it (`npm run build`)
### Mobile app ### Mobile app
@@ -115,72 +114,32 @@ Note: Activating the license is not required.
### VSCode ### VSCode
Install `Flutter`, `DCM`, `Prettier`, `ESLint` and `Svelte` extensions. These extensions are listed in the `extensions.json` file under `.vscode/` and should appear as workspace recommendations. Install `Flutter`, `DCM`, `Prettier`, `ESLint` and `Svelte` extensions.
Here are the settings we use, they should be active as workspace settings (`settings.json`): in User `settings.json` (`cmd + shift + p` and search for `Open User Settings JSON`) add the following:
```json title="settings.json" ```json title="settings.json"
{ {
"[css]": { "editor.formatOnSave": true,
"[javascript][typescript][css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true, "editor.tabSize": 2,
"editor.formatOnSave": true
},
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.tabSize": 2 "editor.tabSize": 2
}, },
"svelte.enable-ts-plugin": true,
"eslint.validate": ["javascript", "svelte"],
"[dart]": { "[dart]": {
"editor.defaultFormatter": "Dart-Code.dart-code",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.selectionHighlight": false, "editor.selectionHighlight": false,
"editor.suggest.snippetsPreventQuickSuggestions": false, "editor.suggest.snippetsPreventQuickSuggestions": false,
"editor.suggestSelection": "first", "editor.suggestSelection": "first",
"editor.tabCompletion": "onlySnippets", "editor.tabCompletion": "onlySnippets",
"editor.wordBasedSuggestions": "off" "editor.wordBasedSuggestions": "off",
}, "editor.defaultFormatter": "Dart-Code.dart-code"
"[javascript]": { }
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[svelte]": {
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[typescript]": {
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"cSpell.words": ["immich"],
"editor.formatOnSave": true,
"eslint.validate": ["javascript", "svelte"],
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
"*.ts": "${capture}.spec.ts,${capture}.mock.ts"
},
"svelte.enable-ts-plugin": true,
"typescript.preferences.importModuleSpecifier": "non-relative"
} }
``` ```

View File

@@ -1,19 +0,0 @@
# Chromecast support
Immich supports the Google's Cast protocol so that photos and videos can be cast to devices such as a Chromecast and a Nest Hub. This feature is considered experimental and has several important limitations listed below. Currently, this feature is only supported by the web client, support on Android and iOS is planned for the future.
## Enable Google Cast Support
Google Cast support is disabled by default. The web UI uses Google-provided scripts and must retreive them from Google servers when the page loads. This is a privacy concern for some and is thus opt-in.
You can enable Google Cast support through `Account Settings > Features > Cast > Google Cast`
<img src={require('./img/gcast-enable.webp').default} width="70%" title='Enable Google Cast Support' />
## Limitations
To use casting with Immich, there are a few prerequisites:
1. Your instance must be accessed via an HTTPS connection in order for the casting menu to show.
2. Your instance must be publicly accessible via HTTPS and a DNS record for the server must be accessible via Google's DNS servers (`8.8.8.8` and `8.8.4.4`)
3. Videos must be in a format that is compatible with Google Cast. For more info, check out [Google's documentation](https://developers.google.com/cast/docs/media)

View File

@@ -90,22 +90,19 @@ Usage: immich upload [paths...] [options]
Upload assets Upload assets
Arguments: Arguments:
paths One or more paths to assets to be uploaded paths One or more paths to assets to be uploaded
Options: Options:
-r, --recursive Recursive (default: false, env: IMMICH_RECURSIVE) -r, --recursive Recursive (default: false, env: IMMICH_RECURSIVE)
-i, --ignore <pattern> Pattern to ignore (env: IMMICH_IGNORE_PATHS) -i, --ignore [paths...] Paths to ignore (default: [], env: IMMICH_IGNORE_PATHS)
-h, --skip-hash Don't hash files before upload (default: false, env: IMMICH_SKIP_HASH) -h, --skip-hash Don't hash files before upload (default: false, env: IMMICH_SKIP_HASH)
-H, --include-hidden Include hidden folders (default: false, env: IMMICH_INCLUDE_HIDDEN) -H, --include-hidden Include hidden folders (default: false, env: IMMICH_INCLUDE_HIDDEN)
-a, --album Automatically create albums based on folder name (default: false, env: IMMICH_AUTO_CREATE_ALBUM) -a, --album Automatically create albums based on folder name (default: false, env: IMMICH_AUTO_CREATE_ALBUM)
-A, --album-name <name> Add all assets to specified album (env: IMMICH_ALBUM_NAME) -A, --album-name <name> Add all assets to specified album (env: IMMICH_ALBUM_NAME)
-n, --dry-run Don't perform any actions, just show what will be done (default: false, env: IMMICH_DRY_RUN) -n, --dry-run Don't perform any actions, just show what will be done (default: false, env: IMMICH_DRY_RUN)
-c, --concurrency <number> Number of assets to upload at the same time (default: 4, env: IMMICH_UPLOAD_CONCURRENCY) -c, --concurrency <number> Number of assets to upload at the same time (default: 4, env: IMMICH_UPLOAD_CONCURRENCY)
-j, --json-output Output detailed information in json format (default: false, env: IMMICH_JSON_OUTPUT) --delete Delete local assets after upload (env: IMMICH_DELETE_ASSETS)
--delete Delete local assets after upload (env: IMMICH_DELETE_ASSETS) --help display help for command
--no-progress Hide progress bars (env: IMMICH_PROGRESS_BAR)
--watch Watch for changes and upload automatically (default: false, env: IMMICH_WATCH_CHANGES)
--help display help for command
``` ```
</details> </details>
@@ -175,16 +172,6 @@ By default, hidden files are skipped. If you want to include hidden files, use t
immich upload --include-hidden --recursive directory/ immich upload --include-hidden --recursive directory/
``` ```
You can use the `--json-output` option to get a json printed which includes
three keys: `newFiles`, `duplicates` and `newAssets`. Due to some logging
output you will need to strip the first three lines of output to get the json.
For example to get a list of files that would be uploaded for further
processing:
```bash
immich upload --dry-run . | tail -n +4 | jq .newFiles[]
```
### Obtain the API Key ### Obtain the API Key
The API key can be obtained in the user setting panel on the web interface. The API key can be obtained in the user setting panel on the web interface.

View File

@@ -121,6 +121,6 @@ Once this is done, you can continue to step 3 of "Basic Setup".
[hw-file]: https://github.com/immich-app/immich/releases/latest/download/hwaccel.transcoding.yml [hw-file]: https://github.com/immich-app/immich/releases/latest/download/hwaccel.transcoding.yml
[nvct]: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html [nvct]: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html
[jellyfin-lp]: https://jellyfin.org/docs/general/post-install/transcoding/hardware-acceleration/intel#low-power-encoding [jellyfin-lp]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#configure-and-verify-lp-mode-on-linux
[jellyfin-kernel-bug]: https://jellyfin.org/docs/general/post-install/transcoding/hardware-acceleration/intel#known-issues-and-limitations-on-linux [jellyfin-kernel-bug]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#known-issues-and-limitations
[libmali-rockchip]: https://github.com/tsukumijima/libmali-rockchip/releases [libmali-rockchip]: https://github.com/tsukumijima/libmali-rockchip/releases

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -112,15 +112,12 @@ _Remember to run `docker compose up -d` to register the changes. Make sure you c
These actions must be performed by the Immich administrator. These actions must be performed by the Immich administrator.
- Click on your avatar on the upper right corner - Click on Administration -> Libraries
- Click on Administration -> External Libraries - Click on Create External Library
- Click on Create an external library…
- Select which user owns the library, this can not be changed later - Select which user owns the library, this can not be changed later
- Enter `/mnt/media/christmas-trip` then click Add - Enter `/mnt/media/christmas-trip` then click Add
- Click on Save - Click on Save
- Click the drop-down menu on the newly created library - Click the drop-down menu on the newly created library
- Click on Scan
- Click the drop-down menu on the newly created library
- Click on Rename Library and rename it to "Christmas Trip" - Click on Rename Library and rename it to "Christmas Trip"
NOTE: We have to use the `/mnt/media/christmas-trip` path and not the `/mnt/nas/christmas-trip` path since all paths have to be what the Docker containers see. NOTE: We have to use the `/mnt/media/christmas-trip` path and not the `/mnt/nas/christmas-trip` path since all paths have to be what the Docker containers see.

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 ### Local
# Backup Immich database # 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: # 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 ### Append to local Borg repository
borg create "$BACKUP_PATH/immich-borg::{now}" "$UPLOAD_LOCATION" --exclude "$UPLOAD_LOCATION"/thumbs/ --exclude "$UPLOAD_LOCATION"/encoded-video/ borg create "$BACKUP_PATH/immich-borg::{now}" "$UPLOAD_LOCATION" --exclude "$UPLOAD_LOCATION"/thumbs/ --exclude "$UPLOAD_LOCATION"/encoded-video/

View File

@@ -123,7 +123,7 @@ The default configuration looks like this:
"buttonText": "Login with OAuth", "buttonText": "Login with OAuth",
"clientId": "", "clientId": "",
"clientSecret": "", "clientSecret": "",
"defaultStorageQuota": null, "defaultStorageQuota": 0,
"enabled": false, "enabled": false,
"issuerUrl": "", "issuerUrl": "",
"mobileOverrideEnabled": false, "mobileOverrideEnabled": false,

View File

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

View File

@@ -25,7 +25,7 @@ When you're all done, you should have the following:
- `./docker/immich-app/postgres` - `./docker/immich-app/postgres`
- `./docker/immich-app/library` - `./docker/immich-app/library`
Download [`docker-compose.yml`](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml) and [`example.env`](https://github.com/immich-app/immich/releases/latest/download/example.env) to your computer. Upload the files to the `./docker/immich-app` directory, and rename `example.env` to `.env`. Download [`docker-compose.yml`](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml) and [`example.env`](https://github.com/immich-app/immich/releases/latest/download/example.env) to your computer. Upload the files to the `./docker/immich-app` directory.
## Step 2 - Populate the .env file with custom values ## Step 2 - Populate the .env file with custom values

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/). 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. Immich can easily be installed on TrueNAS Community Edition via the **Community** train application.

View File

@@ -57,6 +57,6 @@
"node": ">=20" "node": ">=20"
}, },
"volta": { "volta": {
"node": "22.16.0" "node": "22.14.0"
} }
} }

View File

@@ -44,6 +44,11 @@ const projects: CommunityProjectProps[] = [
'Lightroom plugin to publish, export photos from Lightroom to Immich. Import from Immich to Lightroom is also supported.', 'Lightroom plugin to publish, export photos from Lightroom to Immich. Import from Immich to Lightroom is also supported.',
url: 'https://blog.fokuspunk.de/lrc-immich-plugin/', url: 'https://blog.fokuspunk.de/lrc-immich-plugin/',
}, },
{
title: 'Immich Duplicate Finder',
description: 'Webapp that uses machine learning to identify near-duplicate images.',
url: 'https://github.com/vale46n1/immich_duplicate_finder',
},
{ {
title: 'Immich-Tiktok-Remover', title: 'Immich-Tiktok-Remover',
description: 'Script to search for and remove TikTok videos from your Immich library.', description: 'Script to search for and remove TikTok videos from your Immich library.',

View File

@@ -13,9 +13,6 @@ import {
mdiTrashCan, mdiTrashCan,
mdiWeb, mdiWeb,
mdiWrap, mdiWrap,
mdiCloudKeyOutline,
mdiRegex,
mdiCodeJson,
} from '@mdi/js'; } from '@mdi/js';
import Layout from '@theme/Layout'; import Layout from '@theme/Layout';
import React from 'react'; import React from 'react';
@@ -26,30 +23,6 @@ const withLanguage = (date: Date) => (language: string) => date.toLocaleDateStri
type Item = Omit<TimelineItem, 'done' | 'getDateLabel'> & { date: Date }; type Item = Omit<TimelineItem, 'done' | 'getDateLabel'> & { date: Date };
const items: Item[] = [ const items: Item[] = [
{
icon: mdiRegex,
iconColor: 'purple',
title: 'Zitadel Actions are cursed',
description:
"Zitadel is cursed because its custom scripting feature is executed with a JS engine that doesn't support regex named capture groups.",
link: {
url: 'https://github.com/dop251/goja',
text: 'Go JS engine',
},
date: new Date(2025, 5, 4),
},
{
icon: mdiCloudKeyOutline,
iconColor: '#0078d4',
title: 'Entra is cursed',
description:
"Microsoft Entra supports PKCE, but doesn't include it in its OpenID discovery document. This leads to clients thinking PKCE isn't available.",
link: {
url: 'https://github.com/immich-app/immich/pull/18725',
text: '#18725',
},
date: new Date(2025, 4, 30),
},
{ {
icon: mdiCrop, icon: mdiCrop,
iconColor: 'tomato', iconColor: 'tomato',
@@ -60,18 +33,7 @@ const items: Item[] = [
url: 'https://github.com/immich-app/immich/pull/17974', url: 'https://github.com/immich-app/immich/pull/17974',
text: '#17974', text: '#17974',
}, },
date: new Date(2025, 4, 5), date: new Date(2025, 5, 5),
},
{
icon: mdiCodeJson,
iconColor: 'yellow',
title: 'YAML whitespace is cursed',
description: 'YAML whitespaces are often handled in unintuitive ways.',
link: {
url: 'https://github.com/immich-app/immich/pull/17309',
text: '#17309',
},
date: new Date(2025, 3, 1),
}, },
{ {
icon: mdiMicrosoftWindows, icon: mdiMicrosoftWindows,

View File

@@ -78,14 +78,12 @@ import {
mdiLinkEdit, mdiLinkEdit,
mdiTagFaces, mdiTagFaces,
mdiMovieOpenPlayOutline, mdiMovieOpenPlayOutline,
mdiCast,
} from '@mdi/js'; } from '@mdi/js';
import Layout from '@theme/Layout'; import Layout from '@theme/Layout';
import React from 'react'; import React from 'react';
import { Item, Timeline } from '../components/timeline'; import { Item, Timeline } from '../components/timeline';
const releases = { const releases = {
'v1.133.0': new Date(2025, 4, 21),
'v1.130.0': new Date(2025, 2, 25), 'v1.130.0': new Date(2025, 2, 25),
'v1.127.0': new Date(2025, 1, 26), 'v1.127.0': new Date(2025, 1, 26),
'v1.122.0': new Date(2024, 11, 5), 'v1.122.0': new Date(2024, 11, 5),
@@ -218,6 +216,14 @@ const roadmap: Item[] = [
iconColor: 'indianred', iconColor: 'indianred',
title: 'Stable release', title: 'Stable release',
description: 'Immich goes stable', description: 'Immich goes stable',
getDateLabel: () => 'Planned for early 2025',
},
{
done: false,
icon: mdiLockOutline,
iconColor: 'sandybrown',
title: 'Private/locked photos',
description: 'Private assets with extra protections',
getDateLabel: () => 'Planned for 2025', getDateLabel: () => 'Planned for 2025',
}, },
{ {
@@ -239,20 +245,6 @@ const roadmap: Item[] = [
]; ];
const milestones: Item[] = [ const milestones: Item[] = [
withRelease({
icon: mdiCast,
iconColor: 'aqua',
title: 'Google Cast (web)',
description: 'Cast assets to Google Cast/Chromecast compatible devices',
release: 'v1.133.0',
}),
withRelease({
icon: mdiLockOutline,
iconColor: 'sandybrown',
title: 'Private/locked photos',
description: 'Private assets with extra protections',
release: 'v1.133.0',
}),
withRelease({ withRelease({
icon: mdiFolderMultiple, icon: mdiFolderMultiple,
iconColor: 'brown', iconColor: 'brown',

View File

@@ -1,32 +1,4 @@
[ [
{
"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"
},
{
"label": "v1.133.1",
"url": "https://v1.133.1.archive.immich.app"
},
{
"label": "v1.133.0",
"url": "https://v1.133.0.archive.immich.app"
},
{ {
"label": "v1.132.3", "label": "v1.132.3",
"url": "https://v1.132.3.archive.immich.app" "url": "https://v1.132.3.archive.immich.app"

View File

@@ -1 +1 @@
22.16.0 22.14.0

View File

@@ -28,10 +28,8 @@ services:
extra_hosts: extra_hosts:
- 'auth-server:host-gateway' - 'auth-server:host-gateway'
depends_on: depends_on:
redis: - redis
condition: service_started - database
database:
condition: service_healthy
ports: ports:
- 2285:2285 - 2285:2285
@@ -39,17 +37,11 @@ services:
image: redis:6.2-alpine@sha256:3211c33a618c457e5d241922c975dbc4f446d0bdb2dc75694f5573ef8e2d01fa image: redis:6.2-alpine@sha256:3211c33a618c457e5d241922c975dbc4f446d0bdb2dc75694f5573ef8e2d01fa
database: database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:3aef84a0a4fabbda17ef115c3019ba0c914ec73e9f6e59203674322d858b8eea image: tensorchord/vchord-postgres:pg14-v0.3.0
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf command: -c fsync=off -c shared_preload_libraries=vchord.so
environment: environment:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_DB: immich POSTGRES_DB: immich
ports: ports:
- 5435:5432 - 5435:5432
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d immich']
interval: 1s
timeout: 5s
retries: 30
start_period: 10s

2569
e2e/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.135.3", "version": "1.132.3",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -25,26 +25,25 @@
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.15.32", "@types/node": "^22.14.1",
"@types/oidc-provider": "^9.0.0", "@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.15.1", "@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"eslint": "^9.14.0", "eslint": "^9.14.0",
"eslint-config-prettier": "^10.0.0", "eslint-config-prettier": "^10.0.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^59.0.0", "eslint-plugin-unicorn": "^57.0.0",
"exiftool-vendored": "^28.3.1", "exiftool-vendored": "^28.3.1",
"globals": "^16.0.0", "globals": "^16.0.0",
"jose": "^5.6.3", "jose": "^5.6.3",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"oidc-provider": "^9.0.0", "oidc-provider": "^8.5.1",
"pg": "^8.11.3", "pg": "^8.11.3",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.33.5",
"socket.io-client": "^4.7.4", "socket.io-client": "^4.7.4",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
@@ -53,6 +52,6 @@
"vitest": "^3.0.0" "vitest": "^3.0.0"
}, },
"volta": { "volta": {
"node": "22.16.0" "node": "22.14.0"
} }
} }

View File

@@ -428,15 +428,6 @@ describe('/albums', () => {
order: AssetOrder.Desc, order: AssetOrder.Desc,
}); });
}); });
it('should not be able to share album with owner', async () => {
const { status, body } = await request(app)
.post('/albums')
.send({ albumName: 'New album', albumUsers: [{ role: AlbumUserRole.Editor, userId: user1.userId }] })
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Cannot share album with owner'));
});
}); });
describe('PUT /albums/:id/assets', () => { describe('PUT /albums/:id/assets', () => {

View File

@@ -143,7 +143,7 @@ describe('/api-keys', () => {
const { apiKey } = await create(user.accessToken, [Permission.All]); const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/api-keys/${apiKey.id}`) .put(`/api-keys/${apiKey.id}`)
.send({ name: 'new name', permissions: [Permission.All] }) .send({ name: 'new name' })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('API Key not found')); expect(body).toEqual(errorDto.badRequest('API Key not found'));
@@ -153,16 +153,13 @@ describe('/api-keys', () => {
const { apiKey } = await create(user.accessToken, [Permission.All]); const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/api-keys/${apiKey.id}`) .put(`/api-keys/${apiKey.id}`)
.send({ .send({ name: 'new name' })
name: 'new name',
permissions: [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate],
})
.set('Authorization', `Bearer ${user.accessToken}`); .set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual({ expect(body).toEqual({
id: expect.any(String), id: expect.any(String),
name: 'new name', name: 'new name',
permissions: [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate], permissions: [Permission.All],
createdAt: expect.any(String), createdAt: expect.any(String),
updatedAt: expect.any(String), updatedAt: expect.any(String),
}); });

View File

@@ -15,7 +15,6 @@ import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises'; import { readFile, writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path'; import { basename, join } from 'node:path';
import sharp from 'sharp';
import { Socket } from 'socket.io-client'; import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures'; import { createUserDto, uuidDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators'; import { makeRandomImage } from 'src/generators';
@@ -41,40 +40,6 @@ const today = DateTime.fromObject({
}) as DateTime<true>; }) as DateTime<true>;
const yesterday = today.minus({ days: 1 }); 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', () => { describe('/asset', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let websocket: Socket; let websocket: Socket;
@@ -237,6 +202,7 @@ describe('/asset', () => {
{ {
name: 'Marie Curie', name: 'Marie Curie',
birthDate: null, birthDate: null,
thumbnailPath: '',
isHidden: false, isHidden: false,
faces: [ faces: [
{ {
@@ -253,6 +219,7 @@ describe('/asset', () => {
{ {
name: 'Pierre Curie', name: 'Pierre Curie',
birthDate: null, birthDate: null,
thumbnailPath: '',
isHidden: false, isHidden: false,
faces: [ faces: [
{ {
@@ -1225,411 +1192,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', () => { describe('POST /assets/exist', () => {
it('ignores invalid deviceAssetIds', async () => { it('ignores invalid deviceAssetIds', async () => {
const response = await utils.checkExistingAssets(user1.accessToken, { 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, createMemory,
getMemory, getMemory,
} from '@immich/sdk'; } from '@immich/sdk';
import { createUserDto } from 'src/fixtures'; import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils'; import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
@@ -17,6 +17,7 @@ describe('/memories', () => {
let user: LoginResponseDto; let user: LoginResponseDto;
let adminAsset: AssetMediaResponseDto; let adminAsset: AssetMediaResponseDto;
let userAsset1: AssetMediaResponseDto; let userAsset1: AssetMediaResponseDto;
let userAsset2: AssetMediaResponseDto;
let userMemory: MemoryResponseDto; let userMemory: MemoryResponseDto;
beforeAll(async () => { beforeAll(async () => {
@@ -24,9 +25,10 @@ describe('/memories', () => {
admin = await utils.adminSetup(); admin = await utils.adminSetup();
user = await utils.userSetup(admin.accessToken, createUserDto.user1); 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(admin.accessToken),
utils.createAsset(user.accessToken), utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken),
]); ]);
userMemory = await createMemory( 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', () => { 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 () => { it('should require access', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get(`/memories/${userMemory.id}`) .get(`/memories/${userMemory.id}`)
@@ -60,6 +176,22 @@ describe('/memories', () => {
}); });
describe('PUT /memories/:id', () => { 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 () => { it('should require access', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/memories/${userMemory.id}`) .put(`/memories/${userMemory.id}`)
@@ -86,6 +218,23 @@ describe('/memories', () => {
}); });
describe('PUT /memories/:id/assets', () => { 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 () => { it('should require access', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/memories/${userMemory.id}/assets`) .put(`/memories/${userMemory.id}/assets`)
@@ -95,6 +244,15 @@ describe('/memories', () => {
expect(body).toEqual(errorDto.noPermission); 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 () => { it('should require asset access', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/memories/${userMemory.id}/assets`) .put(`/memories/${userMemory.id}/assets`)
@@ -121,6 +279,23 @@ describe('/memories', () => {
}); });
describe('DELETE /memories/:id/assets', () => { 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 () => { it('should require access', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}/assets`) .delete(`/memories/${userMemory.id}/assets`)
@@ -130,6 +305,15 @@ describe('/memories', () => {
expect(body).toEqual(errorDto.noPermission); 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 () => { it('should only remove assets in the memory', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}/assets`) .delete(`/memories/${userMemory.id}/assets`)
@@ -156,6 +340,21 @@ describe('/memories', () => {
}); });
describe('DELETE /memories/:id', () => { 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 () => { it('should require access', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}`) .delete(`/memories/${userMemory.id}`)

View File

@@ -5,38 +5,33 @@ import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
const invalidBirthday = [
{
birthDate: 'false',
response: ['birthDate must be a string in the format yyyy-MM-dd', 'Birth date cannot be in the future'],
},
{
birthDate: '123567',
response: ['birthDate must be a string in the format yyyy-MM-dd', 'Birth date cannot be in the future'],
},
{
birthDate: 123_567,
response: ['birthDate must be a string in the format yyyy-MM-dd', 'Birth date cannot be in the future'],
},
{ birthDate: '9999-01-01', response: ['Birth date cannot be in the future'] },
];
describe('/people', () => { describe('/people', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let visiblePerson: PersonResponseDto; let visiblePerson: PersonResponseDto;
let hiddenPerson: PersonResponseDto; let hiddenPerson: PersonResponseDto;
let multipleAssetsPerson: PersonResponseDto; let multipleAssetsPerson: PersonResponseDto;
let nameAlicePerson: PersonResponseDto;
let nameBobPerson: PersonResponseDto;
let nameCharliePerson: PersonResponseDto;
let nameNullPerson4Assets: PersonResponseDto;
let nameNullPerson3Assets: PersonResponseDto;
let nameNullPerson1Asset: PersonResponseDto;
let nameBillPersonFavourite: PersonResponseDto;
let nameFreddyPersonFavourite: PersonResponseDto;
beforeAll(async () => { beforeAll(async () => {
await utils.resetDatabase(); await utils.resetDatabase();
admin = await utils.adminSetup(); admin = await utils.adminSetup();
[ [visiblePerson, hiddenPerson, multipleAssetsPerson] = await Promise.all([
visiblePerson,
hiddenPerson,
multipleAssetsPerson,
nameCharliePerson,
nameBobPerson,
nameAlicePerson,
nameNullPerson4Assets,
nameNullPerson3Assets,
nameNullPerson1Asset,
nameBillPersonFavourite,
nameFreddyPersonFavourite,
] = await Promise.all([
utils.createPerson(admin.accessToken, { utils.createPerson(admin.accessToken, {
name: 'visible_person', name: 'visible_person',
}), }),
@@ -47,39 +42,10 @@ describe('/people', () => {
utils.createPerson(admin.accessToken, { utils.createPerson(admin.accessToken, {
name: 'multiple_assets_person', name: 'multiple_assets_person',
}), }),
// --- Setup for the specific sorting test ---
utils.createPerson(admin.accessToken, {
name: 'Charlie',
}),
utils.createPerson(admin.accessToken, {
name: 'Bob',
}),
utils.createPerson(admin.accessToken, {
name: 'Alice',
}),
utils.createPerson(admin.accessToken, {
name: '',
}),
utils.createPerson(admin.accessToken, {
name: '',
}),
utils.createPerson(admin.accessToken, {
name: '',
}),
utils.createPerson(admin.accessToken, {
name: 'Bill',
isFavorite: true,
}),
utils.createPerson(admin.accessToken, {
name: 'Freddy',
isFavorite: true,
}),
]); ]);
const asset1 = await utils.createAsset(admin.accessToken); const asset1 = await utils.createAsset(admin.accessToken);
const asset2 = 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([ await Promise.all([
utils.createFace({ assetId: asset1.id, personId: visiblePerson.id }), utils.createFace({ assetId: asset1.id, personId: visiblePerson.id }),
@@ -87,32 +53,19 @@ describe('/people', () => {
utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }), utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }), utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }), utils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset3.id, personId: multipleAssetsPerson.id }), // 4 assets
// Named persons
utils.createFace({ assetId: asset1.id, personId: nameCharliePerson.id }), // 1 asset
utils.createFace({ assetId: asset1.id, personId: nameBobPerson.id }),
utils.createFace({ assetId: asset2.id, personId: nameBobPerson.id }), // 2 assets
utils.createFace({ assetId: asset1.id, personId: nameAlicePerson.id }), // 1 asset
// Null-named person 4 assets
utils.createFace({ assetId: asset1.id, personId: nameNullPerson4Assets.id }),
utils.createFace({ assetId: asset2.id, personId: nameNullPerson4Assets.id }),
utils.createFace({ assetId: asset3.id, personId: nameNullPerson4Assets.id }),
utils.createFace({ assetId: asset4.id, personId: nameNullPerson4Assets.id }), // 4 assets
// Null-named person 3 assets
utils.createFace({ assetId: asset1.id, personId: nameNullPerson3Assets.id }),
utils.createFace({ assetId: asset2.id, personId: nameNullPerson3Assets.id }),
utils.createFace({ assetId: asset3.id, personId: nameNullPerson3Assets.id }), // 3 assets
// Null-named person 1 asset
utils.createFace({ assetId: asset3.id, personId: nameNullPerson1Asset.id }),
// Favourite People
utils.createFace({ assetId: asset1.id, personId: nameFreddyPersonFavourite.id }),
utils.createFace({ assetId: asset2.id, personId: nameFreddyPersonFavourite.id }),
utils.createFace({ assetId: asset1.id, personId: nameBillPersonFavourite.id }),
]); ]);
}); });
describe('GET /people', () => { describe('GET /people', () => {
beforeEach(async () => {}); beforeEach(async () => {});
it('should require authentication', async () => {
const { status, body } = await request(app).get('/people');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should return all people (including hidden)', async () => { it('should return all people (including hidden)', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/people') .get('/people')
@@ -122,66 +75,27 @@ describe('/people', () => {
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual({ expect(body).toEqual({
hasNextPage: false, hasNextPage: false,
total: 11, total: 3,
hidden: 1, hidden: 1,
people: [ people: [
expect.objectContaining({ name: 'Freddy' }),
expect.objectContaining({ name: 'Bill' }),
expect.objectContaining({ name: 'multiple_assets_person' }), 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({ name: 'visible_person' }),
expect.objectContaining({ id: nameNullPerson4Assets.id, name: '' }), expect.objectContaining({ name: 'hidden_person' }),
expect.objectContaining({ id: nameNullPerson3Assets.id, name: '' }),
expect.objectContaining({ name: 'hidden_person' }), // Should really be before the null names
], ],
}); });
}); });
it('should sort visible people by asset count (desc), then by name (asc, nulls last)', async () => {
const { status, body } = await request(app).get('/people').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body.hasNextPage).toBe(false);
expect(body.total).toBe(11); // All persons
expect(body.hidden).toBe(1); // 'hidden_person'
const people = body.people as PersonResponseDto[];
expect(people.map((p) => p.id)).toEqual([
nameFreddyPersonFavourite.id, // name: 'Freddy', count: 2
nameBillPersonFavourite.id, // name: 'Bill', count: 1
multipleAssetsPerson.id, // name: 'multiple_assets_person', count: 3
nameBobPerson.id, // name: 'Bob', count: 2
nameAlicePerson.id, // name: 'Alice', count: 1
nameCharliePerson.id, // name: 'Charlie', count: 1
visiblePerson.id, // name: 'visible_person', count: 1
nameNullPerson4Assets.id, // name: '', count: 4
nameNullPerson3Assets.id, // name: '', count: 3
]);
expect(people.some((p) => p.id === hiddenPerson.id)).toBe(false);
});
it('should return only visible people', async () => { it('should return only visible people', async () => {
const { status, body } = await request(app).get('/people').set('Authorization', `Bearer ${admin.accessToken}`); const { status, body } = await request(app).get('/people').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual({ expect(body).toEqual({
hasNextPage: false, hasNextPage: false,
total: 11, total: 3,
hidden: 1, hidden: 1,
people: [ people: [
expect.objectContaining({ name: 'Freddy' }),
expect.objectContaining({ name: 'Bill' }),
expect.objectContaining({ name: 'multiple_assets_person' }), 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({ name: 'visible_person' }),
expect.objectContaining({ id: nameNullPerson4Assets.id, name: '' }),
expect.objectContaining({ id: nameNullPerson3Assets.id, name: '' }),
], ],
}); });
}); });
@@ -190,19 +104,26 @@ describe('/people', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/people') .get('/people')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.query({ withHidden: true, page: 5, size: 1 }); .query({ withHidden: true, page: 2, size: 1 });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual({ expect(body).toEqual({
hasNextPage: true, hasNextPage: true,
total: 11, total: 3,
hidden: 1, hidden: 1,
people: [expect.objectContaining({ name: 'Alice' })], people: [expect.objectContaining({ name: 'visible_person' })],
}); });
}); });
}); });
describe('GET /people/:id', () => { describe('GET /people/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/people/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should throw error if person with id does not exist', async () => { it('should throw error if person with id does not exist', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get(`/people/${uuidDto.notFound}`) .get(`/people/${uuidDto.notFound}`)
@@ -223,6 +144,13 @@ describe('/people', () => {
}); });
describe('GET /people/:id/statistics', () => { describe('GET /people/:id/statistics', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/people/${multipleAssetsPerson.id}/statistics`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should throw error if person with id does not exist', async () => { it('should throw error if person with id does not exist', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get(`/people/${uuidDto.notFound}/statistics`) .get(`/people/${uuidDto.notFound}/statistics`)
@@ -238,11 +166,28 @@ describe('/people', () => {
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ assets: 3 })); expect(body).toEqual(expect.objectContaining({ assets: 2 }));
}); });
}); });
describe('POST /people', () => { describe('POST /people', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/people`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
for (const { birthDate, response } of invalidBirthday) {
it(`should not accept an invalid birth date [${birthDate}]`, async () => {
const { status, body } = await request(app)
.post(`/people`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ birthDate });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(response));
});
}
it('should create a person', async () => { it('should create a person', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.post(`/people`) .post(`/people`)
@@ -278,6 +223,39 @@ describe('/people', () => {
}); });
describe('PUT /people/:id', () => { describe('PUT /people/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/people/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
for (const { key, type } of [
{ key: 'name', type: 'string' },
{ key: 'featureFaceAssetId', type: 'string' },
{ key: 'isHidden', type: 'boolean value' },
{ key: 'isFavorite', type: 'boolean value' },
]) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app)
.put(`/people/${visiblePerson.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([`${key} must be a ${type}`]));
});
}
for (const { birthDate, response } of invalidBirthday) {
it(`should not accept an invalid birth date [${birthDate}]`, async () => {
const { status, body } = await request(app)
.put(`/people/${visiblePerson.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ birthDate });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(response));
});
}
it('should update a date of birth', async () => { it('should update a date of birth', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/people/${visiblePerson.id}`) .put(`/people/${visiblePerson.id}`)
@@ -334,6 +312,12 @@ describe('/people', () => {
}); });
describe('POST /people/:id/merge', () => { describe('POST /people/:id/merge', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/people/${uuidDto.notFound}/merge`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should not supporting merging a person into themselves', async () => { it('should not supporting merging a person into themselves', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.post(`/people/${visiblePerson.id}/merge`) .post(`/people/${visiblePerson.id}/merge`)

View File

@@ -119,16 +119,6 @@ describe('/shared-links', () => {
expect(resp.header['content-type']).toContain('text/html'); expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).toContain(`<meta property="og:image" content="https://my.immich.app`); 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', () => { describe('GET /shared-links', () => {

View File

@@ -1,10 +1,4 @@
import { import { AssetMediaResponseDto, AssetVisibility, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk';
AssetMediaResponseDto,
AssetVisibility,
LoginResponseDto,
SharedLinkType,
TimeBucketAssetResponseDto,
} from '@immich/sdk';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { createUserDto } from 'src/fixtures'; import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
@@ -25,8 +19,7 @@ describe('/timeline', () => {
let user: LoginResponseDto; let user: LoginResponseDto;
let timeBucketUser: LoginResponseDto; let timeBucketUser: LoginResponseDto;
let user1Assets: AssetMediaResponseDto[]; let userAssets: AssetMediaResponseDto[];
let user2Assets: AssetMediaResponseDto[];
beforeAll(async () => { beforeAll(async () => {
await utils.resetDatabase(); await utils.resetDatabase();
@@ -36,7 +29,7 @@ describe('/timeline', () => {
utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')), utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')),
]); ]);
user1Assets = await Promise.all([ userAssets = await Promise.all([
utils.createAsset(user.accessToken), utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken), utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken, { utils.createAsset(user.accessToken, {
@@ -49,20 +42,17 @@ describe('/timeline', () => {
utils.createAsset(user.accessToken), utils.createAsset(user.accessToken),
]); ]);
user2Assets = await Promise.all([ await Promise.all([
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-01-01').toISOString() }), 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-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-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', () => { describe('GET /timeline/buckets', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(app).get('/timeline/buckets'); const { status, body } = await request(app).get('/timeline/buckets').query({ size: TimeBucketSize.Month });
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
@@ -70,13 +60,14 @@ describe('/timeline', () => {
it('should get time buckets by month', async () => { it('should get time buckets by month', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/timeline/buckets') .get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`); .set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual( expect(body).toEqual(
expect.arrayContaining([ expect.arrayContaining([
{ count: 3, timeBucket: '1970-02-01' }, { count: 3, timeBucket: '1970-02-01T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-01-01' }, { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
]), ]),
); );
}); });
@@ -84,20 +75,36 @@ describe('/timeline', () => {
it('should not allow access for unrelated shared links', async () => { it('should not allow access for unrelated shared links', async () => {
const sharedLink = await utils.createSharedLink(user.accessToken, { const sharedLink = await utils.createSharedLink(user.accessToken, {
type: SharedLinkType.Individual, type: SharedLinkType.Individual,
assetIds: user1Assets.map(({ id }) => id), assetIds: userAssets.map(({ id }) => id),
}); });
const { status, body } = await request(app).get('/timeline/buckets').query({ key: sharedLink.key }); const { status, body } = await request(app)
.get('/timeline/buckets')
.query({ key: sharedLink.key, size: TimeBucketSize.Month });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission); expect(body).toEqual(errorDto.noPermission);
}); });
it('should get time buckets by day', async () => {
const { status, body } = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Day });
expect(status).toBe(200);
expect(body).toEqual([
{ count: 2, timeBucket: '1970-02-11T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-02-10T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
]);
});
it('should return error if time bucket is requested with partners asset and archived', async () => { it('should return error if time bucket is requested with partners asset and archived', async () => {
const req1 = await request(app) const req1 = await request(app)
.get('/timeline/buckets') .get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`) .set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ withPartners: true, visibility: AssetVisibility.Archive }); .query({ size: TimeBucketSize.Month, withPartners: true, visibility: AssetVisibility.Archive });
expect(req1.status).toBe(400); expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest()); expect(req1.body).toEqual(errorDto.badRequest());
@@ -105,7 +112,7 @@ describe('/timeline', () => {
const req2 = await request(app) const req2 = await request(app)
.get('/timeline/buckets') .get('/timeline/buckets')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.query({ withPartners: true, visibility: undefined }); .query({ size: TimeBucketSize.Month, withPartners: true, visibility: undefined });
expect(req2.status).toBe(400); expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest()); expect(req2.body).toEqual(errorDto.badRequest());
@@ -115,7 +122,7 @@ describe('/timeline', () => {
const req1 = await request(app) const req1 = await request(app)
.get('/timeline/buckets') .get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`) .set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ withPartners: true, isFavorite: true }); .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true });
expect(req1.status).toBe(400); expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest()); expect(req1.body).toEqual(errorDto.badRequest());
@@ -123,7 +130,7 @@ describe('/timeline', () => {
const req2 = await request(app) const req2 = await request(app)
.get('/timeline/buckets') .get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`) .set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ withPartners: true, isFavorite: false }); .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false });
expect(req2.status).toBe(400); expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest()); expect(req2.body).toEqual(errorDto.badRequest());
@@ -133,7 +140,7 @@ describe('/timeline', () => {
const req = await request(app) const req = await request(app)
.get('/timeline/buckets') .get('/timeline/buckets')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.query({ withPartners: true, isTrashed: true }); .query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true });
expect(req.status).toBe(400); expect(req.status).toBe(400);
expect(req.body).toEqual(errorDto.badRequest()); expect(req.body).toEqual(errorDto.badRequest());
@@ -143,6 +150,7 @@ describe('/timeline', () => {
describe('GET /timeline/bucket', () => { describe('GET /timeline/bucket', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(app).get('/timeline/bucket').query({ const { status, body } = await request(app).get('/timeline/bucket').query({
size: TimeBucketSize.Month,
timeBucket: '1900-01-01', timeBucket: '1900-01-01',
}); });
@@ -153,28 +161,11 @@ describe('/timeline', () => {
it('should handle 5 digit years', async () => { it('should handle 5 digit years', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/timeline/bucket') .get('/timeline/bucket')
.query({ timeBucket: '012345-01-01' }) .query({ size: TimeBucketSize.Month, timeBucket: '012345-01-01' })
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`); .set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual({ 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 // TODO enable date string validation while still accepting 5 digit years
@@ -182,7 +173,7 @@ describe('/timeline', () => {
// const { status, body } = await request(app) // const { status, body } = await request(app)
// .get('/timeline/bucket') // .get('/timeline/bucket')
// .set('Authorization', `Bearer ${user.accessToken}`) // .set('Authorization', `Bearer ${user.accessToken}`)
// .query({ timeBucket: 'foo' }); // .query({ size: TimeBucketSize.Month, timeBucket: 'foo' });
// expect(status).toBe(400); // expect(status).toBe(400);
// expect(body).toEqual(errorDto.badRequest); // expect(body).toEqual(errorDto.badRequest);
@@ -192,39 +183,10 @@ describe('/timeline', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/timeline/bucket') .get('/timeline/bucket')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`) .set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ timeBucket: '1970-02-10' }); .query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10' });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual({ 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-01T00:00:00.000Z', isTrashed: true });
expect(status).toBe(200);
const timeBucket: TimeBucketAssetResponseDto = body;
expect(timeBucket.isTrashed).toEqual([true]);
}); });
}); });
}); });

View File

@@ -118,7 +118,7 @@ describe('/admin/users', () => {
}); });
} }
it('should accept `isAdmin`', async () => { it('should ignore `isAdmin`', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.post(`/admin/users`) .post(`/admin/users`)
.send({ .send({
@@ -130,7 +130,7 @@ describe('/admin/users', () => {
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toMatchObject({ expect(body).toMatchObject({
email: 'user5@immich.cloud', email: 'user5@immich.cloud',
isAdmin: true, isAdmin: false,
shouldChangePassword: true, shouldChangePassword: true,
}); });
expect(status).toBe(201); expect(status).toBe(201);
@@ -163,15 +163,14 @@ describe('/admin/users', () => {
}); });
} }
it('should allow a non-admin to become an admin', async () => { it('should not allow a non-admin to become an admin', async () => {
const user = await utils.userSetup(admin.accessToken, createUserDto.create('admin2'));
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/admin/users/${user.userId}`) .put(`/admin/users/${nonAdmin.userId}`)
.send({ isAdmin: true }) .send({ isAdmin: true })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toMatchObject({ isAdmin: true }); expect(body).toMatchObject({ isAdmin: false });
}); });
it('ignores updates to profileImagePath', async () => { it('ignores updates to profileImagePath', async () => {

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

@@ -7,44 +7,6 @@ describe(`immich-admin`, () => {
await utils.adminSetup(); await utils.adminSetup();
}); });
describe('revoke-admin', () => {
it('should revoke admin privileges from a user', async () => {
const { child, promise } = immichAdmin(['revoke-admin']);
let data = '';
child.stdout.on('data', (chunk) => {
data += chunk;
if (data.includes('Please enter the user email:')) {
child.stdin.end('admin@immich.cloud\n');
}
});
const { stdout, exitCode } = await promise;
expect(exitCode).toBe(0);
expect(stdout).toContain('Admin access has been revoked from');
});
});
describe('grant-admin', () => {
it('should grant admin privileges to a user', async () => {
const { child, promise } = immichAdmin(['grant-admin']);
let data = '';
child.stdout.on('data', (chunk) => {
data += chunk;
if (data.includes('Please enter the user email:')) {
child.stdin.end('admin@immich.cloud\n');
}
});
const { stdout, exitCode } = await promise;
expect(exitCode).toBe(0);
expect(stdout).toContain('Admin access has been granted to');
});
});
describe('list-users', () => { describe('list-users', () => {
it('should list the admin user', async () => { it('should list the admin user', async () => {
const { stdout, exitCode } = await immichAdmin(['list-users']).promise; const { stdout, exitCode } = await immichAdmin(['list-users']).promise;

View File

@@ -103,7 +103,6 @@ export const loginResponseDto = {
accessToken: expect.any(String), accessToken: expect.any(String),
name: 'Immich Admin', name: 'Immich Admin',
isAdmin: true, isAdmin: true,
isOnboarded: false,
profileImagePath: '', profileImagePath: '',
shouldChangePassword: true, shouldChangePassword: true,
userEmail: 'admin@immich.cloud', userEmail: 'admin@immich.cloud',

View File

@@ -33,9 +33,7 @@ test.describe('Registration', () => {
// onboarding // onboarding
await expect(page).toHaveURL('/auth/onboarding'); await expect(page).toHaveURL('/auth/onboarding');
await page.getByRole('button', { name: 'Theme' }).click(); await page.getByRole('button', { name: 'Theme' }).click();
await page.getByRole('button', { name: 'Language' }).click(); await page.getByRole('button', { name: 'Privacy' }).click();
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: 'Storage Template' }).click();
await page.getByRole('button', { name: 'Done' }).click(); await page.getByRole('button', { name: 'Done' }).click();
@@ -79,13 +77,6 @@ test.describe('Registration', () => {
await page.getByLabel('Password').fill('new-password'); await page.getByLabel('Password').fill('new-password');
await page.getByRole('button', { name: 'Login' }).click(); await page.getByRole('button', { name: 'Login' }).click();
// onboarding
await expect(page).toHaveURL('/auth/onboarding');
await page.getByRole('button', { name: 'Theme' }).click();
await page.getByRole('button', { name: 'Language' }).click();
await page.getByRole('button', { name: 'User Privacy' }).click();
await page.getByRole('button', { name: 'Done' }).click();
// success // success
await expect(page).toHaveURL(/\/photos/); await expect(page).toHaveURL(/\/photos/);
}); });

View File

@@ -1,89 +0,0 @@
import { getUserAdmin } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { asBearerAuth, utils } from 'src/utils';
test.describe('User Administration', () => {
test.beforeAll(() => {
utils.initSdk();
});
test.beforeEach(async () => {
await utils.resetDatabase();
});
test('validate admin/users link', async ({ context, page }) => {
const admin = await utils.adminSetup();
await utils.setAuthCookies(context, admin.accessToken);
// Navigate to user management page and verify title and header
await page.goto(`/admin/users`);
await expect(page).toHaveTitle(/User Management/);
await expect(page.getByText('User Management')).toBeVisible();
});
test('create user', async ({ context, page }) => {
const admin = await utils.adminSetup();
await utils.setAuthCookies(context, admin.accessToken);
// Create a new user
await page.goto('/admin/users');
await page.getByRole('button', { name: 'Create user' }).click();
await page.getByLabel('Email').fill('user@immich.cloud');
await page.getByLabel('Password', { exact: true }).fill('password');
await page.getByLabel('Confirm Password').fill('password');
await page.getByLabel('Name').fill('Immich User');
await page.getByRole('button', { name: 'Create', exact: true }).click();
// Verify the user exists in the user list
await page.getByRole('row', { name: 'user@immich.cloud' });
});
test('promote to admin', async ({ context, page }) => {
const admin = await utils.adminSetup();
await utils.setAuthCookies(context, admin.accessToken);
const user = await utils.userSetup(admin.accessToken, {
name: 'Admin 2',
email: 'admin2@immich.cloud',
password: 'password',
});
expect(user.isAdmin).toBe(false);
await page.goto(`/admin/users/${user.userId}`);
await page.getByRole('button', { name: 'Edit user' }).click();
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByText('Admin User').click();
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(updated.isAdmin).toBe(true);
});
test('revoke admin access', async ({ context, page }) => {
const admin = await utils.adminSetup();
await utils.setAuthCookies(context, admin.accessToken);
const user = await utils.userSetup(admin.accessToken, {
name: 'Admin 2',
email: 'admin2@immich.cloud',
password: 'password',
isAdmin: true,
});
expect(user.isAdmin).toBe(true);
await page.goto(`/admin/users/${user.userId}`);
await page.getByRole('button', { name: 'Edit user' }).click();
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByText('Admin User').click();
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(updated.isAdmin).toBe(false);
});
});

View File

@@ -40,6 +40,7 @@
"backup_keep_last_amount": "Aantal vorige rugsteune om te hou", "backup_keep_last_amount": "Aantal vorige rugsteune om te hou",
"backup_settings": "Rugsteun instellings", "backup_settings": "Rugsteun instellings",
"backup_settings_description": "Bestuur databasis rugsteun instellings", "backup_settings_description": "Bestuur databasis rugsteun instellings",
"check_all": "Kies Alles",
"cleared_jobs": "Poste gevee vir: {job}", "cleared_jobs": "Poste gevee vir: {job}",
"config_set_by_file": "Config word tans deur 'n konfigurasielêer gestel", "config_set_by_file": "Config word tans deur 'n konfigurasielêer gestel",
"confirm_delete_library": "Is jy seker jy wil {library}-biblioteek uitvee?", "confirm_delete_library": "Is jy seker jy wil {library}-biblioteek uitvee?",
@@ -54,10 +55,12 @@
"disable_login": "Deaktiveer aanmelding", "disable_login": "Deaktiveer aanmelding",
"duplicate_detection_job_description": "Begin masjienleer op bates om soortgelyke beelde op te spoor. Maak staat op Smart Search", "duplicate_detection_job_description": "Begin masjienleer op bates om soortgelyke beelde op te spoor. Maak staat op Smart Search",
"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.", "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_created_at": "Eksterne biblioteek (geskep op {date})",
"external_library_management": "Eksterne Biblioteekbestuur", "external_library_management": "Eksterne Biblioteekbestuur",
"face_detection": "Gesig deteksie", "face_detection": "Gesig deteksie",
"failed_job_command": "Opdrag {command} het misluk vir werk: {job}", "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.", "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.",
"forcing_refresh_library_files": "Forseer herlaai van alle biblioteeklêers",
"image_format": "Formaat", "image_format": "Formaat",
"image_format_description": "WebP produseer kleiner lêers as JPEG, maar is stadiger om te enkodeer.", "image_format_description": "WebP produseer kleiner lêers as JPEG, maar is stadiger om te enkodeer.",
"image_prefer_embedded_preview": "Verkies ingebedde voorskou", "image_prefer_embedded_preview": "Verkies ingebedde voorskou",

View File

@@ -14,6 +14,7 @@
"add_a_location": "إضافة موقع", "add_a_location": "إضافة موقع",
"add_a_name": "إضافة إسم", "add_a_name": "إضافة إسم",
"add_a_title": "إضافة عنوان", "add_a_title": "إضافة عنوان",
"add_endpoint": "Add endpoint",
"add_exclusion_pattern": "إضافة نمط إستثناء", "add_exclusion_pattern": "إضافة نمط إستثناء",
"add_import_path": "إضافة مسار الإستيراد", "add_import_path": "إضافة مسار الإستيراد",
"add_location": "إضافة موقع", "add_location": "إضافة موقع",
@@ -43,6 +44,8 @@
"backup_keep_last_amount": "مقدار النسخ الاحتياطية السابقة للاحتفاظ بها", "backup_keep_last_amount": "مقدار النسخ الاحتياطية السابقة للاحتفاظ بها",
"backup_settings": "إعدادات النسخ الاحتياطي", "backup_settings": "إعدادات النسخ الاحتياطي",
"backup_settings_description": "إدارة إعدادات النسخ الاحتياطي لقاعدة البيانات", "backup_settings_description": "إدارة إعدادات النسخ الاحتياطي لقاعدة البيانات",
"check_all": "اختر الكل",
"cleanup": "تنظيف",
"cleared_jobs": "تم إخلاء مهام: {job}", "cleared_jobs": "تم إخلاء مهام: {job}",
"config_set_by_file": "الإعدادات حاليًا معينة عن طريق ملف الاعدادات", "config_set_by_file": "الإعدادات حاليًا معينة عن طريق ملف الاعدادات",
"confirm_delete_library": "هل أنت متأكد أنك تريد حذف مكتبة {library}؟", "confirm_delete_library": "هل أنت متأكد أنك تريد حذف مكتبة {library}؟",
@@ -57,12 +60,14 @@
"disable_login": "تعطيل تسجيل الدخول", "disable_login": "تعطيل تسجيل الدخول",
"duplicate_detection_job_description": "بدء التعلم الآلي على المحتوى للعثور على الصور المتشابهة. يعتمد على البحث الذكي", "duplicate_detection_job_description": "بدء التعلم الآلي على المحتوى للعثور على الصور المتشابهة. يعتمد على البحث الذكي",
"exclusion_pattern_description": "تتيح لك أنماط الاستبعاد تجاهل الملفات والمجلدات عند فحص مكتبتك. يعد هذا مفيدًا إذا كان لديك مجلدات تحتوي على ملفات لا تريد استيرادها، مثل ملفات RAW.", "exclusion_pattern_description": "تتيح لك أنماط الاستبعاد تجاهل الملفات والمجلدات عند فحص مكتبتك. يعد هذا مفيدًا إذا كان لديك مجلدات تحتوي على ملفات لا تريد استيرادها، مثل ملفات RAW.",
"external_library_created_at": "مكتبة خارجية (أُنشئت في {date})",
"external_library_management": "إدارة المكتبة الخارجية", "external_library_management": "إدارة المكتبة الخارجية",
"face_detection": "إ‏كتشاف الوجوه", "face_detection": "إ‏كتشاف الوجوه",
"face_detection_description": "اكتشف الوجوه في الأصول باستخدام التعلم الآلي. بالنسبة لمقاطع الفيديو، يتم اعتبار الصورة المصغرة فقط. \"تحديث\" (إعادة) معالجة جميع الأصول. \"إعادة تعيين\" تمسح أيضًا جميع بيانات الوجوه الحالية. \"مفقود\" يضع الأصول التي لم تتم معالجتها بعد في قائمة الانتظار. سيتم وضع الوجوه المكتشفة في قائمة الانتظار للتعرف على الوجه بعد اكتمال اكتشاف الوجه، وتجميعها في أشخاص موجودين أو جدد.", "face_detection_description": "اكتشف الوجوه في الأصول باستخدام التعلم الآلي. بالنسبة لمقاطع الفيديو، يتم اعتبار الصورة المصغرة فقط. \"تحديث\" (إعادة) معالجة جميع الأصول. \"إعادة تعيين\" تمسح أيضًا جميع بيانات الوجوه الحالية. \"مفقود\" يضع الأصول التي لم تتم معالجتها بعد في قائمة الانتظار. سيتم وضع الوجوه المكتشفة في قائمة الانتظار للتعرف على الوجه بعد اكتمال اكتشاف الوجه، وتجميعها في أشخاص موجودين أو جدد.",
"facial_recognition_job_description": "تجميع الوجوه المكتشفة كأشخاص. يتم تنفيذ هذه الخطوة بعد اكتمال اكتشاف الوجه. خيار \"إعادة التعيين\" يعيد تجميع جميع الوجوه. خيار \"المفقود\" يضع في قائمة الانتظار الوجوه التي لم يتم تعيين شخص لها.", "facial_recognition_job_description": "تجميع الوجوه المكتشفة كأشخاص. يتم تنفيذ هذه الخطوة بعد اكتمال اكتشاف الوجه. خيار \"إعادة التعيين\" يعيد تجميع جميع الوجوه. خيار \"المفقود\" يضع في قائمة الانتظار الوجوه التي لم يتم تعيين شخص لها.",
"failed_job_command": "فشل الأمر {command} للمهمة: {job}", "failed_job_command": "فشل الأمر {command} للمهمة: {job}",
"force_delete_user_warning": "تحذير: سيؤدي ذلك إلى إزالة المستخدم وجميع محتوياته على الفور. لا يمكن التراجع عن هذا الإجراء ولا يمكن استرداد الملفات.", "force_delete_user_warning": "تحذير: سيؤدي ذلك إلى إزالة المستخدم وجميع محتوياته على الفور. لا يمكن التراجع عن هذا الإجراء ولا يمكن استرداد الملفات.",
"forcing_refresh_library_files": "إجبار التحديث لجميع ملفات المكتبة",
"image_format": "التنسيق", "image_format": "التنسيق",
"image_format_description": "يُنتج WebP ملفات أصغر حجمًا من ملفات JPEG، ولكنه أبطأ في عملية الترميز.", "image_format_description": "يُنتج WebP ملفات أصغر حجمًا من ملفات JPEG، ولكنه أبطأ في عملية الترميز.",
"image_prefer_embedded_preview": "تفضيل المعاينة المدمجة", "image_prefer_embedded_preview": "تفضيل المعاينة المدمجة",
@@ -185,7 +190,7 @@
"oauth_enable_description": "تسجيل الدخول باستخدام OAuth", "oauth_enable_description": "تسجيل الدخول باستخدام OAuth",
"oauth_mobile_redirect_uri": "عنوان URI لإعادة التوجيه على الهاتف", "oauth_mobile_redirect_uri": "عنوان URI لإعادة التوجيه على الهاتف",
"oauth_mobile_redirect_uri_override": "تجاوز عنوان URI لإعادة التوجيه على الهاتف", "oauth_mobile_redirect_uri_override": "تجاوز عنوان URI لإعادة التوجيه على الهاتف",
"oauth_mobile_redirect_uri_override_description": "قم بتفعيله عندما لا يسمح موفر OAuth بمعرف URI للجوال، مثل ''{callback}''", "oauth_mobile_redirect_uri_override_description": "قم بتفعيله عندما لا يسمح موفر OAuth بمعرف URI للجوال، مثل '{callback}'",
"oauth_settings": "OAuth", "oauth_settings": "OAuth",
"oauth_settings_description": "إدارة إعدادات تسجيل الدخول OAuth", "oauth_settings_description": "إدارة إعدادات تسجيل الدخول OAuth",
"oauth_settings_more_details": "لمزيد من التفاصيل حول هذه الميزة، يرجى الرجوع إلى <link>الوثائق</link>.", "oauth_settings_more_details": "لمزيد من التفاصيل حول هذه الميزة، يرجى الرجوع إلى <link>الوثائق</link>.",
@@ -195,6 +200,8 @@
"oauth_storage_quota_claim_description": "قم تلقائيًا بتعيين حصة التخزين للمستخدم على قيمة هذه المطالبة.", "oauth_storage_quota_claim_description": "قم تلقائيًا بتعيين حصة التخزين للمستخدم على قيمة هذه المطالبة.",
"oauth_storage_quota_default": "حصة التخزين الافتراضية (جيجابايت)", "oauth_storage_quota_default": "حصة التخزين الافتراضية (جيجابايت)",
"oauth_storage_quota_default_description": "الحصة بالجيجابايت التي سيتم استخدامها عندما لا يتم توفير مطالبة (أدخل 0 لحصة غير محدودة).", "oauth_storage_quota_default_description": "الحصة بالجيجابايت التي سيتم استخدامها عندما لا يتم توفير مطالبة (أدخل 0 لحصة غير محدودة).",
"offline_paths": "مسارات غير متصلة",
"offline_paths_description": "قد تكون هذه النتائج ناتجة عن حذف يدوي لملفات لا تتبع لمكتبة خارجية.",
"password_enable_description": "تسجيل الدخول باستخدام البريد الكتروني وكلمة المرور", "password_enable_description": "تسجيل الدخول باستخدام البريد الكتروني وكلمة المرور",
"password_settings": "تسجيل الدخول بكلمة المرور", "password_settings": "تسجيل الدخول بكلمة المرور",
"password_settings_description": "إدارة تسجيل الدخول بكلمة المرور", "password_settings_description": "إدارة تسجيل الدخول بكلمة المرور",
@@ -204,6 +211,9 @@
"refreshing_all_libraries": "تحديث كافة المكتبات", "refreshing_all_libraries": "تحديث كافة المكتبات",
"registration": "تسجيل المدير", "registration": "تسجيل المدير",
"registration_description": "بما أنك أول مستخدم في النظام، سيتم تعيينك كمسؤول وستكون مسؤولًا عن المهام الإدارية، وسيتم إنشاء مستخدمين إضافيين بواسطتك.", "registration_description": "بما أنك أول مستخدم في النظام، سيتم تعيينك كمسؤول وستكون مسؤولًا عن المهام الإدارية، وسيتم إنشاء مستخدمين إضافيين بواسطتك.",
"repair_all": "إصلاح الكل",
"repair_matched_items": "تمت مطابقة {count, plural, one {# عنصر} other {# عناصر}}",
"repaired_items": "تم إصلاح {count, plural, one {# عنصر} other {# عناصر}}",
"require_password_change_on_login": "الطلب من المستخدم تغيير كلمة المرور عند تسجيل الدخول الأول", "require_password_change_on_login": "الطلب من المستخدم تغيير كلمة المرور عند تسجيل الدخول الأول",
"reset_settings_to_default": "إعادة ضبط الإعدادات إلى الوضع الافتراضي", "reset_settings_to_default": "إعادة ضبط الإعدادات إلى الوضع الافتراضي",
"reset_settings_to_recent_saved": "إعادة ضبط الإعدادات إلى الإعدادات المحفوظة مؤخرًا", "reset_settings_to_recent_saved": "إعادة ضبط الإعدادات إلى الإعدادات المحفوظة مؤخرًا",
@@ -232,6 +242,7 @@
"storage_template_migration_info": "تغييرات القالب ستنطبق فقط على المحتويات الجديدة. لتطبيق القالب على المحتويات التي تم رفعها سابقًا، قم بتشغيل <link>{job}</link>.", "storage_template_migration_info": "تغييرات القالب ستنطبق فقط على المحتويات الجديدة. لتطبيق القالب على المحتويات التي تم رفعها سابقًا، قم بتشغيل <link>{job}</link>.",
"storage_template_migration_job": "وظيفة تهجير قالب التخزين", "storage_template_migration_job": "وظيفة تهجير قالب التخزين",
"storage_template_more_details": "لمزيد من التفاصيل حول هذه الميزة، يرجى الرجوع إلى <template-link>Storage Template</template-link> و<implications-link>implications</implications-link>", "storage_template_more_details": "لمزيد من التفاصيل حول هذه الميزة، يرجى الرجوع إلى <template-link>Storage Template</template-link> و<implications-link>implications</implications-link>",
"storage_template_onboarding_description": "عند تفعيل هذه الميزة، سيقوم بتنظيم الملفات تلقائيًا بناءً على قالب محدد من قبل المستخدم. بسبب مشاكل الاستقرار، تم تعطيل الميزة افتراضيًا. للمزيد من المعلومات، يرجى الرجوع إلى <link>الوثائق</link>.",
"storage_template_path_length": "الحد التقريبي لطول المسار: <b>{length, number}</b>/{limit, number}", "storage_template_path_length": "الحد التقريبي لطول المسار: <b>{length, number}</b>/{limit, number}",
"storage_template_settings": "قالب التخزين", "storage_template_settings": "قالب التخزين",
"storage_template_settings_description": "إدارة هيكل المجلد واسم الملف للأصول المرفوعة", "storage_template_settings_description": "إدارة هيكل المجلد واسم الملف للأصول المرفوعة",
@@ -243,6 +254,7 @@
"template_email_invite_album": "قالب دعوة الألبوم", "template_email_invite_album": "قالب دعوة الألبوم",
"template_email_preview": "عرض مسبق", "template_email_preview": "عرض مسبق",
"template_email_settings": "نماذج البريد الالكتروني", "template_email_settings": "نماذج البريد الالكتروني",
"template_email_settings_description": "إدارة قوالب إشعارات البريد الإلكتروني المخصصة",
"template_email_update_album": "تحديث قالب الألبوم", "template_email_update_album": "تحديث قالب الألبوم",
"template_email_welcome": "قالب البريد الإلكتروني الترحيبي", "template_email_welcome": "قالب البريد الإلكتروني الترحيبي",
"template_settings": "قوالب الإشعارات", "template_settings": "قوالب الإشعارات",
@@ -251,6 +263,7 @@
"theme_custom_css_settings_description": "أوراق الأنماط المتتالية تسمح بتخصيص تصميم Immich.", "theme_custom_css_settings_description": "أوراق الأنماط المتتالية تسمح بتخصيص تصميم Immich.",
"theme_settings": "إعدادات السمة", "theme_settings": "إعدادات السمة",
"theme_settings_description": "إدارة تخصيص واجهة ويب Immich", "theme_settings_description": "إدارة تخصيص واجهة ويب Immich",
"these_files_matched_by_checksum": "تتم مطابقة هذه الملفات من خلال المجاميع الاختبارية الخاصة بهم",
"thumbnail_generation_job": "إنشاء الصور المصغرة", "thumbnail_generation_job": "إنشاء الصور المصغرة",
"thumbnail_generation_job_description": "إنشاء صور مصغرة كبيرة وصغيرة وغير واضحة لكل أصل، بالإضافة إلى صور مصغرة لكل شخص", "thumbnail_generation_job_description": "إنشاء صور مصغرة كبيرة وصغيرة وغير واضحة لكل أصل، بالإضافة إلى صور مصغرة لكل شخص",
"transcoding_acceleration_api": "واجهة برمجة التطبيقات للتسريع", "transcoding_acceleration_api": "واجهة برمجة التطبيقات للتسريع",
@@ -281,6 +294,7 @@
"transcoding_hardware_acceleration_description": "تجريبي؛ أسرع بكثير، ولكن ستكون جودتها أقل عند نفس معدل البت", "transcoding_hardware_acceleration_description": "تجريبي؛ أسرع بكثير، ولكن ستكون جودتها أقل عند نفس معدل البت",
"transcoding_hardware_decoding": "فك تشفير الأجهزة", "transcoding_hardware_decoding": "فك تشفير الأجهزة",
"transcoding_hardware_decoding_setting_description": "ينطبق ذلك فقط على NVENC، QSV، و RKMPP. يمكن التسريع من طرف لطرف بدلاً من تسريع الترميز فقط. قد لا يعمل على جميع مقاطع الفيديو.", "transcoding_hardware_decoding_setting_description": "ينطبق ذلك فقط على NVENC، QSV، و RKMPP. يمكن التسريع من طرف لطرف بدلاً من تسريع الترميز فقط. قد لا يعمل على جميع مقاطع الفيديو.",
"transcoding_hevc_codec": "كود HEVC",
"transcoding_max_b_frames": "أقصى عدد من الإطارات B", "transcoding_max_b_frames": "أقصى عدد من الإطارات B",
"transcoding_max_b_frames_description": "القيم الأعلى تعزز كفاءة الضغط، ولكنها تبطئ عملية الترميز. قد لا تكون متوافقة مع التسريع العتادي على الأجهزة القديمة. قيمة 0 تعطل إطارات B، بينما تضبط القيمة -1 هذا القيمة تلقائيًا.", "transcoding_max_b_frames_description": "القيم الأعلى تعزز كفاءة الضغط، ولكنها تبطئ عملية الترميز. قد لا تكون متوافقة مع التسريع العتادي على الأجهزة القديمة. قيمة 0 تعطل إطارات B، بينما تضبط القيمة -1 هذا القيمة تلقائيًا.",
"transcoding_max_bitrate": "الحد الأقصى لمعدل البت", "transcoding_max_bitrate": "الحد الأقصى لمعدل البت",
@@ -318,6 +332,8 @@
"trash_number_of_days_description": "عدد أيام الاحتفاظ بالمحتويات في سلة المهملات قبل حذفها نهائيًا", "trash_number_of_days_description": "عدد أيام الاحتفاظ بالمحتويات في سلة المهملات قبل حذفها نهائيًا",
"trash_settings": "إعدادات سلة المهملات", "trash_settings": "إعدادات سلة المهملات",
"trash_settings_description": "إدارة إعدادات سلة المهملات", "trash_settings_description": "إدارة إعدادات سلة المهملات",
"untracked_files": "الملفات التي لم يتم تعقبها",
"untracked_files_description": "لا يتم تعقب هذه الملفات بواسطة التطبيق. يمكن أن تكون نتيجة لعمليات نقل فاشلة، أو عمليات تحميل متقطعة، أو يتم تركها في الخلف بسبب خطأ ما",
"user_cleanup_job": "تنظيف المستخدم", "user_cleanup_job": "تنظيف المستخدم",
"user_delete_delay": "سيتم جدولة حساب <b>{user}</b> ومحتوياته للحذف النهائي في غضون {delay, plural, one {# يوم} other {# أيام}}.", "user_delete_delay": "سيتم جدولة حساب <b>{user}</b> ومحتوياته للحذف النهائي في غضون {delay, plural, one {# يوم} other {# أيام}}.",
"user_delete_delay_settings": "فترة التأخير قبل الحذف", "user_delete_delay_settings": "فترة التأخير قبل الحذف",
@@ -343,8 +359,12 @@
"admin_password": "كلمة سر المشرف", "admin_password": "كلمة سر المشرف",
"administration": "الإدارة", "administration": "الإدارة",
"advanced": "متقدم", "advanced": "متقدم",
"advanced_settings_log_level_title": "Log level: {}",
"advanced_settings_prefer_remote_subtitle": "تكون بعض الأجهزة بطيئة للغاية في تحميل الصور المصغرة من الأصول الموجودة على الجهاز. قم بتنشيط هذا الإعداد لتحميل الصور البعيدة بدلاً من ذلك.", "advanced_settings_prefer_remote_subtitle": "تكون بعض الأجهزة بطيئة للغاية في تحميل الصور المصغرة من الأصول الموجودة على الجهاز. قم بتنشيط هذا الإعداد لتحميل الصور البعيدة بدلاً من ذلك.",
"advanced_settings_prefer_remote_title": "تفضل الصور البعيدة", "advanced_settings_prefer_remote_title": "تفضل الصور البعيدة",
"advanced_settings_proxy_headers_subtitle": "Define proxy headers Immich should send with each network request",
"advanced_settings_proxy_headers_title": "Proxy Headers",
"advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.",
"advanced_settings_self_signed_ssl_title": "السماح بشهادات SSL الموقعة ذاتيًا", "advanced_settings_self_signed_ssl_title": "السماح بشهادات SSL الموقعة ذاتيًا",
"advanced_settings_tile_subtitle": "إعدادات المستخدم المتقدمة", "advanced_settings_tile_subtitle": "إعدادات المستخدم المتقدمة",
"advanced_settings_troubleshooting_subtitle": "تمكين الميزات الإضافية لاستكشاف الأخطاء وإصلاحها", "advanced_settings_troubleshooting_subtitle": "تمكين الميزات الإضافية لاستكشاف الأخطاء وإصلاحها",
@@ -367,6 +387,10 @@
"album_remove_user": "هل ترغب في إزالة المستخدم؟", "album_remove_user": "هل ترغب في إزالة المستخدم؟",
"album_remove_user_confirmation": "هل أنت متأكد أنك تريد إزالة {user}؟", "album_remove_user_confirmation": "هل أنت متأكد أنك تريد إزالة {user}؟",
"album_share_no_users": "يبدو أنك قمت بمشاركة هذا الألبوم مع جميع المستخدمين أو ليس لديك أي مستخدم للمشاركة معه.", "album_share_no_users": "يبدو أنك قمت بمشاركة هذا الألبوم مع جميع المستخدمين أو ليس لديك أي مستخدم للمشاركة معه.",
"album_thumbnail_card_item": "عنصر واحد",
"album_thumbnail_card_items": "{} items",
"album_thumbnail_card_shared": " · . مشترك",
"album_thumbnail_shared_by": "Shared by {}",
"album_updated": "تم تحديث الألبوم", "album_updated": "تم تحديث الألبوم",
"album_updated_setting_description": "تلقي إشعارًا عبر البريد الإلكتروني عندما يحتوي الألبوم المشترك على محتويات جديدة", "album_updated_setting_description": "تلقي إشعارًا عبر البريد الإلكتروني عندما يحتوي الألبوم المشترك على محتويات جديدة",
"album_user_left": "تم ترك {album}", "album_user_left": "تم ترك {album}",
@@ -404,8 +428,10 @@
"archive": "الأرشيف", "archive": "الأرشيف",
"archive_or_unarchive_photo": "أرشفة الصورة أو إلغاء أرشفتها", "archive_or_unarchive_photo": "أرشفة الصورة أو إلغاء أرشفتها",
"archive_page_no_archived_assets": "لم يتم العثور على الأصول المؤرشفة", "archive_page_no_archived_assets": "لم يتم العثور على الأصول المؤرشفة",
"archive_page_title": "Archive ({})",
"archive_size": "حجم الأرشيف", "archive_size": "حجم الأرشيف",
"archive_size_description": "تكوين حجم الأرشيف للتنزيلات (بالجيجابايت)", "archive_size_description": "تكوين حجم الأرشيف للتنزيلات (بالجيجابايت)",
"archived": "Archived",
"archived_count": "{count, plural, other {الأرشيف #}}", "archived_count": "{count, plural, other {الأرشيف #}}",
"are_these_the_same_person": "هل هؤلاء هم نفس الشخص؟", "are_these_the_same_person": "هل هؤلاء هم نفس الشخص؟",
"are_you_sure_to_do_this": "هل انت متأكد من أنك تريد أن تفعل هذا؟", "are_you_sure_to_do_this": "هل انت متأكد من أنك تريد أن تفعل هذا؟",
@@ -427,26 +453,39 @@
"asset_list_settings_title": "شبكة الصور", "asset_list_settings_title": "شبكة الصور",
"asset_offline": "المحتوى غير اتصال", "asset_offline": "المحتوى غير اتصال",
"asset_offline_description": "لم يعد هذا الأصل الخارجي موجودًا على القرص. يرجى الاتصال بمسؤول Immich للحصول على المساعدة.", "asset_offline_description": "لم يعد هذا الأصل الخارجي موجودًا على القرص. يرجى الاتصال بمسؤول Immich للحصول على المساعدة.",
"asset_restored_successfully": "Asset restored successfully",
"asset_skipped": "تم تخطيه", "asset_skipped": "تم تخطيه",
"asset_skipped_in_trash": "في سلة المهملات", "asset_skipped_in_trash": "في سلة المهملات",
"asset_uploaded": "تم الرفع", "asset_uploaded": "تم الرفع",
"asset_uploading": "جارٍ الرفع…", "asset_uploading": "جارٍ الرفع…",
"asset_viewer_settings_subtitle": "Manage your gallery viewer settings",
"asset_viewer_settings_title": "عارض الأصول", "asset_viewer_settings_title": "عارض الأصول",
"assets": "المحتويات", "assets": "المحتويات",
"assets_added_count": "تمت إضافة {count, plural, one {# محتوى} other {# محتويات}}", "assets_added_count": "تمت إضافة {count, plural, one {# محتوى} other {# محتويات}}",
"assets_added_to_album_count": "تمت إضافة {count, plural, one {# الأصل} other {# الأصول}} إلى الألبوم", "assets_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_added_to_name_count": "تم إضافة {count, plural, one {# محتوى} other {# محتويات }} إلى {hasName, select, true {<b>{name}</b>} other {ألبوم جديد}}",
"assets_count": "{count, plural, one {# محتوى} other {# محتويات}}", "assets_count": "{count, plural, one {# محتوى} other {# محتويات}}",
"assets_deleted_permanently": "{} asset(s) deleted permanently",
"assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server",
"assets_moved_to_trash_count": "تم نقل {count, plural, one {# محتوى} other {# محتويات}} إلى سلة المهملات", "assets_moved_to_trash_count": "تم نقل {count, plural, one {# محتوى} other {# محتويات}} إلى سلة المهملات",
"assets_permanently_deleted_count": "تم حذف {count, plural, one {# هذا المحتوى} other {# هذه المحتويات}} بشكل دائم", "assets_permanently_deleted_count": "تم حذف {count, plural, one {# هذا المحتوى} other {# هذه المحتويات}} بشكل دائم",
"assets_removed_count": "تمت إزالة {count, plural, one {# محتوى} other {# محتويات}}", "assets_removed_count": "تمت إزالة {count, plural, one {# محتوى} other {# محتويات}}",
"assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device",
"assets_restore_confirmation": "هل أنت متأكد من أنك تريد استعادة جميع الأصول المحذوفة؟ لا يمكنك التراجع عن هذا الإجراء! لاحظ أنه لا يمكن استعادة أي أصول غير متصلة بهذه الطريقة.", "assets_restore_confirmation": "هل أنت متأكد من أنك تريد استعادة جميع الأصول المحذوفة؟ لا يمكنك التراجع عن هذا الإجراء! لاحظ أنه لا يمكن استعادة أي أصول غير متصلة بهذه الطريقة.",
"assets_restored_count": "تمت استعادة {count, plural, one {# محتوى} other {# محتويات}}", "assets_restored_count": "تمت استعادة {count, plural, one {# محتوى} other {# محتويات}}",
"assets_restored_successfully": "{} asset(s) restored successfully",
"assets_trashed": "{} asset(s) trashed",
"assets_trashed_count": "تم إرسال {count, plural, one {# محتوى} other {# محتويات}} إلى سلة المهملات", "assets_trashed_count": "تم إرسال {count, plural, one {# محتوى} other {# محتويات}} إلى سلة المهملات",
"assets_trashed_from_server": "{} asset(s) trashed from the Immich server",
"assets_were_part_of_album_count": "{count, plural, one {هذا المحتوى} other {هذه المحتويات}} في الألبوم بالفعل", "assets_were_part_of_album_count": "{count, plural, one {هذا المحتوى} other {هذه المحتويات}} في الألبوم بالفعل",
"authorized_devices": "الأجهزه المخولة", "authorized_devices": "الأجهزه المخولة",
"automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere",
"automatic_endpoint_switching_title": "Automatic URL switching",
"back": "خلف", "back": "خلف",
"back_close_deselect": "الرجوع أو الإغلاق أو إلغاء التحديد", "back_close_deselect": "الرجوع أو الإغلاق أو إلغاء التحديد",
"background_location_permission": "Background location permission",
"background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name",
"backup_album_selection_page_albums_device": "Albums on device ({})",
"backup_album_selection_page_albums_tap": "انقر للتضمين، وانقر نقرًا مزدوجًا للاستثناء", "backup_album_selection_page_albums_tap": "انقر للتضمين، وانقر نقرًا مزدوجًا للاستثناء",
"backup_album_selection_page_assets_scatter": "يمكن أن تنتشر الأصول عبر ألبومات متعددة. وبالتالي، يمكن تضمين الألبومات أو استبعادها أثناء عملية النسخ الاحتياطي.", "backup_album_selection_page_assets_scatter": "يمكن أن تنتشر الأصول عبر ألبومات متعددة. وبالتالي، يمكن تضمين الألبومات أو استبعادها أثناء عملية النسخ الاحتياطي.",
"backup_album_selection_page_select_albums": "حدد الألبومات", "backup_album_selection_page_select_albums": "حدد الألبومات",
@@ -455,9 +494,11 @@
"backup_all": "الجميع", "backup_all": "الجميع",
"backup_background_service_backup_failed_message": "فشل في النسخ الاحتياطي للأصول. جارٍ إعادة المحاولة...", "backup_background_service_backup_failed_message": "فشل في النسخ الاحتياطي للأصول. جارٍ إعادة المحاولة...",
"backup_background_service_connection_failed_message": "فشل في الاتصال بالخادم. جارٍ إعادة المحاولة...", "backup_background_service_connection_failed_message": "فشل في الاتصال بالخادم. جارٍ إعادة المحاولة...",
"backup_background_service_current_upload_notification": "Uploading {}",
"backup_background_service_default_notification": "التحقق من الأصول الجديدة ...", "backup_background_service_default_notification": "التحقق من الأصول الجديدة ...",
"backup_background_service_error_title": "خطأ في النسخ الاحتياطي", "backup_background_service_error_title": "خطأ في النسخ الاحتياطي",
"backup_background_service_in_progress_notification": "النسخ الاحتياطي للأصول الخاصة بك...", "backup_background_service_in_progress_notification": "النسخ الاحتياطي للأصول الخاصة بك...",
"backup_background_service_upload_failure_notification": "Failed to upload {}",
"backup_controller_page_albums": "ألبومات احتياطية", "backup_controller_page_albums": "ألبومات احتياطية",
"backup_controller_page_background_app_refresh_disabled_content": "قم بتمكين تحديث تطبيق الخلفية في الإعدادات > عام > تحديث تطبيق الخلفية لاستخدام النسخ الاحتياطي في الخلفية.", "backup_controller_page_background_app_refresh_disabled_content": "قم بتمكين تحديث تطبيق الخلفية في الإعدادات > عام > تحديث تطبيق الخلفية لاستخدام النسخ الاحتياطي في الخلفية.",
"backup_controller_page_background_app_refresh_disabled_title": "تم تعطيل تحديث التطبيق في الخلفية", "backup_controller_page_background_app_refresh_disabled_title": "تم تعطيل تحديث التطبيق في الخلفية",
@@ -468,6 +509,7 @@
"backup_controller_page_background_battery_info_title": "تحسين البطارية", "backup_controller_page_background_battery_info_title": "تحسين البطارية",
"backup_controller_page_background_charging": "فقط أثناء الشحن", "backup_controller_page_background_charging": "فقط أثناء الشحن",
"backup_controller_page_background_configure_error": "فشل في تكوين خدمة الخلفية", "backup_controller_page_background_configure_error": "فشل في تكوين خدمة الخلفية",
"backup_controller_page_background_delay": "Delay new assets backup: {}",
"backup_controller_page_background_description": "قم بتشغيل خدمة الخلفية لإجراء نسخ احتياطي لأي أصول جديدة تلقائيًا دون الحاجة إلى فتح التطبيق", "backup_controller_page_background_description": "قم بتشغيل خدمة الخلفية لإجراء نسخ احتياطي لأي أصول جديدة تلقائيًا دون الحاجة إلى فتح التطبيق",
"backup_controller_page_background_is_off": "تم إيقاف النسخ الاحتياطي التلقائي للخلفية", "backup_controller_page_background_is_off": "تم إيقاف النسخ الاحتياطي التلقائي للخلفية",
"backup_controller_page_background_is_on": "النسخ الاحتياطي التلقائي للخلفية قيد التشغيل", "backup_controller_page_background_is_on": "النسخ الاحتياطي التلقائي للخلفية قيد التشغيل",
@@ -477,8 +519,12 @@
"backup_controller_page_backup": "دعم", "backup_controller_page_backup": "دعم",
"backup_controller_page_backup_selected": "المحدد: ", "backup_controller_page_backup_selected": "المحدد: ",
"backup_controller_page_backup_sub": "النسخ الاحتياطي للصور ومقاطع الفيديو", "backup_controller_page_backup_sub": "النسخ الاحتياطي للصور ومقاطع الفيديو",
"backup_controller_page_created": "Created on: {}",
"backup_controller_page_desc_backup": "قم بتشغيل النسخ الاحتياطي الأمامي لتحميل الأصول الجديدة تلقائيًا إلى الخادم عند فتح التطبيق.", "backup_controller_page_desc_backup": "قم بتشغيل النسخ الاحتياطي الأمامي لتحميل الأصول الجديدة تلقائيًا إلى الخادم عند فتح التطبيق.",
"backup_controller_page_excluded": "مستبعد: ", "backup_controller_page_excluded": "مستبعد: ",
"backup_controller_page_failed": "Failed ({})",
"backup_controller_page_filename": "File name: {} [{}]",
"backup_controller_page_id": "ID: {}",
"backup_controller_page_info": "معلومات النسخ الاحتياطي", "backup_controller_page_info": "معلومات النسخ الاحتياطي",
"backup_controller_page_none_selected": "لم يتم التحديد", "backup_controller_page_none_selected": "لم يتم التحديد",
"backup_controller_page_remainder": "بقية", "backup_controller_page_remainder": "بقية",
@@ -487,6 +533,7 @@
"backup_controller_page_start_backup": "بدء النسخ الاحتياطي", "backup_controller_page_start_backup": "بدء النسخ الاحتياطي",
"backup_controller_page_status_off": "النسخة الاحتياطية التلقائية غير فعالة", "backup_controller_page_status_off": "النسخة الاحتياطية التلقائية غير فعالة",
"backup_controller_page_status_on": "النسخة الاحتياطية التلقائية فعالة", "backup_controller_page_status_on": "النسخة الاحتياطية التلقائية فعالة",
"backup_controller_page_storage_format": "{} of {} used",
"backup_controller_page_to_backup": "الألبومات الاحتياطية", "backup_controller_page_to_backup": "الألبومات الاحتياطية",
"backup_controller_page_total_sub": "جميع الصور ومقاطع الفيديو الفريدة من ألبومات مختارة", "backup_controller_page_total_sub": "جميع الصور ومقاطع الفيديو الفريدة من ألبومات مختارة",
"backup_controller_page_turn_off": "قم بإيقاف تشغيل النسخ الاحتياطي المقدمة", "backup_controller_page_turn_off": "قم بإيقاف تشغيل النسخ الاحتياطي المقدمة",
@@ -499,6 +546,7 @@
"backup_manual_success": "نجاح", "backup_manual_success": "نجاح",
"backup_manual_title": "حالة التحميل", "backup_manual_title": "حالة التحميل",
"backup_options_page_title": "خيارات النسخ الاحتياطي", "backup_options_page_title": "خيارات النسخ الاحتياطي",
"backup_setting_subtitle": "Manage background and foreground upload settings",
"backward": "الى الوراء", "backward": "الى الوراء",
"birthdate_saved": "تم حفظ تاريخ الميلاد بنجاح", "birthdate_saved": "تم حفظ تاريخ الميلاد بنجاح",
"birthdate_set_description": "يتم استخدام تاريخ الميلاد لحساب عمر هذا الشخص وقت التقاط الصورة.", "birthdate_set_description": "يتم استخدام تاريخ الميلاد لحساب عمر هذا الشخص وقت التقاط الصورة.",
@@ -510,16 +558,21 @@
"bulk_keep_duplicates_confirmation": "هل أنت متأكد من أنك تريد الاحتفاظ بـ {count, plural, one {# محتوى مكرر} other {# محتويات مكررة}}؟ سيؤدي هذا إلى حل جميع مجموعات النسخ المكررة دون حذف أي شيء.", "bulk_keep_duplicates_confirmation": "هل أنت متأكد من أنك تريد الاحتفاظ بـ {count, plural, one {# محتوى مكرر} other {# محتويات مكررة}}؟ سيؤدي هذا إلى حل جميع مجموعات النسخ المكررة دون حذف أي شيء.",
"bulk_trash_duplicates_confirmation": "هل أنت متأكد من أنك تريد إرسال {count, plural, one {# محتوى مكرر} other {# محتويات مكررة}} إلى سلة المهملات ؟ سيحتفظ هذا بأكبر محتوى من كل مجموعة ويرسل جميع النسخ المكررة الأخرى إلى سلة المهملات.", "bulk_trash_duplicates_confirmation": "هل أنت متأكد من أنك تريد إرسال {count, plural, one {# محتوى مكرر} other {# محتويات مكررة}} إلى سلة المهملات ؟ سيحتفظ هذا بأكبر محتوى من كل مجموعة ويرسل جميع النسخ المكررة الأخرى إلى سلة المهملات.",
"buy": "شراء immich", "buy": "شراء immich",
"cache_settings_album_thumbnails": "Library page thumbnails ({} assets)",
"cache_settings_clear_cache_button": "مسح ذاكرة التخزين المؤقت", "cache_settings_clear_cache_button": "مسح ذاكرة التخزين المؤقت",
"cache_settings_clear_cache_button_title": "يقوم بمسح ذاكرة التخزين المؤقت للتطبيق.سيؤثر هذا بشكل كبير على أداء التطبيق حتى إعادة بناء ذاكرة التخزين المؤقت.", "cache_settings_clear_cache_button_title": "يقوم بمسح ذاكرة التخزين المؤقت للتطبيق.سيؤثر هذا بشكل كبير على أداء التطبيق حتى إعادة بناء ذاكرة التخزين المؤقت.",
"cache_settings_duplicated_assets_clear_button": "واضح", "cache_settings_duplicated_assets_clear_button": "واضح",
"cache_settings_duplicated_assets_subtitle": "الصور ومقاطع الفيديو اللتي تم تجاهلها المدرجة في التطبيق", "cache_settings_duplicated_assets_subtitle": "الصور ومقاطع الفيديو اللتي تم تجاهلها المدرجة في التطبيق",
"cache_settings_duplicated_assets_title": "Duplicated Assets ({})",
"cache_settings_image_cache_size": "Image cache size ({} assets)",
"cache_settings_statistics_album": "مكتبه الصور المصغره", "cache_settings_statistics_album": "مكتبه الصور المصغره",
"cache_settings_statistics_assets": "{} assets ({})",
"cache_settings_statistics_full": "صور كاملة", "cache_settings_statistics_full": "صور كاملة",
"cache_settings_statistics_shared": "صورة ألبوم مشتركة", "cache_settings_statistics_shared": "صورة ألبوم مشتركة",
"cache_settings_statistics_thumbnail": "الصورة المصغرة", "cache_settings_statistics_thumbnail": "الصورة المصغرة",
"cache_settings_statistics_title": "استخدام ذاكرة التخزين المؤقت", "cache_settings_statistics_title": "استخدام ذاكرة التخزين المؤقت",
"cache_settings_subtitle": "تحكم في سلوك التخزين المؤقت لتطبيق الجوال.", "cache_settings_subtitle": "تحكم في سلوك التخزين المؤقت لتطبيق الجوال.",
"cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)",
"cache_settings_tile_subtitle": "التحكم في سلوك التخزين المحلي", "cache_settings_tile_subtitle": "التحكم في سلوك التخزين المحلي",
"cache_settings_tile_title": "التخزين المحلي", "cache_settings_tile_title": "التخزين المحلي",
"cache_settings_title": "إعدادات التخزين المؤقت", "cache_settings_title": "إعدادات التخزين المؤقت",
@@ -528,10 +581,12 @@
"camera_model": "طراز الكاميرا", "camera_model": "طراز الكاميرا",
"cancel": "إلغاء", "cancel": "إلغاء",
"cancel_search": "الغي البحث", "cancel_search": "الغي البحث",
"canceled": "Canceled",
"cannot_merge_people": "لا يمكن دمج الأشخاص", "cannot_merge_people": "لا يمكن دمج الأشخاص",
"cannot_undo_this_action": "لا يمكنك التراجع عن هذا الإجراء!", "cannot_undo_this_action": "لا يمكنك التراجع عن هذا الإجراء!",
"cannot_update_the_description": "لا يمكن تحديث الوصف", "cannot_update_the_description": "لا يمكن تحديث الوصف",
"change_date": "غيّر التاريخ", "change_date": "غيّر التاريخ",
"change_display_order": "Change display order",
"change_expiration_time": "تغيير وقت انتهاء الصلاحية", "change_expiration_time": "تغيير وقت انتهاء الصلاحية",
"change_location": "غيّر الموقع", "change_location": "غيّر الموقع",
"change_name": "تغيير الإسم", "change_name": "تغيير الإسم",
@@ -543,9 +598,12 @@
"change_password_form_new_password": "كلمة المرور الجديدة", "change_password_form_new_password": "كلمة المرور الجديدة",
"change_password_form_password_mismatch": "كلمة المرور غير مطابقة", "change_password_form_password_mismatch": "كلمة المرور غير مطابقة",
"change_password_form_reenter_new_password": "أعد إدخال كلمة مرور جديدة", "change_password_form_reenter_new_password": "أعد إدخال كلمة مرور جديدة",
"change_pin_code": "تغيير الرقم السري",
"change_your_password": "غير كلمة المرور الخاصة بك", "change_your_password": "غير كلمة المرور الخاصة بك",
"changed_visibility_successfully": "تم تغيير الرؤية بنجاح", "changed_visibility_successfully": "تم تغيير الرؤية بنجاح",
"check_all": "تحقق من الكل",
"check_corrupt_asset_backup": "Check for corrupt asset backups",
"check_corrupt_asset_backup_button": "Perform check",
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
"check_logs": "تحقق من السجلات", "check_logs": "تحقق من السجلات",
"choose_matching_people_to_merge": "اختر الأشخاص المتطابقين لدمجهم", "choose_matching_people_to_merge": "اختر الأشخاص المتطابقين لدمجهم",
"city": "المدينة", "city": "المدينة",
@@ -554,6 +612,14 @@
"clear_all_recent_searches": "مسح جميع عمليات البحث الأخيرة", "clear_all_recent_searches": "مسح جميع عمليات البحث الأخيرة",
"clear_message": "إخلاء الرسالة", "clear_message": "إخلاء الرسالة",
"clear_value": "إخلاء القيمة", "clear_value": "إخلاء القيمة",
"client_cert_dialog_msg_confirm": "OK",
"client_cert_enter_password": "Enter Password",
"client_cert_import": "Import",
"client_cert_import_success_msg": "Client certificate is imported",
"client_cert_invalid_msg": "Invalid certificate file or wrong password",
"client_cert_remove_msg": "Client certificate is removed",
"client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login",
"client_cert_title": "SSL Client Certificate",
"clockwise": "باتجاه عقارب الساعة", "clockwise": "باتجاه عقارب الساعة",
"close": "إغلاق", "close": "إغلاق",
"collapse": "طي", "collapse": "طي",
@@ -566,21 +632,23 @@
"comments_are_disabled": "التعليقات معطلة", "comments_are_disabled": "التعليقات معطلة",
"common_create_new_album": "إنشاء ألبوم جديد", "common_create_new_album": "إنشاء ألبوم جديد",
"common_server_error": "يرجى التحقق من اتصال الشبكة الخاص بك ، والتأكد من أن الجهاز قابل للوصول وإصدارات التطبيق/الجهاز متوافقة.", "common_server_error": "يرجى التحقق من اتصال الشبكة الخاص بك ، والتأكد من أن الجهاز قابل للوصول وإصدارات التطبيق/الجهاز متوافقة.",
"completed": "Completed",
"confirm": "تأكيد", "confirm": "تأكيد",
"confirm_admin_password": "تأكيد كلمة مرور المسؤول", "confirm_admin_password": "تأكيد كلمة مرور المسؤول",
"confirm_delete_face": "هل أنت متأكد من حذف وجه {name} من الأصول؟", "confirm_delete_face": "هل أنت متأكد من حذف وجه {name} من الأصول؟",
"confirm_delete_shared_link": "هل أنت متأكد أنك تريد حذف هذا الرابط المشترك؟", "confirm_delete_shared_link": "هل أنت متأكد أنك تريد حذف هذا الرابط المشترك؟",
"confirm_keep_this_delete_others": "سيتم حذف جميع الأصول الأخرى في المجموعة باستثناء هذا الأصل. هل أنت متأكد من أنك تريد المتابعة؟", "confirm_keep_this_delete_others": "سيتم حذف جميع الأصول الأخرى في المجموعة باستثناء هذا الأصل. هل أنت متأكد من أنك تريد المتابعة؟",
"confirm_new_pin_code": "ثبت الرقم السري الجديد",
"confirm_password": "تأكيد كلمة المرور", "confirm_password": "تأكيد كلمة المرور",
"contain": "محتواة", "contain": "محتواة",
"context": "السياق", "context": "السياق",
"continue": "متابعة", "continue": "متابعة",
"control_bottom_app_bar_album_info_shared": "{} items · Shared",
"control_bottom_app_bar_create_new_album": "إنشاء ألبوم جديد", "control_bottom_app_bar_create_new_album": "إنشاء ألبوم جديد",
"control_bottom_app_bar_delete_from_immich": " حذف منال تطبيق", "control_bottom_app_bar_delete_from_immich": " حذف منال تطبيق",
"control_bottom_app_bar_delete_from_local": "حذف من الجهاز", "control_bottom_app_bar_delete_from_local": "حذف من الجهاز",
"control_bottom_app_bar_edit_location": "تحديد الوجهة", "control_bottom_app_bar_edit_location": "تحديد الوجهة",
"control_bottom_app_bar_edit_time": "تحرير التاريخ والوقت", "control_bottom_app_bar_edit_time": "تحرير التاريخ والوقت",
"control_bottom_app_bar_share_link": "Share Link",
"control_bottom_app_bar_share_to": "مشاركة إلى", "control_bottom_app_bar_share_to": "مشاركة إلى",
"control_bottom_app_bar_trash_from_immich": "حذفه ونقله في سله المهملات", "control_bottom_app_bar_trash_from_immich": "حذفه ونقله في سله المهملات",
"copied_image_to_clipboard": "تم نسخ الصورة إلى الحافظة.", "copied_image_to_clipboard": "تم نسخ الصورة إلى الحافظة.",
@@ -602,6 +670,7 @@
"create_link": "إنشاء رابط", "create_link": "إنشاء رابط",
"create_link_to_share": "إنشاء رابط للمشاركة", "create_link_to_share": "إنشاء رابط للمشاركة",
"create_link_to_share_description": "السماح لأي شخص لديه الرابط بمشاهدة الصورة (الصور) المحددة", "create_link_to_share_description": "السماح لأي شخص لديه الرابط بمشاهدة الصورة (الصور) المحددة",
"create_new": "CREATE NEW",
"create_new_person": "إنشاء شخص جديد", "create_new_person": "إنشاء شخص جديد",
"create_new_person_hint": "تعيين المحتويات المحددة لشخص جديد", "create_new_person_hint": "تعيين المحتويات المحددة لشخص جديد",
"create_new_user": "إنشاء مستخدم جديد", "create_new_user": "إنشاء مستخدم جديد",
@@ -611,9 +680,10 @@
"create_tag_description": "أنشئ علامة جديدة. بالنسبة للعلامات المتداخلة، يرجى إدخال المسار الكامل للعلامة بما في ذلك الخطوط المائلة للأمام.", "create_tag_description": "أنشئ علامة جديدة. بالنسبة للعلامات المتداخلة، يرجى إدخال المسار الكامل للعلامة بما في ذلك الخطوط المائلة للأمام.",
"create_user": "إنشاء مستخدم", "create_user": "إنشاء مستخدم",
"created": "تم الإنشاء", "created": "تم الإنشاء",
"crop": "Crop",
"curated_object_page_title": "أشياء", "curated_object_page_title": "أشياء",
"current_device": "الجهاز الحالي", "current_device": "الجهاز الحالي",
"current_pin_code": "الرقم السري الحالي", "current_server_address": "Current server address",
"custom_locale": "لغة مخصصة", "custom_locale": "لغة مخصصة",
"custom_locale_description": "تنسيق التواريخ والأرقام بناءً على اللغة والمنطقة", "custom_locale_description": "تنسيق التواريخ والأرقام بناءً على اللغة والمنطقة",
"daily_title_text_date": "E ، MMM DD", "daily_title_text_date": "E ، MMM DD",
@@ -664,6 +734,7 @@
"direction": "الإتجاه", "direction": "الإتجاه",
"disabled": "معطل", "disabled": "معطل",
"disallow_edits": "منع التعديلات", "disallow_edits": "منع التعديلات",
"discord": "Discord",
"discover": "اكتشف", "discover": "اكتشف",
"dismiss_all_errors": "تجاهل كافة الأخطاء", "dismiss_all_errors": "تجاهل كافة الأخطاء",
"dismiss_error": "تجاهل الخطأ", "dismiss_error": "تجاهل الخطأ",
@@ -675,12 +746,26 @@
"documentation": "الوثائق", "documentation": "الوثائق",
"done": "تم", "done": "تم",
"download": "تنزيل", "download": "تنزيل",
"download_canceled": "Download canceled",
"download_complete": "Download complete",
"download_enqueue": "Download enqueued",
"download_error": "Download Error",
"download_failed": "Download failed",
"download_filename": "file: {}",
"download_finished": "Download finished",
"download_include_embedded_motion_videos": "مقاطع الفيديو المدمجة", "download_include_embedded_motion_videos": "مقاطع الفيديو المدمجة",
"download_include_embedded_motion_videos_description": "تضمين مقاطع الفيديو المضمنة في الصور المتحركة كملف منفصل", "download_include_embedded_motion_videos_description": "تضمين مقاطع الفيديو المضمنة في الصور المتحركة كملف منفصل",
"download_notfound": "Download not found",
"download_paused": "Download paused",
"download_settings": "التنزيلات", "download_settings": "التنزيلات",
"download_settings_description": "إدارة الإعدادات المتعلقة بتنزيل المحتويات", "download_settings_description": "إدارة الإعدادات المتعلقة بتنزيل المحتويات",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"download_waiting_to_retry": "Waiting to retry",
"downloading": "جارٍ التنزيل", "downloading": "جارٍ التنزيل",
"downloading_asset_filename": "{filename} قيد التنزيل", "downloading_asset_filename": "{filename} قيد التنزيل",
"downloading_media": "Downloading media",
"drop_files_to_upload": "قم بإسقاط الملفات في أي مكان لرفعها", "drop_files_to_upload": "قم بإسقاط الملفات في أي مكان لرفعها",
"duplicates": "التكرارات", "duplicates": "التكرارات",
"duplicates_description": "قم بحل كل مجموعة من خلال الإشارة إلى التكرارات، إن وجدت", "duplicates_description": "قم بحل كل مجموعة من خلال الإشارة إلى التكرارات، إن وجدت",
@@ -710,15 +795,19 @@
"editor_crop_tool_h2_aspect_ratios": "نسب العرض إلى الارتفاع", "editor_crop_tool_h2_aspect_ratios": "نسب العرض إلى الارتفاع",
"editor_crop_tool_h2_rotation": "التدوير", "editor_crop_tool_h2_rotation": "التدوير",
"email": "البريد الإلكتروني", "email": "البريد الإلكتروني",
"empty_folder": "This folder is empty",
"empty_trash": "أفرغ سلة المهملات", "empty_trash": "أفرغ سلة المهملات",
"empty_trash_confirmation": "هل أنت متأكد أنك تريد إفراغ سلة المهملات؟ سيؤدي هذا إلى إزالة جميع المحتويات الموجودة في سلة المهملات بشكل نهائي من Immich.\nلا يمكنك التراجع عن هذا الإجراء!", "empty_trash_confirmation": "هل أنت متأكد أنك تريد إفراغ سلة المهملات؟ سيؤدي هذا إلى إزالة جميع المحتويات الموجودة في سلة المهملات بشكل نهائي من Immich.\nلا يمكنك التراجع عن هذا الإجراء!",
"enable": "تفعيل", "enable": "تفعيل",
"enabled": "مفعل", "enabled": "مفعل",
"end_date": "تاريخ الإنتهاء", "end_date": "تاريخ الإنتهاء",
"enqueued": "Enqueued",
"enter_wifi_name": "Enter WiFi name", "enter_wifi_name": "Enter WiFi name",
"error": "خطأ", "error": "خطأ",
"error_change_sort_album": "Failed to change album sort order",
"error_delete_face": "حدث خطأ في حذف الوجه من الأصول", "error_delete_face": "حدث خطأ في حذف الوجه من الأصول",
"error_loading_image": "حدث خطأ أثناء تحميل الصورة", "error_loading_image": "حدث خطأ أثناء تحميل الصورة",
"error_saving_image": "Error: {}",
"error_title": "خطأ - حدث خللٌ ما", "error_title": "خطأ - حدث خللٌ ما",
"errors": { "errors": {
"cannot_navigate_next_asset": "لا يمكن الانتقال إلى المحتوى التالي", "cannot_navigate_next_asset": "لا يمكن الانتقال إلى المحتوى التالي",
@@ -731,6 +820,7 @@
"cant_get_number_of_comments": "لا يمكن الحصول على عدد التعليقات", "cant_get_number_of_comments": "لا يمكن الحصول على عدد التعليقات",
"cant_search_people": "لا يمكن البحث عن الناس", "cant_search_people": "لا يمكن البحث عن الناس",
"cant_search_places": "لا يمكن البحث عن الأماكن", "cant_search_places": "لا يمكن البحث عن الأماكن",
"cleared_jobs": "اُخليت المهام لـ: {job}",
"error_adding_assets_to_album": "حدث خطأٌ أثناء إضافة المحتويات إلى الألبوم", "error_adding_assets_to_album": "حدث خطأٌ أثناء إضافة المحتويات إلى الألبوم",
"error_adding_users_to_album": "حدث خطأٌ أثناء إضافة المستخدمين إلى الألبوم", "error_adding_users_to_album": "حدث خطأٌ أثناء إضافة المستخدمين إلى الألبوم",
"error_deleting_shared_user": "حدث خطأٌ أثناء حذف المستخدم المشترك", "error_deleting_shared_user": "حدث خطأٌ أثناء حذف المستخدم المشترك",
@@ -739,6 +829,7 @@
"error_removing_assets_from_album": "خطأٌّ في إزالة المحتويات من الألبوم، تحقق من وحدة التحكم للحصول على مزيدٍ من التفاصيل", "error_removing_assets_from_album": "خطأٌّ في إزالة المحتويات من الألبوم، تحقق من وحدة التحكم للحصول على مزيدٍ من التفاصيل",
"error_selecting_all_assets": "خطأٌ في تحديد جميع المحتويات", "error_selecting_all_assets": "خطأٌ في تحديد جميع المحتويات",
"exclusion_pattern_already_exists": "نمط الاستبعاد هذا موجود مسبقًا.", "exclusion_pattern_already_exists": "نمط الاستبعاد هذا موجود مسبقًا.",
"failed_job_command": "فشل الأمر {command} لوظيفة: {job}",
"failed_to_create_album": "فشل إنشاء الألبوم", "failed_to_create_album": "فشل إنشاء الألبوم",
"failed_to_create_shared_link": "فشل إنشاء رابط مشترك", "failed_to_create_shared_link": "فشل إنشاء رابط مشترك",
"failed_to_edit_shared_link": "فشل تعديل الرابط المشترك", "failed_to_edit_shared_link": "فشل تعديل الرابط المشترك",
@@ -755,6 +846,7 @@
"paths_validation_failed": "فشل في التحقق من {paths, plural, one {# مسار} other {# مسارات}}", "paths_validation_failed": "فشل في التحقق من {paths, plural, one {# مسار} other {# مسارات}}",
"profile_picture_transparent_pixels": "لا يمكن أن تحتوي صور الملف الشخصي على أجزاء/بكسلات شفافة. يرجى التكبير و/أو تحريك الصورة.", "profile_picture_transparent_pixels": "لا يمكن أن تحتوي صور الملف الشخصي على أجزاء/بكسلات شفافة. يرجى التكبير و/أو تحريك الصورة.",
"quota_higher_than_disk_size": "لقد قمت بتعيين حصة نسبية أعلى من حجم القرص", "quota_higher_than_disk_size": "لقد قمت بتعيين حصة نسبية أعلى من حجم القرص",
"repair_unable_to_check_items": "تعذر التحقق من {count, select, one {عنصر} other {عناصر}}",
"unable_to_add_album_users": "تعذر إضافة مستخدمين إلى الألبوم", "unable_to_add_album_users": "تعذر إضافة مستخدمين إلى الألبوم",
"unable_to_add_assets_to_shared_link": "تعذر إضافة المحتويات إلى الرابط المشترك", "unable_to_add_assets_to_shared_link": "تعذر إضافة المحتويات إلى الرابط المشترك",
"unable_to_add_comment": "تعذر إضافة التعليق", "unable_to_add_comment": "تعذر إضافة التعليق",
@@ -772,6 +864,7 @@
"unable_to_change_visibility": "غير قادر على تغيير الظهور لـ {count, plural, one {# شخص} other {# أشخاص}}", "unable_to_change_visibility": "غير قادر على تغيير الظهور لـ {count, plural, one {# شخص} other {# أشخاص}}",
"unable_to_complete_oauth_login": "غير قادر على إكمال تسجيل الدخول عبر OAuth", "unable_to_complete_oauth_login": "غير قادر على إكمال تسجيل الدخول عبر OAuth",
"unable_to_connect": "غير قادر على الإتصال", "unable_to_connect": "غير قادر على الإتصال",
"unable_to_connect_to_server": "غير قادر على الإتصال بالسيرفر",
"unable_to_copy_to_clipboard": "لا يمكن النسخ إلى الحافظة، تأكد من استخدامك للصفحة عبر https", "unable_to_copy_to_clipboard": "لا يمكن النسخ إلى الحافظة، تأكد من استخدامك للصفحة عبر https",
"unable_to_create_admin_account": "غير قادر على إنشاء حساب المسؤول", "unable_to_create_admin_account": "غير قادر على إنشاء حساب المسؤول",
"unable_to_create_api_key": "غير قادر على إنشاء مفتاح API جديد", "unable_to_create_api_key": "غير قادر على إنشاء مفتاح API جديد",
@@ -795,6 +888,10 @@
"unable_to_hide_person": "غير قادر على إخفاء الشخص", "unable_to_hide_person": "غير قادر على إخفاء الشخص",
"unable_to_link_motion_video": "غير قادر على ربط فيديو الحركة", "unable_to_link_motion_video": "غير قادر على ربط فيديو الحركة",
"unable_to_link_oauth_account": "غير قادر على ربط حساب OAuth", "unable_to_link_oauth_account": "غير قادر على ربط حساب OAuth",
"unable_to_load_album": "غير قادر على تحميل الألبوم",
"unable_to_load_asset_activity": "غير قادر على تحميل نشاط المحتويات",
"unable_to_load_items": "غير قادر على تحميل العناصر",
"unable_to_load_liked_status": "غير قادر على تحميل حالة الإعجاب",
"unable_to_log_out_all_devices": "غير قادر على تسجيل الخروج من جميع الأجهزة", "unable_to_log_out_all_devices": "غير قادر على تسجيل الخروج من جميع الأجهزة",
"unable_to_log_out_device": "غير قادر على تسجيل الخروج من الجهاز", "unable_to_log_out_device": "غير قادر على تسجيل الخروج من الجهاز",
"unable_to_login_with_oauth": "غير قادر على تسجيل الدخول باستخدام OAuth", "unable_to_login_with_oauth": "غير قادر على تسجيل الدخول باستخدام OAuth",
@@ -805,9 +902,11 @@
"unable_to_remove_album_users": "تعذر إزالة المستخدمين من الألبوم", "unable_to_remove_album_users": "تعذر إزالة المستخدمين من الألبوم",
"unable_to_remove_api_key": "تعذر إزالة مفتاح API", "unable_to_remove_api_key": "تعذر إزالة مفتاح API",
"unable_to_remove_assets_from_shared_link": "غير قادر على إزالة المحتويات من الرابط المشترك", "unable_to_remove_assets_from_shared_link": "غير قادر على إزالة المحتويات من الرابط المشترك",
"unable_to_remove_deleted_assets": "غير قادر على إزالة الملفات غير المتصلة",
"unable_to_remove_library": "غير قادر على إزالة المكتبة", "unable_to_remove_library": "غير قادر على إزالة المكتبة",
"unable_to_remove_partner": "غير قادر على إزالة الشريك", "unable_to_remove_partner": "غير قادر على إزالة الشريك",
"unable_to_remove_reaction": "غير قادر على إزالة رد الفعل", "unable_to_remove_reaction": "غير قادر على إزالة رد الفعل",
"unable_to_repair_items": "غير قادر على إصلاح العناصر",
"unable_to_reset_password": "غير قادر على إعادة تعيين كلمة المرور", "unable_to_reset_password": "غير قادر على إعادة تعيين كلمة المرور",
"unable_to_resolve_duplicate": "غير قادر على حل التكرارات", "unable_to_resolve_duplicate": "غير قادر على حل التكرارات",
"unable_to_restore_assets": "غير قادر على استعادة المحتويات", "unable_to_restore_assets": "غير قادر على استعادة المحتويات",
@@ -842,6 +941,10 @@
"exif_bottom_sheet_location": "موقع", "exif_bottom_sheet_location": "موقع",
"exif_bottom_sheet_people": "الناس", "exif_bottom_sheet_people": "الناس",
"exif_bottom_sheet_person_add_person": "اضف اسما", "exif_bottom_sheet_person_add_person": "اضف اسما",
"exif_bottom_sheet_person_age": "Age {}",
"exif_bottom_sheet_person_age_months": "Age {} months",
"exif_bottom_sheet_person_age_year_months": "Age 1 year, {} months",
"exif_bottom_sheet_person_age_years": "Age {}",
"exit_slideshow": "خروج من العرض التقديمي", "exit_slideshow": "خروج من العرض التقديمي",
"expand_all": "توسيع الكل", "expand_all": "توسيع الكل",
"experimental_settings_new_asset_list_subtitle": "أعمال جارية", "experimental_settings_new_asset_list_subtitle": "أعمال جارية",
@@ -858,9 +961,12 @@
"extension": "الإمتداد", "extension": "الإمتداد",
"external": "خارجي", "external": "خارجي",
"external_libraries": "المكتبات الخارجية", "external_libraries": "المكتبات الخارجية",
"external_network": "External network",
"external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom",
"face_unassigned": "غير معين", "face_unassigned": "غير معين",
"failed": "Failed",
"failed_to_load_assets": "فشل تحميل الأصول", "failed_to_load_assets": "فشل تحميل الأصول",
"failed_to_load_folder": "Failed to load folder",
"favorite": "مفضل", "favorite": "مفضل",
"favorite_or_unfavorite_photo": "تفضيل أو إلغاء تفضيل الصورة", "favorite_or_unfavorite_photo": "تفضيل أو إلغاء تفضيل الصورة",
"favorites": "المفضلة", "favorites": "المفضلة",
@@ -872,18 +978,23 @@
"file_name_or_extension": "اسم الملف أو امتداده", "file_name_or_extension": "اسم الملف أو امتداده",
"filename": "اسم الملف", "filename": "اسم الملف",
"filetype": "نوع الملف", "filetype": "نوع الملف",
"filter": "Filter",
"filter_people": "تصفية الاشخاص", "filter_people": "تصفية الاشخاص",
"find_them_fast": "يمكنك العثور عليها بسرعة بالاسم من خلال البحث", "find_them_fast": "يمكنك العثور عليها بسرعة بالاسم من خلال البحث",
"fix_incorrect_match": "إصلاح المطابقة غير الصحيحة", "fix_incorrect_match": "إصلاح المطابقة غير الصحيحة",
"folder": "Folder",
"folder_not_found": "Folder not found",
"folders": "المجلدات", "folders": "المجلدات",
"folders_feature_description": "تصفح عرض المجلد للصور ومقاطع الفيديو الموجودة على نظام الملفات", "folders_feature_description": "تصفح عرض المجلد للصور ومقاطع الفيديو الموجودة على نظام الملفات",
"forward": "إلى الأمام", "forward": "إلى الأمام",
"general": "عام", "general": "عام",
"get_help": "الحصول على المساعدة", "get_help": "الحصول على المساعدة",
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
"getting_started": "البدء", "getting_started": "البدء",
"go_back": "الرجوع للخلف", "go_back": "الرجوع للخلف",
"go_to_folder": "اذهب إلى المجلد", "go_to_folder": "اذهب إلى المجلد",
"go_to_search": "اذهب إلى البحث", "go_to_search": "اذهب إلى البحث",
"grant_permission": "Grant permission",
"group_albums_by": "تجميع الألبومات حسب...", "group_albums_by": "تجميع الألبومات حسب...",
"group_country": "مجموعة البلد", "group_country": "مجموعة البلد",
"group_no": "بدون تجميع", "group_no": "بدون تجميع",
@@ -893,6 +1004,12 @@
"haptic_feedback_switch": "تمكين ردود الفعل اللمسية", "haptic_feedback_switch": "تمكين ردود الفعل اللمسية",
"haptic_feedback_title": "ردود فعل لمسية", "haptic_feedback_title": "ردود فعل لمسية",
"has_quota": "محدد بحصة", "has_quota": "محدد بحصة",
"header_settings_add_header_tip": "Add Header",
"header_settings_field_validator_msg": "Value cannot be empty",
"header_settings_header_name_input": "Header name",
"header_settings_header_value_input": "Header value",
"headers_settings_tile_subtitle": "Define proxy headers the app should send with each network request",
"headers_settings_tile_title": "Custom proxy headers",
"hi_user": "مرحبا {name} ({email})", "hi_user": "مرحبا {name} ({email})",
"hide_all_people": "إخفاء جميع الأشخاص", "hide_all_people": "إخفاء جميع الأشخاص",
"hide_gallery": "اخفاء المعرض", "hide_gallery": "اخفاء المعرض",
@@ -916,6 +1033,8 @@
"home_page_upload_err_limit": "لا يمكن إلا تحميل 30 أحد الأصول في وقت واحد ، سوف يتخطى", "home_page_upload_err_limit": "لا يمكن إلا تحميل 30 أحد الأصول في وقت واحد ، سوف يتخطى",
"host": "المضيف", "host": "المضيف",
"hour": "ساعة", "hour": "ساعة",
"ignore_icloud_photos": "Ignore iCloud photos",
"ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server",
"image": "صورة", "image": "صورة",
"image_alt_text_date": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {date}", "image_alt_text_date": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {date}",
"image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} تم التقاطها مع {person1} في {date}", "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} تم التقاطها مع {person1} في {date}",
@@ -927,6 +1046,7 @@
"image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {city}، {country} مع {person1} و{person2} في {date}", "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {city}، {country} مع {person1} و{person2} في {date}",
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {city}، {country} مع {person1}، {person2}، و{person3} في {date}", "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {city}، {country} مع {person1}، {person2}، و{person3} في {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {city}, {country} with {person1}, {person2}, مع {additionalCount, number} آخرين في {date}", "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {city}, {country} with {person1}, {person2}, مع {additionalCount, number} آخرين في {date}",
"image_saved_successfully": "Image saved",
"image_viewer_page_state_provider_download_started": "بدأ التنزيل", "image_viewer_page_state_provider_download_started": "بدأ التنزيل",
"image_viewer_page_state_provider_download_success": "تم التنزيل بنجاح", "image_viewer_page_state_provider_download_success": "تم التنزيل بنجاح",
"image_viewer_page_state_provider_share_error": "خطأ في المشاركة", "image_viewer_page_state_provider_share_error": "خطأ في المشاركة",
@@ -948,6 +1068,8 @@
"night_at_midnight": "كل ليلة عند منتصف الليل", "night_at_midnight": "كل ليلة عند منتصف الليل",
"night_at_twoam": "كل ليلة الساعة 2 صباحا" "night_at_twoam": "كل ليلة الساعة 2 صباحا"
}, },
"invalid_date": "Invalid date",
"invalid_date_format": "Invalid date format",
"invite_people": "دعوة الأشخاص", "invite_people": "دعوة الأشخاص",
"invite_to_album": "دعوة إلى الألبوم", "invite_to_album": "دعوة إلى الألبوم",
"items_count": "{count, plural, one {# عنصر} other {# عناصر}}", "items_count": "{count, plural, one {# عنصر} other {# عناصر}}",
@@ -983,6 +1105,9 @@
"list": "قائمة", "list": "قائمة",
"loading": "تحميل", "loading": "تحميل",
"loading_search_results_failed": "فشل تحميل نتائج البحث", "loading_search_results_failed": "فشل تحميل نتائج البحث",
"local_network": "Local network",
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
"location_permission": "Location permission",
"location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name",
"location_picker_choose_on_map": "اختر على الخريطة", "location_picker_choose_on_map": "اختر على الخريطة",
"location_picker_latitude_error": "أدخل خط عرض صالح", "location_picker_latitude_error": "أدخل خط عرض صالح",
@@ -998,6 +1123,7 @@
"login_form_api_exception": " استثناء برمجة التطبيقات. يرجى التحقق من عنوان الخادم والمحاولة مرة أخرى ", "login_form_api_exception": " استثناء برمجة التطبيقات. يرجى التحقق من عنوان الخادم والمحاولة مرة أخرى ",
"login_form_back_button_text": "الرجوع للخلف", "login_form_back_button_text": "الرجوع للخلف",
"login_form_email_hint": "yoursemail@email.com", "login_form_email_hint": "yoursemail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port",
"login_form_endpoint_url": "url نقطة نهاية الخادم", "login_form_endpoint_url": "url نقطة نهاية الخادم",
"login_form_err_http": "يرجى تحديد http:// أو https://", "login_form_err_http": "يرجى تحديد http:// أو https://",
"login_form_err_invalid_email": "بريد إلكتروني خاطئ", "login_form_err_invalid_email": "بريد إلكتروني خاطئ",
@@ -1031,6 +1157,8 @@
"manage_your_devices": "إدارة الأجهزة التي تم تسجيل الدخول إليها", "manage_your_devices": "إدارة الأجهزة التي تم تسجيل الدخول إليها",
"manage_your_oauth_connection": "إدارة اتصال OAuth الخاص بك", "manage_your_oauth_connection": "إدارة اتصال OAuth الخاص بك",
"map": "الخريطة", "map": "الخريطة",
"map_assets_in_bound": "{} photo",
"map_assets_in_bounds": "{} photos",
"map_cannot_get_user_location": "لا يمكن الحصول على موقع المستخدم", "map_cannot_get_user_location": "لا يمكن الحصول على موقع المستخدم",
"map_location_dialog_yes": "نعم", "map_location_dialog_yes": "نعم",
"map_location_picker_page_use_location": "استخدم هذا الموقع", "map_location_picker_page_use_location": "استخدم هذا الموقع",
@@ -1044,7 +1172,9 @@
"map_settings": "إعدادات الخريطة", "map_settings": "إعدادات الخريطة",
"map_settings_dark_mode": "الوضع المظلم", "map_settings_dark_mode": "الوضع المظلم",
"map_settings_date_range_option_day": "24 ساعة الماضية", "map_settings_date_range_option_day": "24 ساعة الماضية",
"map_settings_date_range_option_days": "Past {} days",
"map_settings_date_range_option_year": "السنة الفائتة", "map_settings_date_range_option_year": "السنة الفائتة",
"map_settings_date_range_option_years": "Past {} years",
"map_settings_dialog_title": "إعدادات الخريطة", "map_settings_dialog_title": "إعدادات الخريطة",
"map_settings_include_show_archived": "تشمل الأرشفة", "map_settings_include_show_archived": "تشمل الأرشفة",
"map_settings_include_show_partners": "تضمين الشركاء", "map_settings_include_show_partners": "تضمين الشركاء",
@@ -1059,6 +1189,8 @@
"memories_setting_description": "إدارة ما تراه في ذكرياتك", "memories_setting_description": "إدارة ما تراه في ذكرياتك",
"memories_start_over": "ابدأ من جديد", "memories_start_over": "ابدأ من جديد",
"memories_swipe_to_close": "اسحب لأعلى للإغلاق", "memories_swipe_to_close": "اسحب لأعلى للإغلاق",
"memories_year_ago": "A year ago",
"memories_years_ago": "{} years ago",
"memory": "ذكرى", "memory": "ذكرى",
"memory_lane_title": "ذكرياتٌ من {title}", "memory_lane_title": "ذكرياتٌ من {title}",
"menu": "القائمة", "menu": "القائمة",
@@ -1082,12 +1214,13 @@
"my_albums": "ألبوماتي", "my_albums": "ألبوماتي",
"name": "الاسم", "name": "الاسم",
"name_or_nickname": "الاسم أو اللقب", "name_or_nickname": "الاسم أو اللقب",
"networking_settings": "Networking",
"networking_subtitle": "Manage the server endpoint settings",
"never": "أبداً", "never": "أبداً",
"new_album": "البوم جديد", "new_album": "البوم جديد",
"new_api_key": "مفتاح API جديد", "new_api_key": "مفتاح API جديد",
"new_password": "كلمة المرور الجديدة", "new_password": "كلمة المرور الجديدة",
"new_person": "شخص جديد", "new_person": "شخص جديد",
"new_pin_code": "الرقم السري الجديد",
"new_user_created": "تم إنشاء مستخدم جديد", "new_user_created": "تم إنشاء مستخدم جديد",
"new_version_available": "إصدار جديد متاح", "new_version_available": "إصدار جديد متاح",
"newest_first": "الأحدث أولاً", "newest_first": "الأحدث أولاً",
@@ -1111,6 +1244,7 @@
"no_results_description": "جرب كلمة رئيسية مرادفة أو أكثر عمومية", "no_results_description": "جرب كلمة رئيسية مرادفة أو أكثر عمومية",
"no_shared_albums_message": "قم بإنشاء ألبوم لمشاركة الصور ومقاطع الفيديو مع الأشخاص في شبكتك", "no_shared_albums_message": "قم بإنشاء ألبوم لمشاركة الصور ومقاطع الفيديو مع الأشخاص في شبكتك",
"not_in_any_album": "ليست في أي ألبوم", "not_in_any_album": "ليست في أي ألبوم",
"not_selected": "Not selected",
"note_apply_storage_label_to_previously_uploaded assets": "ملاحظة: لتطبيق تسمية التخزين على المحتويات التي تم رفعها مسبقًا، قم بتشغيل", "note_apply_storage_label_to_previously_uploaded assets": "ملاحظة: لتطبيق تسمية التخزين على المحتويات التي تم رفعها مسبقًا، قم بتشغيل",
"notes": "ملاحظات", "notes": "ملاحظات",
"notification_permission_dialog_content": "لتمكين الإخطارات ، انتقل إلى الإعدادات و اختار السماح.", "notification_permission_dialog_content": "لتمكين الإخطارات ، انتقل إلى الإعدادات و اختار السماح.",
@@ -1120,13 +1254,18 @@
"notification_toggle_setting_description": "تفعيل إشعارات البريد الإلكتروني", "notification_toggle_setting_description": "تفعيل إشعارات البريد الإلكتروني",
"notifications": "إشعارات", "notifications": "إشعارات",
"notifications_setting_description": "إدارة الإشعارات", "notifications_setting_description": "إدارة الإشعارات",
"oauth": "OAuth",
"official_immich_resources": "الموارد الرسمية لشركة Immich", "official_immich_resources": "الموارد الرسمية لشركة Immich",
"offline": "غير متصل", "offline": "غير متصل",
"offline_paths": "مسارات غير متصلة",
"offline_paths_description": "قد تكون هذه النتائج بسبب الحذف اليدوي للملفات التي لا تشكل جزءًا من مكتبة خارجية.",
"ok": "نعم", "ok": "نعم",
"oldest_first": "الأقدم أولا", "oldest_first": "الأقدم أولا",
"on_this_device": "On this device",
"onboarding": "الإعداد الأولي", "onboarding": "الإعداد الأولي",
"onboarding_privacy_description": "تعتمد الميزات التالية (اختياري) على خدمات خارجية، ويمكن تعطيلها في أي وقت في إعدادات الإدارة.", "onboarding_privacy_description": "تعتمد الميزات التالية (اختياري) على خدمات خارجية، ويمكن تعطيلها في أي وقت في إعدادات الإدارة.",
"onboarding_theme_description": "اختر نسق الألوان للنسخة الخاصة بك. يمكنك تغيير ذلك لاحقًا في إعداداتك.", "onboarding_theme_description": "اختر نسق الألوان للنسخة الخاصة بك. يمكنك تغيير ذلك لاحقًا في إعداداتك.",
"onboarding_welcome_description": "لنقم بإعداد نسختك باستخدام بعض الإعدادات الشائعة.",
"onboarding_welcome_user": "مرحبا، {user}", "onboarding_welcome_user": "مرحبا، {user}",
"online": "متصل", "online": "متصل",
"only_favorites": "المفضلة فقط", "only_favorites": "المفضلة فقط",
@@ -1146,12 +1285,14 @@
"partner_can_access": "يستطيع {partner} الوصول", "partner_can_access": "يستطيع {partner} الوصول",
"partner_can_access_assets": "جميع الصور ومقاطع الفيديو الخاصة بك باستثناء تلك الموجودة في المؤرشفة والمحذوفة", "partner_can_access_assets": "جميع الصور ومقاطع الفيديو الخاصة بك باستثناء تلك الموجودة في المؤرشفة والمحذوفة",
"partner_can_access_location": "الموقع الذي تم التقاط صورك فيه", "partner_can_access_location": "الموقع الذي تم التقاط صورك فيه",
"partner_list_user_photos": "{user}'s photos",
"partner_list_view_all": "عرض الكل", "partner_list_view_all": "عرض الكل",
"partner_page_empty_message": "لم يتم مشاركة صورك بعد مع أي شريك.", "partner_page_empty_message": "لم يتم مشاركة صورك بعد مع أي شريك.",
"partner_page_no_more_users": "لا مزيد من المستخدمين لإضافة", "partner_page_no_more_users": "لا مزيد من المستخدمين لإضافة",
"partner_page_partner_add_failed": "فشل في إضافة شريك", "partner_page_partner_add_failed": "فشل في إضافة شريك",
"partner_page_select_partner": "حدد شريكًا", "partner_page_select_partner": "حدد شريكًا",
"partner_page_shared_to_title": "مشترك ل", "partner_page_shared_to_title": "مشترك ل",
"partner_page_stop_sharing_content": "{} will no longer be able to access your photos.",
"partner_sharing": "مشاركة الشركاء", "partner_sharing": "مشاركة الشركاء",
"partners": "الشركاء", "partners": "الشركاء",
"password": "كلمة المرور", "password": "كلمة المرور",
@@ -1197,9 +1338,6 @@
"photos_count": "{count, plural, one {{count, number} صورة} other {{count, number} صور}}", "photos_count": "{count, plural, one {{count, number} صورة} other {{count, number} صور}}",
"photos_from_previous_years": "صور من السنوات السابقة", "photos_from_previous_years": "صور من السنوات السابقة",
"pick_a_location": "اختر موقعًا", "pick_a_location": "اختر موقعًا",
"pin_code_changed_successfully": "تم تغير الرقم السري",
"pin_code_reset_successfully": "تم اعادة تعيين الرقم السري",
"pin_code_setup_successfully": "تم انشاء رقم سري",
"place": "مكان", "place": "مكان",
"places": "الأماكن", "places": "الأماكن",
"places_count": "{count, plural, one {{count, number} مكان} other {{count, number} أماكن}}", "places_count": "{count, plural, one {{count, number} مكان} other {{count, number} أماكن}}",
@@ -1208,6 +1346,7 @@
"play_motion_photo": "تشغيل الصور المتحركة", "play_motion_photo": "تشغيل الصور المتحركة",
"play_or_pause_video": "تشغيل الفيديو أو إيقافه مؤقتًا", "play_or_pause_video": "تشغيل الفيديو أو إيقافه مؤقتًا",
"port": "المنفذ", "port": "المنفذ",
"preferences_settings_subtitle": "Manage the app's preferences",
"preferences_settings_title": "التفضيلات", "preferences_settings_title": "التفضيلات",
"preset": "الإعداد المسبق", "preset": "الإعداد المسبق",
"preview": "معاينة", "preview": "معاينة",
@@ -1229,7 +1368,7 @@
"public_share": "مشاركة عامة", "public_share": "مشاركة عامة",
"purchase_account_info": "داعم", "purchase_account_info": "داعم",
"purchase_activated_subtitle": "شكرًا لك على دعمك لـ Immich والبرمجيات مفتوحة المصدر", "purchase_activated_subtitle": "شكرًا لك على دعمك لـ Immich والبرمجيات مفتوحة المصدر",
"purchase_activated_time": "تم التفعيل في {date}", "purchase_activated_time": "تم التفعيل في {date, date}",
"purchase_activated_title": "لقد تم تفعيل مفتاحك بنجاح", "purchase_activated_title": "لقد تم تفعيل مفتاحك بنجاح",
"purchase_button_activate": "تنشيط", "purchase_button_activate": "تنشيط",
"purchase_button_buy": "شراء", "purchase_button_buy": "شراء",
@@ -1272,6 +1411,7 @@
"recent": "حديث", "recent": "حديث",
"recent-albums": "ألبومات الحديثة", "recent-albums": "ألبومات الحديثة",
"recent_searches": "عمليات البحث الأخيرة", "recent_searches": "عمليات البحث الأخيرة",
"recently_added": "Recently added",
"recently_added_page_title": "أضيف مؤخرا", "recently_added_page_title": "أضيف مؤخرا",
"refresh": "تحديث", "refresh": "تحديث",
"refresh_encoded_videos": "تحديث مقاطع الفيديو المشفرة", "refresh_encoded_videos": "تحديث مقاطع الفيديو المشفرة",
@@ -1329,6 +1469,7 @@
"role_editor": "المحرر", "role_editor": "المحرر",
"role_viewer": "العارض", "role_viewer": "العارض",
"save": "حفظ", "save": "حفظ",
"save_to_gallery": "Save to gallery",
"saved_api_key": "تم حفظ مفتاح الـ API", "saved_api_key": "تم حفظ مفتاح الـ API",
"saved_profile": "تم حفظ الملف", "saved_profile": "تم حفظ الملف",
"saved_settings": "تم حفظ الإعدادات", "saved_settings": "تم حفظ الإعدادات",
@@ -1350,17 +1491,31 @@
"search_city": "البحث حسب المدينة...", "search_city": "البحث حسب المدينة...",
"search_country": "البحث حسب الدولة...", "search_country": "البحث حسب الدولة...",
"search_filter_apply": "اختار الفلتر ", "search_filter_apply": "اختار الفلتر ",
"search_filter_camera_title": "Select camera type",
"search_filter_date": "Date",
"search_filter_date_interval": "{start} to {end}",
"search_filter_date_title": "Select a date range",
"search_filter_display_option_not_in_album": "ليس في الألبوم", "search_filter_display_option_not_in_album": "ليس في الألبوم",
"search_filter_display_options": "Display Options",
"search_filter_filename": "Search by file name",
"search_filter_location": "Location",
"search_filter_location_title": "Select location",
"search_filter_media_type": "Media Type",
"search_filter_media_type_title": "Select media type",
"search_filter_people_title": "Select people",
"search_for": "البحث عن", "search_for": "البحث عن",
"search_for_existing_person": "البحث عن شخص موجود", "search_for_existing_person": "البحث عن شخص موجود",
"search_no_more_result": "No more results",
"search_no_people": "لا يوجد أشخاص", "search_no_people": "لا يوجد أشخاص",
"search_no_people_named": "لا يوجد أشخاص بالاسم \"{name}\"", "search_no_people_named": "لا يوجد أشخاص بالاسم \"{name}\"",
"search_no_result": "No results found, try a different search term or combination",
"search_options": "خيارات البحث", "search_options": "خيارات البحث",
"search_page_categories": "فئات", "search_page_categories": "فئات",
"search_page_motion_photos": "الصور المتحركه", "search_page_motion_photos": "الصور المتحركه",
"search_page_no_objects": "لا توجد معلومات عن أشياء متاحة", "search_page_no_objects": "لا توجد معلومات عن أشياء متاحة",
"search_page_no_places": "لا توجد معلومات متوفرة للأماكن", "search_page_no_places": "لا توجد معلومات متوفرة للأماكن",
"search_page_screenshots": "لقطات الشاشة", "search_page_screenshots": "لقطات الشاشة",
"search_page_search_photos_videos": "Search for your photos and videos",
"search_page_selfies": " صور ذاتيه", "search_page_selfies": " صور ذاتيه",
"search_page_things": "أشياء", "search_page_things": "أشياء",
"search_page_view_all_button": "عرض الكل", "search_page_view_all_button": "عرض الكل",
@@ -1399,6 +1554,7 @@
"selected_count": "{count, plural, other {# محددة }}", "selected_count": "{count, plural, other {# محددة }}",
"send_message": "‏إرسال رسالة", "send_message": "‏إرسال رسالة",
"send_welcome_email": "إرسال بريدًا إلكترونيًا ترحيبيًا", "send_welcome_email": "إرسال بريدًا إلكترونيًا ترحيبيًا",
"server_endpoint": "Server Endpoint",
"server_info_box_app_version": "نسخة التطبيق", "server_info_box_app_version": "نسخة التطبيق",
"server_info_box_server_url": "عنوان URL الخادم", "server_info_box_server_url": "عنوان URL الخادم",
"server_offline": "الخادم غير متصل", "server_offline": "الخادم غير متصل",
@@ -1419,20 +1575,28 @@
"setting_image_viewer_preview_title": "تحميل صورة معاينة", "setting_image_viewer_preview_title": "تحميل صورة معاينة",
"setting_image_viewer_title": "الصور", "setting_image_viewer_title": "الصور",
"setting_languages_apply": "تغيير الإعدادات", "setting_languages_apply": "تغيير الإعدادات",
"setting_languages_subtitle": "Change the app's language",
"setting_languages_title": "اللغات",
"setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}",
"setting_notifications_notify_hours": "{} hours",
"setting_notifications_notify_immediately": "في الحال", "setting_notifications_notify_immediately": "في الحال",
"setting_notifications_notify_minutes": "{} minutes",
"setting_notifications_notify_never": "أبداً", "setting_notifications_notify_never": "أبداً",
"setting_notifications_notify_seconds": "{} seconds",
"setting_notifications_single_progress_subtitle": "معلومات التقدم التفصيلية تحميل لكل أصل", "setting_notifications_single_progress_subtitle": "معلومات التقدم التفصيلية تحميل لكل أصل",
"setting_notifications_single_progress_title": "إظهار تقدم التفاصيل الاحتياطية الخلفية", "setting_notifications_single_progress_title": "إظهار تقدم التفاصيل الاحتياطية الخلفية",
"setting_notifications_subtitle": "اضبط تفضيلات الإخطار", "setting_notifications_subtitle": "اضبط تفضيلات الإخطار",
"setting_notifications_total_progress_subtitle": "التقدم التحميل العام (تم القيام به/إجمالي الأصول)", "setting_notifications_total_progress_subtitle": "التقدم التحميل العام (تم القيام به/إجمالي الأصول)",
"setting_notifications_total_progress_title": "إظهار النسخ الاحتياطي الخلفية التقدم المحرز", "setting_notifications_total_progress_title": "إظهار النسخ الاحتياطي الخلفية التقدم المحرز",
"setting_video_viewer_looping_title": "تكرار مقطع فيديو تلقائيًا", "setting_video_viewer_looping_title": "تكرار مقطع فيديو تلقائيًا",
"setting_video_viewer_original_video_subtitle": "When streaming a video from the server, play the original even when a transcode is available. May lead to buffering. Videos available locally are played in original quality regardless of this setting.",
"setting_video_viewer_original_video_title": "Force original video",
"settings": "الإعدادات", "settings": "الإعدادات",
"settings_require_restart": "يرجى إعادة تشغيل لتطبيق هذا الإعداد", "settings_require_restart": "يرجى إعادة تشغيل لتطبيق هذا الإعداد",
"settings_saved": "تم حفظ الإعدادات", "settings_saved": "تم حفظ الإعدادات",
"setup_pin_code": "تحديد رقم سري",
"share": "مشاركة", "share": "مشاركة",
"share_add_photos": "إضافة الصور", "share_add_photos": "إضافة الصور",
"share_assets_selected": "{} selected",
"share_dialog_preparing": "تحضير...", "share_dialog_preparing": "تحضير...",
"shared": "مُشتَرك", "shared": "مُشتَرك",
"shared_album_activities_input_disable": "التعليق معطل", "shared_album_activities_input_disable": "التعليق معطل",
@@ -1446,22 +1610,40 @@
"shared_by_user": "تمت المشاركة بواسطة {user}", "shared_by_user": "تمت المشاركة بواسطة {user}",
"shared_by_you": "تمت مشاركته من قِبلك", "shared_by_you": "تمت مشاركته من قِبلك",
"shared_from_partner": "صور من {partner}", "shared_from_partner": "صور من {partner}",
"shared_intent_upload_button_progress_text": "{} / {} Uploaded",
"shared_link_app_bar_title": "روابط مشتركة", "shared_link_app_bar_title": "روابط مشتركة",
"shared_link_clipboard_copied_massage": "نسخ إلى الحافظة", "shared_link_clipboard_copied_massage": "نسخ إلى الحافظة",
"shared_link_clipboard_text": "Link: {}\nPassword: {}",
"shared_link_create_error": "خطأ أثناء إنشاء رابط مشترك", "shared_link_create_error": "خطأ أثناء إنشاء رابط مشترك",
"shared_link_edit_description_hint": "أدخل وصف المشاركة", "shared_link_edit_description_hint": "أدخل وصف المشاركة",
"shared_link_edit_expire_after_option_day": "يوم 1", "shared_link_edit_expire_after_option_day": "يوم 1",
"shared_link_edit_expire_after_option_days": "{} days",
"shared_link_edit_expire_after_option_hour": "1 ساعة", "shared_link_edit_expire_after_option_hour": "1 ساعة",
"shared_link_edit_expire_after_option_hours": "{} hours",
"shared_link_edit_expire_after_option_minute": "1 دقيقة", "shared_link_edit_expire_after_option_minute": "1 دقيقة",
"shared_link_edit_expire_after_option_minutes": "{} minutes",
"shared_link_edit_expire_after_option_months": "{} months",
"shared_link_edit_expire_after_option_year": "{} year",
"shared_link_edit_password_hint": "أدخل كلمة مرور المشاركة", "shared_link_edit_password_hint": "أدخل كلمة مرور المشاركة",
"shared_link_edit_submit_button": "تحديث الرابط", "shared_link_edit_submit_button": "تحديث الرابط",
"shared_link_error_server_url_fetch": "لا يمكن جلب عنوان الخادم", "shared_link_error_server_url_fetch": "لا يمكن جلب عنوان الخادم",
"shared_link_expires_day": "Expires in {} day",
"shared_link_expires_days": "Expires in {} days",
"shared_link_expires_hour": "Expires in {} hour",
"shared_link_expires_hours": "Expires in {} hours",
"shared_link_expires_minute": "Expires in {} minute",
"shared_link_expires_minutes": "Expires in {} minutes",
"shared_link_expires_never": "تنتهي ∞", "shared_link_expires_never": "تنتهي ∞",
"shared_link_expires_second": "Expires in {} second",
"shared_link_expires_seconds": "Expires in {} seconds",
"shared_link_individual_shared": "Individual shared",
"shared_link_info_chip_metadata": "EXIF",
"shared_link_manage_links": "إدارة الروابط المشتركة", "shared_link_manage_links": "إدارة الروابط المشتركة",
"shared_link_options": "خيارات الرابط المشترك", "shared_link_options": "خيارات الرابط المشترك",
"shared_links": "روابط مشتركة", "shared_links": "روابط مشتركة",
"shared_links_description": "وصف الروابط المشتركة", "shared_links_description": "وصف الروابط المشتركة",
"shared_photos_and_videos_count": "{assetCount, plural, other {# الصور ومقاطع الفيديو المُشارَكة.}}", "shared_photos_and_videos_count": "{assetCount, plural, other {# الصور ومقاطع الفيديو المُشارَكة.}}",
"shared_with_me": "Shared with me",
"shared_with_partner": "تمت المشاركة مع {partner}", "shared_with_partner": "تمت المشاركة مع {partner}",
"sharing": "مشاركة", "sharing": "مشاركة",
"sharing_enter_password": "الرجاء إدخال كلمة المرور لعرض هذه الصفحة.", "sharing_enter_password": "الرجاء إدخال كلمة المرور لعرض هذه الصفحة.",
@@ -1537,6 +1719,9 @@
"support_third_party_description": "تم حزم تثبيت immich الخاص بك بواسطة جهة خارجية. قد تكون المشكلات التي تواجهها ناجمة عن هذه الحزمة، لذا يرجى طرح المشكلات معهم في المقام الأول باستخدام الروابط أدناه.", "support_third_party_description": "تم حزم تثبيت immich الخاص بك بواسطة جهة خارجية. قد تكون المشكلات التي تواجهها ناجمة عن هذه الحزمة، لذا يرجى طرح المشكلات معهم في المقام الأول باستخدام الروابط أدناه.",
"swap_merge_direction": "تبديل اتجاه الدمج", "swap_merge_direction": "تبديل اتجاه الدمج",
"sync": "مزامنة", "sync": "مزامنة",
"sync_albums": "Sync albums",
"sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums",
"sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich",
"tag": "العلامة", "tag": "العلامة",
"tag_assets": "أصول العلامة", "tag_assets": "أصول العلامة",
"tag_created": "تم إنشاء العلامة: {tag}", "tag_created": "تم إنشاء العلامة: {tag}",
@@ -1551,8 +1736,14 @@
"theme_selection": "اختيار السمة", "theme_selection": "اختيار السمة",
"theme_selection_description": "قم بتعيين السمة تلقائيًا على اللون الفاتح أو الداكن بناءً على تفضيلات نظام المتصفح الخاص بك", "theme_selection_description": "قم بتعيين السمة تلقائيًا على اللون الفاتح أو الداكن بناءً على تفضيلات نظام المتصفح الخاص بك",
"theme_setting_asset_list_storage_indicator_title": "عرض مؤشر التخزين على بلاط الأصول", "theme_setting_asset_list_storage_indicator_title": "عرض مؤشر التخزين على بلاط الأصول",
"theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})",
"theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.",
"theme_setting_colorful_interface_title": "Colorful interface",
"theme_setting_image_viewer_quality_subtitle": "اضبط جودة عارض الصورة التفصيلية", "theme_setting_image_viewer_quality_subtitle": "اضبط جودة عارض الصورة التفصيلية",
"theme_setting_image_viewer_quality_title": "جودة عارض الصورة", "theme_setting_image_viewer_quality_title": "جودة عارض الصورة",
"theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.",
"theme_setting_primary_color_title": "Primary color",
"theme_setting_system_primary_color_title": "Use system color",
"theme_setting_system_theme_switch": "تلقائي (اتبع إعداد النظام)", "theme_setting_system_theme_switch": "تلقائي (اتبع إعداد النظام)",
"theme_setting_theme_subtitle": "اختر إعدادات مظهر التطبيق", "theme_setting_theme_subtitle": "اختر إعدادات مظهر التطبيق",
"theme_setting_three_stage_loading_subtitle": "قد يزيد التحميل من ثلاث مراحل من أداء التحميل ولكنه يسبب تحميل شبكة أعلى بكثير", "theme_setting_three_stage_loading_subtitle": "قد يزيد التحميل من ثلاث مراحل من أداء التحميل ولكنه يسبب تحميل شبكة أعلى بكثير",
@@ -1569,22 +1760,24 @@
"to_parent": "انتقل إلى الوالد", "to_parent": "انتقل إلى الوالد",
"to_trash": "حذف", "to_trash": "حذف",
"toggle_settings": "الإعدادات", "toggle_settings": "الإعدادات",
"toggle_theme": "تبديل المظهر الداكن",
"total": "الإجمالي", "total": "الإجمالي",
"total_usage": "الاستخدام الإجمالي", "total_usage": "الاستخدام الإجمالي",
"trash": "المهملات", "trash": "المهملات",
"trash_all": "نقل الكل إلى سلة المهملات", "trash_all": "نقل الكل إلى سلة المهملات",
"trash_count": "سلة المحملات {count, number}", "trash_count": "سلة المحملات {count, number}",
"trash_delete_asset": "حذف/نقل المحتوى إلى سلة المهملات", "trash_delete_asset": "حذف/نقل المحتوى إلى سلة المهملات",
"trash_emptied": "Emptied trash",
"trash_no_results_message": "ستظهر هنا الصور ومقاطع الفيديو المحذوفة.", "trash_no_results_message": "ستظهر هنا الصور ومقاطع الفيديو المحذوفة.",
"trash_page_delete_all": "حذف الكل", "trash_page_delete_all": "حذف الكل",
"trash_page_empty_trash_dialog_content": "هل تريد تفريغ أصولك المهملة؟ ستتم إزالة هذه العناصر نهائيًا من التطبيق", "trash_page_empty_trash_dialog_content": "هل تريد تفريغ أصولك المهملة؟ ستتم إزالة هذه العناصر نهائيًا من التطبيق",
"trash_page_info": "Trashed items will be permanently deleted after {} days",
"trash_page_no_assets": "لا توجد اصول في سله المهملات", "trash_page_no_assets": "لا توجد اصول في سله المهملات",
"trash_page_restore_all": "استعادة الكل", "trash_page_restore_all": "استعادة الكل",
"trash_page_select_assets_btn": "اختر الأصول ", "trash_page_select_assets_btn": "اختر الأصول ",
"trash_page_title": "Trash ({})",
"trashed_items_will_be_permanently_deleted_after": "سيتم حذفُ العناصر المحذوفة نِهائيًا بعد {days, plural, one {# يوم} other {# أيام }}.", "trashed_items_will_be_permanently_deleted_after": "سيتم حذفُ العناصر المحذوفة نِهائيًا بعد {days, plural, one {# يوم} other {# أيام }}.",
"type": "النوع", "type": "النوع",
"unable_to_change_pin_code": "تفيير الرقم السري غير ممكن",
"unable_to_setup_pin_code": "انشاء الرقم السري غير ممكن",
"unarchive": "أخرج من الأرشيف", "unarchive": "أخرج من الأرشيف",
"unarchived_count": "{count, plural, other {غير مؤرشفة #}}", "unarchived_count": "{count, plural, other {غير مؤرشفة #}}",
"unfavorite": "أزل التفضيل", "unfavorite": "أزل التفضيل",
@@ -1605,6 +1798,8 @@
"unselect_all_duplicates": "إلغاء تحديد كافة النسخ المكررة", "unselect_all_duplicates": "إلغاء تحديد كافة النسخ المكررة",
"unstack": "فك الكومه", "unstack": "فك الكومه",
"unstacked_assets_count": "تم إخراج {count, plural, one {# الأصل} other {# الأصول}} من التكديس", "unstacked_assets_count": "تم إخراج {count, plural, one {# الأصل} other {# الأصول}} من التكديس",
"untracked_files": "الملفات التي لم يتم تعقبها",
"untracked_files_decription": "لا يتم تعقب هذه الملفات بواسطة التطبيق. يمكن أن تكون نتيجةً لعمليات نقل فاشلة، أو عمليات رفع متقطعة، أو يتم تركها في الخلف بسبب خللاً ما",
"up_next": "التالي", "up_next": "التالي",
"updated_password": "تم تحديث كلمة المرور", "updated_password": "تم تحديث كلمة المرور",
"upload": "رفع", "upload": "رفع",
@@ -1618,14 +1813,15 @@
"upload_status_errors": "الأخطاء", "upload_status_errors": "الأخطاء",
"upload_status_uploaded": "تم الرفع", "upload_status_uploaded": "تم الرفع",
"upload_success": "تم الرفع بنجاح، قم بتحديث الصفحة لرؤية المحتويات المرفوعة الجديدة.", "upload_success": "تم الرفع بنجاح، قم بتحديث الصفحة لرؤية المحتويات المرفوعة الجديدة.",
"upload_to_immich": "Upload to Immich ({})",
"uploading": "Uploading",
"url": "عنوان URL", "url": "عنوان URL",
"usage": "الاستخدام", "usage": "الاستخدام",
"use_current_connection": "use current connection",
"use_custom_date_range": "استخدم النطاق الزمني المخصص بدلاً من ذلك", "use_custom_date_range": "استخدم النطاق الزمني المخصص بدلاً من ذلك",
"user": "مستخدم", "user": "مستخدم",
"user_id": "معرف المستخدم", "user_id": "معرف المستخدم",
"user_liked": "قام {user} بالإعجاب {type, select, photo {بهذه الصورة} video {بهذا الفيديو} asset {بهذا المحتوى} other {بها}}", "user_liked": "قام {user} بالإعجاب {type, select, photo {بهذه الصورة} video {بهذا الفيديو} asset {بهذا المحتوى} other {بها}}",
"user_pin_code_settings": "الرقم السري",
"user_pin_code_settings_description": "تغير الرقم السري",
"user_purchase_settings": "الشراء", "user_purchase_settings": "الشراء",
"user_purchase_settings_description": "إدارة عملية الشراء الخاصة بك", "user_purchase_settings_description": "إدارة عملية الشراء الخاصة بك",
"user_role_set": "قم بتعيين {user} كـ {role}", "user_role_set": "قم بتعيين {user} كـ {role}",
@@ -1636,10 +1832,16 @@
"users": "المستخدمين", "users": "المستخدمين",
"utilities": "أدوات", "utilities": "أدوات",
"validate": "تحقْق", "validate": "تحقْق",
"validate_endpoint_error": "Please enter a valid URL",
"variables": "المتغيرات", "variables": "المتغيرات",
"version": "الإصدار", "version": "الإصدار",
"version_announcement_closing": "صديقك، أليكس", "version_announcement_closing": "صديقك، أليكس",
"version_announcement_message": "مرحبًا! يتوفر إصدار جديد من Immich. يُرجى تخصيص بعض الوقت لقراءة <link>ملاحظات الإصدار</link> للتأكد من تحديث إعداداتك لمنع أي أخطاء في التكوين، خاصة إذا كنت تستخدم WatchTower أو أي آلية تتولى تحديث مثيل Immich الخاص بك تلقائيًا.", "version_announcement_message": "مرحبًا! يتوفر إصدار جديد من Immich. يُرجى تخصيص بعض الوقت لقراءة <link>ملاحظات الإصدار</link> للتأكد من تحديث إعداداتك لمنع أي أخطاء في التكوين، خاصة إذا كنت تستخدم WatchTower أو أي آلية تتولى تحديث مثيل Immich الخاص بك تلقائيًا.",
"version_announcement_overlay_release_notes": "ملاحظات الإصدار",
"version_announcement_overlay_text_1": "مرحبًا يا صديقي ، هناك إصدار جديد",
"version_announcement_overlay_text_2": "من فضلك خذ وقتك لزيارة",
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
"version_announcement_overlay_title": "نسخه جديده متاحه للخادم ",
"version_history": "تاريخ الإصدار", "version_history": "تاريخ الإصدار",
"version_history_item": "تم تثبيت {version} في {date}", "version_history_item": "تم تثبيت {version} في {date}",
"video": "فيديو", "video": "فيديو",

View File

@@ -34,15 +34,18 @@
"backup_database_enable_description": "Verilənlər bazasının ehtiyat nüsxələrini aktiv et", "backup_database_enable_description": "Verilənlər bazasının ehtiyat nüsxələrini aktiv et",
"backup_settings": "Ehtiyat Nüsxə Parametrləri", "backup_settings": "Ehtiyat Nüsxə Parametrləri",
"backup_settings_description": "Verilənlər bazasının ehtiyat nüsxə parametrlərini idarə et", "backup_settings_description": "Verilənlər bazasının ehtiyat nüsxə parametrlərini idarə et",
"check_all": "Hamısını yoxla",
"config_set_by_file": "Konfiqurasiya hal-hazırda konfiqurasiya faylı ilə təyin olunub", "config_set_by_file": "Konfiqurasiya hal-hazırda konfiqurasiya faylı ilə təyin olunub",
"confirm_delete_library": "{library} kitabxanasını silmək istədiyinizdən əminmisiniz?", "confirm_delete_library": "{library} kitabxanasını silmək istədiyinizdən əminmisiniz?",
"confirm_email_below": "Təsdiqləmək üçün aşağıya {email} yazın", "confirm_email_below": "Təsdiqləmək üçün aşağıya {email} yazın",
"confirm_user_password_reset": "{user} adlı istifadəçinin şifrəsini sıfırlamaq istədiyinizdən əminmisiniz?", "confirm_user_password_reset": "{user} adlı istifadəçinin şifrəsini sıfırlamaq istədiyinizdən əminmisiniz?",
"disable_login": "Giriş etməni söndür", "disable_login": "Giriş etməni söndür",
"duplicate_detection_job_description": "Bənzər şəkilləri tapmaq üçün maşın öyrənməsini işə salın. Bu prosses Smart Search funksiyasına əsaslanır", "duplicate_detection_job_description": "Bənzər şəkilləri tapmaq üçün maşın öyrənməsini işə salın. Bu prosses Smart Search funksiyasına əsaslanır",
"external_library_created_at": "Xarici kitabxana ({date} (tarixində yaradıldı)",
"external_library_management": "Xarici kitabxana idarəetməsi", "external_library_management": "Xarici kitabxana idarəetməsi",
"face_detection": "Üz tanıma", "face_detection": "Üz tanıma",
"force_delete_user_warning": "XƏBƏRDARLIQ: Bu əməliyyat istifadəçi və bütün məlumatları siləcəkdir. Bu prossesi və silinən faylları geri qaytarmaq olmaz.", "force_delete_user_warning": "XƏBƏRDARLIQ: Bu əməliyyat istifadəçi və bütün məlumatları siləcəkdir. Bu prossesi və silinən faylları geri qaytarmaq olmaz.",
"forcing_refresh_library_files": "Bütün kitabxana fayllarını məcburi yeniləmə",
"image_format_description": "WebP, JPEG faylına görə daha kiçik həcmə sahibdir, lakin onu kodlaşdırmaq daha çox vaxt alır.", "image_format_description": "WebP, JPEG faylına görə daha kiçik həcmə sahibdir, lakin onu kodlaşdırmaq daha çox vaxt alır.",
"image_preview_title": "Önizləmə parametrləri", "image_preview_title": "Önizləmə parametrləri",
"image_quality": "Keyfiyyət", "image_quality": "Keyfiyyət",
@@ -73,6 +76,7 @@
"library_watching_settings_description": "Dəyişdirilən faylları avtomatik olaraq yoxla", "library_watching_settings_description": "Dəyişdirilən faylları avtomatik olaraq yoxla",
"logging_enable_description": "Jurnalı aktivləşdir", "logging_enable_description": "Jurnalı aktivləşdir",
"logging_level_description": "Aktiv edildikdə hansı jurnal səviyyəsi istifadə olunur.", "logging_level_description": "Aktiv edildikdə hansı jurnal səviyyəsi istifadə olunur.",
"logging_settings": "",
"machine_learning_clip_model": "CLIP modeli", "machine_learning_clip_model": "CLIP modeli",
"machine_learning_clip_model_description": "<link>Burada</link>qeyd olunan CLIP modelinin adı. Modeli dəyişdirdikdən sonra bütün şəkillər üçün 'Ağıllı Axtarış' funksiyasını yenidən işə salmalısınız.", "machine_learning_clip_model_description": "<link>Burada</link>qeyd olunan CLIP modelinin adı. Modeli dəyişdirdikdən sonra bütün şəkillər üçün 'Ağıllı Axtarış' funksiyasını yenidən işə salmalısınız.",
"machine_learning_duplicate_detection": "Dublikat Aşkarlama", "machine_learning_duplicate_detection": "Dublikat Aşkarlama",

View File

@@ -44,6 +44,8 @@
"backup_keep_last_amount": "Колькасць папярэдніх рэзервовых копій для захавання", "backup_keep_last_amount": "Колькасць папярэдніх рэзервовых копій для захавання",
"backup_settings": "Налады рэзервовага капіявання", "backup_settings": "Налады рэзервовага капіявання",
"backup_settings_description": "Кіраванне наладамі дампа базы дадзеных. Заўвага: гэтыя задачы не кантралююцца, і ў выпадку няўдачы паведамленне адпраўлена не будзе.", "backup_settings_description": "Кіраванне наладамі дампа базы дадзеных. Заўвага: гэтыя задачы не кантралююцца, і ў выпадку няўдачы паведамленне адпраўлена не будзе.",
"check_all": "Праверыць усе",
"cleanup": "Ачыстка",
"cleared_jobs": "Ачышчаны заданні для: {job}", "cleared_jobs": "Ачышчаны заданні для: {job}",
"config_set_by_file": "Канфігурацыя ў зараз усталявана праз файл канфігурацыі", "config_set_by_file": "Канфігурацыя ў зараз усталявана праз файл канфігурацыі",
"confirm_delete_library": "Вы ўпэўнены што жадаеце выдаліць {library} бібліятэку?", "confirm_delete_library": "Вы ўпэўнены што жадаеце выдаліць {library} бібліятэку?",
@@ -58,12 +60,14 @@
"disable_login": "Адключыць уваход", "disable_login": "Адключыць уваход",
"duplicate_detection_job_description": "Запусціць машыннае навучанне на актывах для выяўлення падобных выяў. Залежыць ад Smart Search", "duplicate_detection_job_description": "Запусціць машыннае навучанне на актывах для выяўлення падобных выяў. Залежыць ад Smart Search",
"exclusion_pattern_description": "Шаблоны выключэння дазваляюць ігнараваць файлы і папкі пры сканаванні вашай бібліятэкі. Гэта карысна, калі ў вас ёсць папкі, якія змяшчаюць файлы, якія вы не хочаце імпартаваць, напрыклад, файлы RAW.", "exclusion_pattern_description": "Шаблоны выключэння дазваляюць ігнараваць файлы і папкі пры сканаванні вашай бібліятэкі. Гэта карысна, калі ў вас ёсць папкі, якія змяшчаюць файлы, якія вы не хочаце імпартаваць, напрыклад, файлы RAW.",
"external_library_created_at": "Знешняя бібліятэка (створана {date})",
"external_library_management": "Кіраванне знешняй бібліятэкай", "external_library_management": "Кіраванне знешняй бібліятэкай",
"face_detection": "Выяўленне твараў", "face_detection": "Выяўленне твараў",
"face_detection_description": "Выяўляць твары на фотаздымках і відэа з дапамогай машыннага навучання. Для відэа ўлічваецца толькі мініяцюра. \"Абнавіць\" (пера)апрацоўвае ўсе медыя. \"Скінуць\" дадаткова ачышчае ўсе бягучыя дадзеныя пра твары. \"Адсутнічае\" ставіць у чаргу медыя, якія яшчэ не былі апрацаваныя. Выяўленыя твары будуць пастаўлены ў чаргу для распазнавання асоб пасля завяршэння выяўлення твараў, з групаваннем іх па існуючых або новых людзях.", "face_detection_description": "Выяўляць твары на фотаздымках і відэа з дапамогай машыннага навучання. Для відэа ўлічваецца толькі мініяцюра. \"Абнавіць\" (пера)апрацоўвае ўсе медыя. \"Скінуць\" дадаткова ачышчае ўсе бягучыя дадзеныя пра твары. \"Адсутнічае\" ставіць у чаргу медыя, якія яшчэ не былі апрацаваныя. Выяўленыя твары будуць пастаўлены ў чаргу для распазнавання асоб пасля завяршэння выяўлення твараў, з групаваннем іх па існуючых або новых людзях.",
"facial_recognition_job_description": "Групаваць выяўленыя твары па асобах. Гэты этап выконваецца пасля завяршэння выяўлення твараў. \"Скінуць\" (паўторна) перагрупоўвае ўсе твары. \"Адсутнічае\" ставіць у чаргу твары, якія яшчэ не прыпісаныя да якой-небудзь асобы.", "facial_recognition_job_description": "Групаваць выяўленыя твары па асобах. Гэты этап выконваецца пасля завяршэння выяўлення твараў. \"Скінуць\" (паўторна) перагрупоўвае ўсе твары. \"Адсутнічае\" ставіць у чаргу твары, якія яшчэ не прыпісаныя да якой-небудзь асобы.",
"failed_job_command": "Каманда {command} не выканалася для задання: {job}", "failed_job_command": "Каманда {command} не выканалася для задання: {job}",
"force_delete_user_warning": "ПАПЯРЭДЖАННЕ: Гэта дзеянне неадкладна выдаліць карыстальніка і ўсе аб'екты. Гэта дзеянне не можа быць адроблена і файлы немагчыма будзе аднавіць.", "force_delete_user_warning": "ПАПЯРЭДЖАННЕ: Гэта дзеянне неадкладна выдаліць карыстальніка і ўсе аб'екты. Гэта дзеянне не можа быць адроблена і файлы немагчыма будзе аднавіць.",
"forcing_refresh_library_files": "Прымусовае абнаўленне ўсіх файлаў бібліятэкі",
"image_format": "Фармат", "image_format": "Фармат",
"image_format_description": "WebP стварае меншыя файлы, чым JPEG, але павольней кадуе.", "image_format_description": "WebP стварае меншыя файлы, чым JPEG, але павольней кадуе.",
"image_fullsize_description": "Выява ў поўным памеры без метаданых, выкарыстоўваецца пры павелічэнні", "image_fullsize_description": "Выява ў поўным памеры без метаданых, выкарыстоўваецца пры павелічэнні",

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,8 @@
"account": "Akaont", "account": "Akaont",
"account_settings": "Seting blo Akaont", "account_settings": "Seting blo Akaont",
"acknowledge": "Akcept", "acknowledge": "Akcept",
"action": "",
"actions": "",
"active": "Stap Mekem", "active": "Stap Mekem",
"activity": "Wanem hemi Mekem", "activity": "Wanem hemi Mekem",
"activity_changed": "WAnem hemi Mekem hemi", "activity_changed": "WAnem hemi Mekem hemi",
@@ -14,5 +16,845 @@
"add_exclusion_pattern": "Putem wan paten wae hemi karem aot", "add_exclusion_pattern": "Putem wan paten wae hemi karem aot",
"add_import_path": "Putem wan pat blo import", "add_import_path": "Putem wan pat blo import",
"add_location": "Putem wan place blo hem", "add_location": "Putem wan place blo hem",
"add_more_users": "Putem mor man" "add_more_users": "Putem mor man",
"add_partner": "",
"add_path": "",
"add_photos": "",
"add_to": "",
"add_to_album": "",
"add_to_shared_album": "",
"admin": {
"add_exclusion_pattern_description": "",
"authentication_settings": "",
"authentication_settings_description": "",
"background_task_job": "",
"check_all": "",
"config_set_by_file": "",
"confirm_delete_library": "",
"confirm_delete_library_assets": "",
"confirm_email_below": "",
"confirm_reprocess_all_faces": "",
"confirm_user_password_reset": "",
"disable_login": "",
"duplicate_detection_job_description": "",
"exclusion_pattern_description": "",
"external_library_created_at": "",
"external_library_management": "",
"face_detection": "",
"face_detection_description": "",
"facial_recognition_job_description": "",
"force_delete_user_warning": "",
"forcing_refresh_library_files": "",
"image_format_description": "",
"image_prefer_embedded_preview": "",
"image_prefer_embedded_preview_setting_description": "",
"image_prefer_wide_gamut": "",
"image_prefer_wide_gamut_setting_description": "",
"image_quality": "",
"image_settings": "",
"image_settings_description": "",
"job_concurrency": "",
"job_not_concurrency_safe": "",
"job_settings": "",
"job_settings_description": "",
"job_status": "",
"jobs_delayed": "",
"jobs_failed": "",
"library_created": "",
"library_deleted": "",
"library_import_path_description": "",
"library_scanning": "",
"library_scanning_description": "",
"library_scanning_enable_description": "",
"library_settings": "",
"library_settings_description": "",
"library_tasks_description": "",
"library_watching_enable_description": "",
"library_watching_settings": "",
"library_watching_settings_description": "",
"logging_enable_description": "",
"logging_level_description": "",
"logging_settings": "",
"machine_learning_clip_model": "",
"machine_learning_duplicate_detection": "",
"machine_learning_duplicate_detection_enabled_description": "",
"machine_learning_duplicate_detection_setting_description": "",
"machine_learning_enabled_description": "",
"machine_learning_facial_recognition": "",
"machine_learning_facial_recognition_description": "",
"machine_learning_facial_recognition_model": "",
"machine_learning_facial_recognition_model_description": "",
"machine_learning_facial_recognition_setting_description": "",
"machine_learning_max_detection_distance": "",
"machine_learning_max_detection_distance_description": "",
"machine_learning_max_recognition_distance": "",
"machine_learning_max_recognition_distance_description": "",
"machine_learning_min_detection_score": "",
"machine_learning_min_detection_score_description": "",
"machine_learning_min_recognized_faces": "",
"machine_learning_min_recognized_faces_description": "",
"machine_learning_settings": "",
"machine_learning_settings_description": "",
"machine_learning_smart_search": "",
"machine_learning_smart_search_description": "",
"machine_learning_smart_search_enabled_description": "",
"machine_learning_url_description": "",
"manage_concurrency": "",
"manage_log_settings": "",
"map_dark_style": "",
"map_enable_description": "",
"map_light_style": "",
"map_reverse_geocoding": "",
"map_reverse_geocoding_enable_description": "",
"map_reverse_geocoding_settings": "",
"map_settings": "",
"map_settings_description": "",
"map_style_description": "",
"metadata_extraction_job": "",
"metadata_extraction_job_description": "",
"migration_job": "",
"migration_job_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": "",
"notification_email_host_description": "",
"notification_email_ignore_certificate_errors": "",
"notification_email_ignore_certificate_errors_description": "",
"notification_email_password_description": "",
"notification_email_port_description": "",
"notification_email_sent_test_email_button": "",
"notification_email_setting_description": "",
"notification_email_test_email_failed": "",
"notification_email_test_email_sent": "",
"notification_email_username_description": "",
"notification_enable_email_notifications": "",
"notification_settings": "",
"notification_settings_description": "",
"oauth_auto_launch": "",
"oauth_auto_launch_description": "",
"oauth_auto_register": "",
"oauth_auto_register_description": "",
"oauth_button_text": "",
"oauth_enable_description": "",
"oauth_mobile_redirect_uri": "",
"oauth_mobile_redirect_uri_override": "",
"oauth_mobile_redirect_uri_override_description": "",
"oauth_settings": "",
"oauth_settings_description": "",
"oauth_storage_label_claim": "",
"oauth_storage_label_claim_description": "",
"oauth_storage_quota_claim": "",
"oauth_storage_quota_claim_description": "",
"oauth_storage_quota_default": "",
"oauth_storage_quota_default_description": "",
"offline_paths": "",
"offline_paths_description": "",
"password_enable_description": "",
"password_settings": "",
"password_settings_description": "",
"paths_validated_successfully": "",
"quota_size_gib": "",
"refreshing_all_libraries": "",
"repair_all": "",
"repair_matched_items": "",
"repaired_items": "",
"require_password_change_on_login": "",
"reset_settings_to_default": "",
"reset_settings_to_recent_saved": "",
"send_welcome_email": "",
"server_external_domain_settings": "",
"server_external_domain_settings_description": "",
"server_settings": "",
"server_settings_description": "",
"server_welcome_message": "",
"server_welcome_message_description": "",
"sidecar_job": "",
"sidecar_job_description": "",
"slideshow_duration_description": "",
"smart_search_job_description": "",
"storage_template_enable_description": "",
"storage_template_hash_verification_enabled": "",
"storage_template_hash_verification_enabled_description": "",
"storage_template_migration": "",
"storage_template_migration_job": "",
"storage_template_settings": "",
"storage_template_settings_description": "",
"system_settings": "",
"theme_custom_css_settings": "",
"theme_custom_css_settings_description": "",
"theme_settings": "",
"theme_settings_description": "",
"these_files_matched_by_checksum": "",
"thumbnail_generation_job": "",
"thumbnail_generation_job_description": "",
"transcoding_acceleration_api": "",
"transcoding_acceleration_api_description": "",
"transcoding_acceleration_nvenc": "",
"transcoding_acceleration_qsv": "",
"transcoding_acceleration_rkmpp": "",
"transcoding_acceleration_vaapi": "",
"transcoding_accepted_audio_codecs": "",
"transcoding_accepted_audio_codecs_description": "",
"transcoding_accepted_video_codecs": "",
"transcoding_accepted_video_codecs_description": "",
"transcoding_advanced_options_description": "",
"transcoding_audio_codec": "",
"transcoding_audio_codec_description": "",
"transcoding_bitrate_description": "",
"transcoding_constant_quality_mode": "",
"transcoding_constant_quality_mode_description": "",
"transcoding_constant_rate_factor": "",
"transcoding_constant_rate_factor_description": "",
"transcoding_disabled_description": "",
"transcoding_hardware_acceleration": "",
"transcoding_hardware_acceleration_description": "",
"transcoding_hardware_decoding": "",
"transcoding_hardware_decoding_setting_description": "",
"transcoding_hevc_codec": "",
"transcoding_max_b_frames": "",
"transcoding_max_b_frames_description": "",
"transcoding_max_bitrate": "",
"transcoding_max_bitrate_description": "",
"transcoding_max_keyframe_interval": "",
"transcoding_max_keyframe_interval_description": "",
"transcoding_optimal_description": "",
"transcoding_preferred_hardware_device": "",
"transcoding_preferred_hardware_device_description": "",
"transcoding_preset_preset": "",
"transcoding_preset_preset_description": "",
"transcoding_reference_frames": "",
"transcoding_reference_frames_description": "",
"transcoding_required_description": "",
"transcoding_settings": "",
"transcoding_settings_description": "",
"transcoding_target_resolution": "",
"transcoding_target_resolution_description": "",
"transcoding_temporal_aq": "",
"transcoding_temporal_aq_description": "",
"transcoding_threads": "",
"transcoding_threads_description": "",
"transcoding_tone_mapping": "",
"transcoding_tone_mapping_description": "",
"transcoding_transcode_policy": "",
"transcoding_transcode_policy_description": "",
"transcoding_two_pass_encoding": "",
"transcoding_two_pass_encoding_setting_description": "",
"transcoding_video_codec": "",
"transcoding_video_codec_description": "",
"trash_enabled_description": "",
"trash_number_of_days": "",
"trash_number_of_days_description": "",
"trash_settings": "",
"trash_settings_description": "",
"untracked_files": "",
"untracked_files_description": "",
"user_delete_delay_settings": "",
"user_delete_delay_settings_description": "",
"user_management": "",
"user_password_has_been_reset": "",
"user_password_reset_description": "",
"user_settings": "",
"user_settings_description": "",
"user_successfully_removed": "",
"version_check_enabled_description": "",
"version_check_settings": "",
"version_check_settings_description": "",
"video_conversion_job": "",
"video_conversion_job_description": ""
},
"admin_email": "",
"admin_password": "",
"administration": "",
"advanced": "",
"album_added": "",
"album_added_notification_setting_description": "",
"album_cover_updated": "",
"album_info_updated": "",
"album_name": "",
"album_options": "",
"album_updated": "",
"album_updated_setting_description": "",
"albums": "",
"albums_count": "",
"all": "",
"all_people": "",
"allow_dark_mode": "",
"allow_edits": "",
"api_key": "",
"api_keys": "",
"app_settings": "",
"appears_in": "",
"archive": "",
"archive_or_unarchive_photo": "",
"asset_offline": "",
"assets": "",
"authorized_devices": "",
"back": "",
"backward": "",
"blurred_background": "",
"camera": "",
"camera_brand": "",
"camera_model": "",
"cancel": "",
"cancel_search": "",
"cannot_merge_people": "",
"cannot_update_the_description": "",
"change_date": "",
"change_expiration_time": "",
"change_location": "",
"change_name": "",
"change_name_successfully": "",
"change_password": "",
"change_your_password": "",
"changed_visibility_successfully": "",
"check_all": "",
"check_logs": "",
"choose_matching_people_to_merge": "",
"city": "",
"clear": "",
"clear_all": "",
"clear_message": "",
"clear_value": "",
"close": "",
"collapse_all": "",
"color_theme": "",
"comment_options": "",
"comments_are_disabled": "",
"confirm": "",
"confirm_admin_password": "",
"confirm_delete_shared_link": "",
"confirm_password": "",
"contain": "",
"context": "",
"continue": "",
"copied_image_to_clipboard": "",
"copied_to_clipboard": "",
"copy_error": "",
"copy_file_path": "",
"copy_image": "",
"copy_link": "",
"copy_link_to_clipboard": "",
"copy_password": "",
"copy_to_clipboard": "",
"country": "",
"cover": "",
"covers": "",
"create": "",
"create_album": "",
"create_library": "",
"create_link": "",
"create_link_to_share": "",
"create_new_person": "",
"create_new_user": "",
"create_user": "",
"created": "",
"current_device": "",
"custom_locale": "",
"custom_locale_description": "",
"dark": "",
"date_after": "",
"date_and_time": "",
"date_before": "",
"date_range": "",
"day": "",
"default_locale": "",
"default_locale_description": "",
"delete": "",
"delete_album": "",
"delete_api_key_prompt": "",
"delete_key": "",
"delete_library": "",
"delete_link": "",
"delete_shared_link": "",
"delete_user": "",
"deleted_shared_link": "",
"description": "",
"details": "",
"direction": "",
"disabled": "",
"disallow_edits": "",
"discover": "",
"dismiss_all_errors": "",
"dismiss_error": "",
"display_options": "",
"display_order": "",
"display_original_photos": "",
"display_original_photos_setting_description": "",
"done": "",
"download": "",
"downloading": "",
"duration": "",
"edit_album": "",
"edit_avatar": "",
"edit_date": "",
"edit_date_and_time": "",
"edit_exclusion_pattern": "",
"edit_faces": "",
"edit_import_path": "",
"edit_import_paths": "",
"edit_key": "",
"edit_link": "",
"edit_location": "",
"edit_name": "",
"edit_people": "",
"edit_title": "",
"edit_user": "",
"edited": "",
"editor": "",
"email": "",
"empty_trash": "",
"enable": "",
"enabled": "",
"end_date": "",
"error": "",
"error_loading_image": "",
"errors": {
"cleared_jobs": "",
"exclusion_pattern_already_exists": "",
"failed_job_command": "",
"import_path_already_exists": "",
"paths_validation_failed": "",
"quota_higher_than_disk_size": "",
"repair_unable_to_check_items": "",
"unable_to_add_album_users": "",
"unable_to_add_comment": "",
"unable_to_add_exclusion_pattern": "",
"unable_to_add_import_path": "",
"unable_to_add_partners": "",
"unable_to_change_album_user_role": "",
"unable_to_change_date": "",
"unable_to_change_location": "",
"unable_to_change_password": "",
"unable_to_copy_to_clipboard": "",
"unable_to_create_api_key": "",
"unable_to_create_library": "",
"unable_to_create_user": "",
"unable_to_delete_album": "",
"unable_to_delete_asset": "",
"unable_to_delete_exclusion_pattern": "",
"unable_to_delete_import_path": "",
"unable_to_delete_shared_link": "",
"unable_to_delete_user": "",
"unable_to_edit_exclusion_pattern": "",
"unable_to_edit_import_path": "",
"unable_to_empty_trash": "",
"unable_to_enter_fullscreen": "",
"unable_to_exit_fullscreen": "",
"unable_to_hide_person": "",
"unable_to_link_oauth_account": "",
"unable_to_load_album": "",
"unable_to_load_asset_activity": "",
"unable_to_load_items": "",
"unable_to_load_liked_status": "",
"unable_to_play_video": "",
"unable_to_refresh_user": "",
"unable_to_remove_album_users": "",
"unable_to_remove_api_key": "",
"unable_to_remove_deleted_assets": "",
"unable_to_remove_library": "",
"unable_to_remove_partner": "",
"unable_to_remove_reaction": "",
"unable_to_repair_items": "",
"unable_to_reset_password": "",
"unable_to_resolve_duplicate": "",
"unable_to_restore_assets": "",
"unable_to_restore_trash": "",
"unable_to_restore_user": "",
"unable_to_save_album": "",
"unable_to_save_api_key": "",
"unable_to_save_name": "",
"unable_to_save_profile": "",
"unable_to_save_settings": "",
"unable_to_scan_libraries": "",
"unable_to_scan_library": "",
"unable_to_set_profile_picture": "",
"unable_to_submit_job": "",
"unable_to_trash_asset": "",
"unable_to_unlink_account": "",
"unable_to_update_library": "",
"unable_to_update_location": "",
"unable_to_update_settings": "",
"unable_to_update_timeline_display_status": "",
"unable_to_update_user": ""
},
"exit_slideshow": "",
"expand_all": "",
"expire_after": "",
"expired": "",
"explore": "",
"export": "",
"export_as_json": "",
"extension": "",
"external": "",
"external_libraries": "",
"favorite": "",
"favorite_or_unfavorite_photo": "",
"favorites": "",
"feature_photo_updated": "",
"file_name": "",
"file_name_or_extension": "",
"filename": "",
"filetype": "",
"filter_people": "",
"find_them_fast": "",
"fix_incorrect_match": "",
"forward": "",
"general": "",
"get_help": "",
"getting_started": "",
"go_back": "",
"go_to_search": "",
"group_albums_by": "",
"has_quota": "",
"hide_gallery": "",
"hide_password": "",
"hide_person": "",
"host": "",
"hour": "",
"image": "",
"immich_logo": "",
"import_from_json": "",
"import_path": "",
"in_archive": "",
"include_archived": "",
"include_shared_albums": "",
"include_shared_partner_assets": "",
"individual_share": "",
"info": "",
"interval": {
"day_at_onepm": "",
"hours": "",
"night_at_midnight": "",
"night_at_twoam": ""
},
"invite_people": "",
"invite_to_album": "",
"jobs": "",
"keep": "",
"keyboard_shortcuts": "",
"language": "",
"language_setting_description": "",
"last_seen": "",
"leave": "",
"let_others_respond": "",
"level": "",
"library": "",
"library_options": "",
"light": "",
"link_options": "",
"link_to_oauth": "",
"linked_oauth_account": "",
"list": "",
"loading": "",
"loading_search_results_failed": "",
"log_out": "",
"log_out_all_devices": "",
"login_has_been_disabled": "",
"look": "",
"loop_videos": "",
"loop_videos_description": "",
"make": "",
"manage_shared_links": "",
"manage_sharing_with_partners": "",
"manage_the_app_settings": "",
"manage_your_account": "",
"manage_your_api_keys": "",
"manage_your_devices": "",
"manage_your_oauth_connection": "",
"map": "",
"map_marker_with_image": "",
"map_settings": "",
"matches": "",
"media_type": "",
"memories": "",
"memories_setting_description": "",
"menu": "",
"merge": "",
"merge_people": "",
"merge_people_successfully": "",
"minimize": "",
"minute": "",
"missing": "",
"model": "",
"month": "",
"more": "",
"moved_to_trash": "",
"my_albums": "",
"name": "",
"name_or_nickname": "",
"never": "",
"new_api_key": "",
"new_password": "",
"new_person": "",
"new_user_created": "",
"newest_first": "",
"next": "",
"next_memory": "",
"no": "",
"no_albums_message": "",
"no_archived_assets_message": "",
"no_assets_message": "",
"no_duplicates_found": "",
"no_exif_info_available": "",
"no_explore_results_message": "",
"no_favorites_message": "",
"no_libraries_message": "",
"no_name": "",
"no_places": "",
"no_results": "",
"no_shared_albums_message": "",
"not_in_any_album": "",
"note_apply_storage_label_to_previously_uploaded assets": "",
"notes": "",
"notification_toggle_setting_description": "",
"notifications": "",
"notifications_setting_description": "",
"oauth": "",
"offline": "",
"offline_paths": "",
"offline_paths_description": "",
"ok": "",
"oldest_first": "",
"online": "",
"only_favorites": "",
"open_the_search_filters": "",
"options": "",
"organize_your_library": "",
"other": "",
"other_devices": "",
"other_variables": "",
"owned": "",
"owner": "",
"partner_can_access": "",
"partner_can_access_assets": "",
"partner_can_access_location": "",
"partner_sharing": "",
"partners": "",
"password": "",
"password_does_not_match": "",
"password_required": "",
"password_reset_success": "",
"past_durations": {
"days": "",
"hours": "",
"years": ""
},
"path": "",
"pattern": "",
"pause": "",
"pause_memories": "",
"paused": "",
"pending": "",
"people": "",
"people_sidebar_description": "",
"permanent_deletion_warning": "",
"permanent_deletion_warning_setting_description": "",
"permanently_delete": "",
"permanently_deleted_asset": "",
"photos": "",
"photos_count": "",
"photos_from_previous_years": "",
"pick_a_location": "",
"place": "",
"places": "",
"play": "",
"play_memories": "",
"play_motion_photo": "",
"play_or_pause_video": "",
"port": "",
"preset": "",
"preview": "",
"previous": "",
"previous_memory": "",
"previous_or_next_photo": "",
"primary": "",
"profile_picture_set": "",
"public_share": "",
"reaction_options": "",
"read_changelog": "",
"recent": "",
"recent_searches": "",
"refresh": "",
"refreshed": "",
"refreshes_every_file": "",
"remove": "",
"remove_deleted_assets": "",
"remove_from_album": "",
"remove_from_favorites": "",
"remove_from_shared_link": "",
"removed_api_key": "",
"rename": "",
"repair": "",
"repair_no_results_message": "",
"replace_with_upload": "",
"require_password": "",
"require_user_to_change_password_on_first_login": "",
"reset": "",
"reset_password": "",
"reset_people_visibility": "",
"restore": "",
"restore_all": "",
"restore_user": "",
"resume": "",
"retry_upload": "",
"review_duplicates": "",
"role": "",
"save": "",
"saved_api_key": "",
"saved_profile": "",
"saved_settings": "",
"say_something": "",
"scan_all_libraries": "",
"scan_settings": "",
"search": "",
"search_albums": "",
"search_by_context": "",
"search_camera_make": "",
"search_camera_model": "",
"search_city": "",
"search_country": "",
"search_for_existing_person": "",
"search_people": "",
"search_places": "",
"search_state": "",
"search_timezone": "",
"search_type": "",
"search_your_photos": "",
"searching_locales": "",
"second": "",
"select_album_cover": "",
"select_all": "",
"select_avatar_color": "",
"select_face": "",
"select_featured_photo": "",
"select_keep_all": "",
"select_library_owner": "",
"select_new_face": "",
"select_photos": "",
"select_trash_all": "",
"selected": "",
"send_message": "",
"send_welcome_email": "",
"server_stats": "",
"set": "",
"set_as_album_cover": "",
"set_as_profile_picture": "",
"set_date_of_birth": "",
"set_profile_picture": "",
"set_slideshow_to_fullscreen": "",
"settings": "",
"settings_saved": "",
"share": "",
"shared": "",
"shared_by": "",
"shared_by_you": "",
"shared_from_partner": "",
"shared_links": "",
"shared_with_partner": "",
"sharing": "",
"sharing_sidebar_description": "",
"show_album_options": "",
"show_and_hide_people": "",
"show_file_location": "",
"show_gallery": "",
"show_hidden_people": "",
"show_in_timeline": "",
"show_in_timeline_setting_description": "",
"show_keyboard_shortcuts": "",
"show_metadata": "",
"show_or_hide_info": "",
"show_password": "",
"show_person_options": "",
"show_progress_bar": "",
"show_search_options": "",
"shuffle": "",
"sign_out": "",
"sign_up": "",
"size": "",
"skip_to_content": "",
"slideshow": "",
"slideshow_settings": "",
"sort_albums_by": "",
"stack": "",
"stack_selected_photos": "",
"stacktrace": "",
"start": "",
"start_date": "",
"state": "",
"status": "",
"stop_motion_photo": "",
"stop_photo_sharing": "",
"stop_photo_sharing_description": "",
"stop_sharing_photos_with_user": "",
"storage": "",
"storage_label": "",
"storage_usage": "",
"submit": "",
"suggestions": "",
"sunrise_on_the_beach": "",
"swap_merge_direction": "",
"sync": "",
"template": "",
"theme": "",
"theme_selection": "",
"theme_selection_description": "",
"time_based_memories": "",
"timezone": "",
"to_archive": "",
"to_favorite": "",
"toggle_settings": "",
"toggle_theme": "",
"total_usage": "",
"trash": "",
"trash_all": "",
"trash_no_results_message": "",
"trashed_items_will_be_permanently_deleted_after": "",
"type": "",
"unarchive": "",
"unfavorite": "",
"unhide_person": "",
"unknown": "",
"unknown_year": "",
"unlimited": "",
"unlink_oauth": "",
"unlinked_oauth_account": "",
"unselect_all": "",
"unstack": "",
"untracked_files": "",
"untracked_files_decription": "",
"up_next": "",
"updated_password": "",
"upload": "",
"upload_concurrency": "",
"url": "",
"usage": "",
"user": "",
"user_id": "",
"user_usage_detail": "",
"username": "",
"users": "",
"utilities": "",
"validate": "",
"variables": "",
"version": "",
"video": "",
"video_hover_setting": "",
"video_hover_setting_description": "",
"videos": "",
"videos_count": "",
"view_all": "",
"view_all_users": "",
"view_links": "",
"view_next_asset": "",
"view_previous_asset": "",
"waiting": "",
"week": "",
"welcome_to_immich": "",
"year": "",
"yes": "",
"you_dont_have_any_shared_links": "",
"zoom_image": ""
} }

View File

@@ -1,19 +1 @@
{ {}
"about": "সম্পর্কে",
"account": "অ্যাকাউন্ট",
"account_settings": "অ্যাকাউন্ট সেটিংস",
"acknowledge": "স্বীকৃতি",
"action": "কার্য",
"action_common_update": "আপডেট",
"actions": "কর্ম",
"active": "সচল",
"activity": "কার্যকলাপ",
"add": "যোগ করুন",
"add_a_description": "একটি বিবরণ যোগ করুন",
"add_a_location": "একটি অবস্থান যোগ করুন",
"add_a_name": "একটি নাম যোগ করুন",
"add_a_title": "একটি শিরোনাম যোগ করুন",
"add_endpoint": "এন্ডপয়েন্ট যোগ করুন",
"add_exclusion_pattern": "বহির্ভূতকরণ নমুনা",
"add_url": "লিঙ্ক যোগ করুন"
}

File diff suppressed because it is too large Load Diff

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