mirror of
https://github.com/immich-app/immich.git
synced 2026-05-21 15:16:31 -04:00
Compare commits
321 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f82037d44 | |||
| a277c6311f | |||
| 5889c42eb6 | |||
| 14cce0cba3 | |||
| 9b80ffd9c6 | |||
| 306a3b8c7f | |||
| be0fc403d8 | |||
| c13fd9e4b5 | |||
| 8724848fce | |||
| 2d950db940 | |||
| 4b9ebc2cff | |||
| e2d26ebdea | |||
| 8c6adf7157 | |||
| 48fdd39d30 | |||
| 22bf7c2005 | |||
| 47b45453c8 | |||
| 448c069fb6 | |||
| 958f270f0d | |||
| 9f699fdfc3 | |||
| 00da7b88a1 | |||
| 144a57ddff | |||
| 1bd2d474d7 | |||
| b33874ef12 | |||
| dbaf4b548b | |||
| 7d58d5be12 | |||
| 42fe86d24c | |||
| eeb55c279b | |||
| 5c159d70a7 | |||
| 44ae0fa7ed | |||
| f782782662 | |||
| 4436cab827 | |||
| 74789ad1c4 | |||
| 7877097b3f | |||
| fb84c1cf61 | |||
| 940a1d4ab8 | |||
| fae25dbe65 | |||
| 8dd0d7f34c | |||
| 9b78f2c0ba | |||
| 67cedfef17 | |||
| c9c2322b9d | |||
| 389356149a | |||
| 4812a2e2d8 | |||
| 8f01d06927 | |||
| a2ff075e9a | |||
| d8b39906f9 | |||
| b36911a16b | |||
| b074ee202e | |||
| 78bb6cf926 | |||
| c980f5fc19 | |||
| a26d9e05ba | |||
| c862163204 | |||
| 5fb8f9bf1a | |||
| b9b5dba037 | |||
| 8bfa75087c | |||
| 95280edd6c | |||
| a9666d2cef | |||
| 4af9edc20b | |||
| c975fe5bc7 | |||
| 12a4d8e2ee | |||
| ce9b32a61a | |||
| 4ddc288cd1 | |||
| 94b15b8678 | |||
| ff9ae24219 | |||
| b456f78771 | |||
| 1506776891 | |||
| 0e93aa74cf | |||
| e95ad9d2eb | |||
| b98a227bbd | |||
| 2dd785e3e2 | |||
| 7e754125cd | |||
| e2eb03d3a4 | |||
| bf065a834f | |||
| db79173b5b | |||
| 33666ccd21 | |||
| be93b9040c | |||
| 00dae6ac38 | |||
| 5a8fd40dc5 | |||
| 813d684aaa | |||
| 644f705be1 | |||
| f3e4bcc733 | |||
| 9a0c17fdb8 | |||
| b7c4497dfd | |||
| 9c227aeaf5 | |||
| e939fde6f1 | |||
| 019beaed0b | |||
| 0e4d6d4eac | |||
| 79f978ddeb | |||
| f2445ecab1 | |||
| 86311e3cfe | |||
| 29000461c2 | |||
| b30373b24f | |||
| bc2439883a | |||
| 044257531e | |||
| f413f5c692 | |||
| 52307ed09f | |||
| 77020e742a | |||
| 38b135ff36 | |||
| cda4a2a5fc | |||
| 88002cf7fe | |||
| 694ea151f5 | |||
| b092c8b601 | |||
| 48e6e17829 | |||
| 0519833d75 | |||
| 34caed3b2b | |||
| 677cb660f5 | |||
| 9b0b2bfcf2 | |||
| ac6938a629 | |||
| 16749ff8ba | |||
| bba4a00eb1 | |||
| 9dafc8e8e9 | |||
| 4e44fb9cf7 | |||
| 82db581cc5 | |||
| b66c97b785 | |||
| ff936f901d | |||
| 48fe111daa | |||
| 0581b49750 | |||
| 2c6d4f3fe1 | |||
| 55513cd59f | |||
| 10fa928abe | |||
| e322d44f95 | |||
| c2a279e49e | |||
| 226b9390db | |||
| 754f072ef9 | |||
| c91d8745b4 | |||
| f3b7cd6198 | |||
| 990aff441b | |||
| 001d7d083f | |||
| 3fd24e2083 | |||
| 6bb8f4fcc4 | |||
| d4605b21d9 | |||
| 3bd37ebbfb | |||
| 5c3777ab46 | |||
| 6c531e0a5a | |||
| 471c27cd33 | |||
| 4773788a88 | |||
| d49d995611 | |||
| 0ac3d6a83a | |||
| 9996ee12d0 | |||
| 0a79dd1228 | |||
| e45308b949 | |||
| c403e03a42 | |||
| e7db3b220d | |||
| 28d5c169c0 | |||
| 0f2fe656db | |||
| 34ce68095d | |||
| 8764a1894b | |||
| 27f69b39b2 | |||
| 9fc6fbc373 | |||
| 9fc32b6f7a | |||
| 4571940a4e | |||
| 1ceb6d2e21 | |||
| 1a4c5d73ac | |||
| 22b43bf4d9 | |||
| 45eff1c663 | |||
| 56b8e1b8a9 | |||
| f79c8cf1c1 | |||
| 8e50d25f45 | |||
| 8222781d1f | |||
| 08c4594cde | |||
| d325231df2 | |||
| f2726606e0 | |||
| 0edbca24e4 | |||
| 4791d9c0c3 | |||
| a47b232235 | |||
| df0c86920d | |||
| 422111d26e | |||
| 7a83baaf27 | |||
| aaf34fa7d4 | |||
| 4a384bca86 | |||
| dd72ec2621 | |||
| e73686bd76 | |||
| 6e9a425592 | |||
| 6012d22d98 | |||
| abfcffb423 | |||
| ec7246b86f | |||
| 9597f8c37f | |||
| 7b0deb1fd3 | |||
| 5ab05e57fa | |||
| ba3f114625 | |||
| 9b642633c1 | |||
| a05c8c6087 | |||
| 35a521c6ec | |||
| 09fabb36b6 | |||
| c259fee309 | |||
| 78ba9cbc63 | |||
| 33d75462c9 | |||
| e9451f10d6 | |||
| 480b7e8d65 | |||
| 228ac63ab9 | |||
| 7e9da945f6 | |||
| dd03c9c0a9 | |||
| 16e4a2b92a | |||
| 5caa7e1902 | |||
| 8279e1078a | |||
| 011ecbb43d | |||
| 2725c96cb1 | |||
| 3c476b1987 | |||
| 5989c9b4aa | |||
| 13c4260a1f | |||
| 54bc9ddd69 | |||
| f94e0fbc39 | |||
| 5532f669eb | |||
| e4c24bdec8 | |||
| 56f14162f6 | |||
| 8abbbc49cf | |||
| 4eb08eee18 | |||
| 0560f98c2d | |||
| 49ad411d50 | |||
| 2478cc40f4 | |||
| 44eeb1e088 | |||
| a868ae3ad0 | |||
| acac0d4f37 | |||
| 8c40a28fef | |||
| b2081eda1e | |||
| 9670c853c6 | |||
| cc2dacb308 | |||
| 15fc6b18f3 | |||
| a284e38890 | |||
| 05010c3a84 | |||
| 4da3d68a67 | |||
| 20c639e52a | |||
| 6deb97d5bc | |||
| b282d83e95 | |||
| 5bc08f8654 | |||
| f54924d46a | |||
| dffe4d1d5c | |||
| 7f47cdd645 | |||
| 625b30c50a | |||
| 8619d14eca | |||
| 062546c168 | |||
| ea668d6b22 | |||
| f06af2c600 | |||
| 9dd2633e0c | |||
| 13a514c189 | |||
| b0c9120bb6 | |||
| bc4265416d | |||
| d4434f2276 | |||
| f4e156494f | |||
| 84abad564e | |||
| 02d356f5dd | |||
| e963eedd26 | |||
| 3da4acfe67 | |||
| e06cedb626 | |||
| ac5ef6a56d | |||
| d6c724b13b | |||
| aa87d1b9a3 | |||
| dc4da4b3d6 | |||
| 7dbd08a747 | |||
| 1d89190f96 | |||
| c2d8400899 | |||
| a100a4025e | |||
| 334fc250d3 | |||
| 28ca5f59fe | |||
| 789d82632a | |||
| 9f9569c152 | |||
| fae05270a3 | |||
| 771816f601 | |||
| e25ec4ec17 | |||
| dd9046508d | |||
| 177d1c9a30 | |||
| ded8d4e2b4 | |||
| e454c3566b | |||
| 4c79c3c902 | |||
| 3bed1b6131 | |||
| 3c9fb651d0 | |||
| 55e625a2ac | |||
| ca6c486a80 | |||
| d94d9600a7 | |||
| 11e5c42bc9 | |||
| 33c6cf8325 | |||
| dd97395f3a | |||
| 7ae268e287 | |||
| f07e2b58f0 | |||
| 4b8f90aa55 | |||
| 55ee9f76da | |||
| 30f6d4439e | |||
| f62d98a0d1 | |||
| db3d580761 | |||
| 0bc38fefe6 | |||
| acc4219849 | |||
| 5234e21241 | |||
| 17b327bfcd | |||
| d14d0a9b9b | |||
| bf47147fbb | |||
| 9ea0a69a72 | |||
| 00f43ffc25 | |||
| 96dc4a77a0 | |||
| db7158b967 | |||
| e5722c525b | |||
| f616de5af8 | |||
| 4f39663d27 | |||
| 367025a3a8 | |||
| 60dafecdc9 | |||
| 16c1c3c780 | |||
| e633bc3f24 | |||
| a07d7b0c82 | |||
| a469d350be | |||
| ccab4c88bb | |||
| 430638e129 | |||
| caebe5166a | |||
| 1bd28c3e78 | |||
| 31a55aaa73 | |||
| 8b2e1509ff | |||
| d0cb97f994 | |||
| f0cf3311d5 | |||
| 3ce0654cab | |||
| f0e2fced57 | |||
| 8ba20cbd44 | |||
| 1d25267f22 | |||
| a4d95b7aba | |||
| 25d0bdc9f5 | |||
| 905b9bd560 | |||
| 672743f543 | |||
| 27c45b5ddb | |||
| 82c6302549 | |||
| aae64b5e2f | |||
| 18bf96b4b2 | |||
| 84f2956941 | |||
| 6044b41648 | |||
| b4e16efdf4 | |||
| 19da655390 |
@@ -2,6 +2,7 @@
|
||||
"name": "Immich - Backend, Frontend and ML",
|
||||
"service": "immich-server",
|
||||
"runServices": [
|
||||
"immich-init",
|
||||
"immich-server",
|
||||
"redis",
|
||||
"database",
|
||||
@@ -31,29 +32,8 @@
|
||||
"tasks": {
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Fix Permissions, Install Dependencies",
|
||||
"type": "shell",
|
||||
"command": "[ -f /immich-devcontainer/container-start.sh ] && /immich-devcontainer/container-start.sh || exit 0",
|
||||
"isBackground": true,
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "dedicated",
|
||||
"showReuseMessage": true,
|
||||
"clear": false,
|
||||
"group": "Devcontainer tasks",
|
||||
"close": true
|
||||
},
|
||||
"runOptions": {
|
||||
"runOn": "default"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Immich API Server (Nest)",
|
||||
"dependsOn": ["Fix Permissions, Install Dependencies"],
|
||||
"type": "shell",
|
||||
"command": "[ -f /immich-devcontainer/container-start-backend.sh ] && /immich-devcontainer/container-start-backend.sh || exit 0",
|
||||
"isBackground": true,
|
||||
@@ -74,7 +54,6 @@
|
||||
},
|
||||
{
|
||||
"label": "Immich Web Server (Vite)",
|
||||
"dependsOn": ["Fix Permissions, Install Dependencies"],
|
||||
"type": "shell",
|
||||
"command": "[ -f /immich-devcontainer/container-start-frontend.sh ] && /immich-devcontainer/container-start-frontend.sh || exit 0",
|
||||
"isBackground": true,
|
||||
@@ -130,8 +109,8 @@
|
||||
}
|
||||
},
|
||||
"overrideCommand": true,
|
||||
"workspaceFolder": "/workspaces/immich",
|
||||
"remoteUser": "node",
|
||||
"workspaceFolder": "/usr/src/app",
|
||||
"remoteUser": "root",
|
||||
"userEnvProbe": "loginInteractiveShell",
|
||||
"remoteEnv": {
|
||||
// The location where your uploaded files are stored
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
services:
|
||||
immich-app-base:
|
||||
image: busybox
|
||||
immich-server:
|
||||
extends:
|
||||
service: immich-app-base
|
||||
profiles: !reset []
|
||||
image: immich-server-dev:latest
|
||||
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
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
||||
- pnpm-store:/usr/src/app/.pnpm-store
|
||||
- server-node_modules:/usr/src/app/server/node_modules
|
||||
- web-node_modules:/usr/src/app/web/node_modules
|
||||
- github-node_modules:/usr/src/app/.github/node_modules
|
||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||
- app-node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
immich-web:
|
||||
env_file: !reset []
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "Immich - Mobile",
|
||||
"service": "immich-server",
|
||||
"runServices": [
|
||||
"immich-init",
|
||||
"immich-server",
|
||||
"redis",
|
||||
"database",
|
||||
@@ -35,7 +36,7 @@
|
||||
},
|
||||
"forwardPorts": [],
|
||||
"overrideCommand": true,
|
||||
"workspaceFolder": "/workspaces/immich",
|
||||
"workspaceFolder": "/usr/src/app",
|
||||
"remoteUser": "node",
|
||||
"userEnvProbe": "loginInteractiveShell",
|
||||
"remoteEnv": {
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
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() {
|
||||
@@ -30,52 +25,8 @@ run_cmd() {
|
||||
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
|
||||
export IMMICH_WORKSPACE="/usr/src/app"
|
||||
|
||||
log "Found immich workspace in $IMMICH_WORKSPACE"
|
||||
log ""
|
||||
|
||||
fix_permissions() {
|
||||
|
||||
log "Fixing permissions for ${IMMICH_WORKSPACE}"
|
||||
|
||||
# Change ownership for directories that exist
|
||||
for dir in "${IMMICH_WORKSPACE}/.vscode" \
|
||||
"${IMMICH_WORKSPACE}/server/upload" \
|
||||
"${IMMICH_WORKSPACE}/.pnpm-store" \
|
||||
"${IMMICH_WORKSPACE}/.github/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/cli/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/e2e/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/server/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/server/dist" \
|
||||
"${IMMICH_WORKSPACE}/web/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/web/dist"; do
|
||||
if [ -d "$dir" ]; then
|
||||
run_cmd sudo chown node -R "$dir"
|
||||
fi
|
||||
done
|
||||
|
||||
log ""
|
||||
}
|
||||
|
||||
install_dependencies() {
|
||||
|
||||
log "Installing dependencies"
|
||||
(
|
||||
cd "${IMMICH_WORKSPACE}" || exit 1
|
||||
export CI=1 FROZEN=1 OFFLINE=1
|
||||
run_cmd make setup-web-dev setup-server-dev
|
||||
)
|
||||
log ""
|
||||
}
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
services:
|
||||
immich-app-base:
|
||||
image: busybox
|
||||
immich-server:
|
||||
extends:
|
||||
service: immich-app-base
|
||||
profiles: !reset []
|
||||
image: immich-server-dev:latest
|
||||
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
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- pnpm-store:/usr/src/app/.pnpm-store
|
||||
- server-node_modules:/usr/src/app/server/node_modules
|
||||
- web-node_modules:/usr/src/app/web/node_modules
|
||||
- github-node_modules:/usr/src/app/.github/node_modules
|
||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||
- app-node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
- pnpm_store_server:/buildcache/pnpm-store
|
||||
- ../plugins:/build/corePlugin
|
||||
immich-web:
|
||||
env_file: !reset []
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/bash
|
||||
# shellcheck source=common.sh
|
||||
# shellcheck disable=SC1091
|
||||
source /immich-devcontainer/container-common.sh
|
||||
|
||||
log "Setting up Immich dev container..."
|
||||
fix_permissions
|
||||
|
||||
log "Setup complete, please wait while backend and frontend services automatically start"
|
||||
log
|
||||
log "If necessary, the services may be manually started using"
|
||||
log
|
||||
log "$ /immich-devcontainer/container-start-backend.sh"
|
||||
log "$ /immich-devcontainer/container-start-frontend.sh"
|
||||
log
|
||||
log "From different terminal windows, as these scripts automatically restart the server"
|
||||
log "on error, and will continuously run in a loop"
|
||||
+1
-1
@@ -1 +1 @@
|
||||
24.13.1
|
||||
24.14.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"scripts": {
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write ."
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.7.4"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
name: Auto-close PRs
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||
types: [opened, edited, labeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
parse_template:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.action != 'labeled' && github.event.pull_request.head.repo.fork == true }}
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
uses_template: ${{ steps.check.outputs.uses_template }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
sparse-checkout: .github/pull_request_template.md
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check required sections
|
||||
id: check
|
||||
env:
|
||||
BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
OK=true
|
||||
while IFS= read -r header; do
|
||||
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
|
||||
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
|
||||
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
|
||||
|
||||
close_template:
|
||||
runs-on: ubuntu-latest
|
||||
needs: parse_template
|
||||
if: ${{ needs.parse_template.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Comment and close
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
subjectId: $prId,
|
||||
body: $body
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
closePullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
|
||||
- name: Add label
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "auto-closed:template"
|
||||
|
||||
close_llm:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.action == 'labeled' && github.event.label.name == 'auto-closed:llm' }}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Comment and close
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
subjectId: $prId,
|
||||
body: $body
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
closePullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
|
||||
reopen:
|
||||
runs-on: ubuntu-latest
|
||||
needs: parse_template
|
||||
if: >-
|
||||
${{
|
||||
needs.parse_template.outputs.uses_template == 'true'
|
||||
&& github.event.pull_request.state == 'closed'
|
||||
&& contains(github.event.pull_request.labels.*.name, 'auto-closed:template')
|
||||
}}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Remove template label
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --remove-label "auto-closed:template" || true
|
||||
|
||||
- name: Check for remaining auto-closed labels
|
||||
id: check_labels
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
REMAINING=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels \
|
||||
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
|
||||
echo "remaining=$REMAINING" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Reopen PR
|
||||
if: ${{ steps.check_labels.outputs.remaining == '0' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f query='
|
||||
mutation ReopenPR($prId: ID!) {
|
||||
reopenPullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
@@ -51,14 +51,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
|
||||
- name: Restore Gradle Cache
|
||||
id: cache-gradle-restore
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
key: build-mobile-gradle-${{ runner.os }}-main
|
||||
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
|
||||
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version-file: ./mobile/pubspec.yaml
|
||||
@@ -153,14 +153,14 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Publish Android Artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: release-apk-signed
|
||||
path: mobile/build/app/outputs/flutter-apk/*.apk
|
||||
|
||||
- name: Save Gradle Cache
|
||||
id: cache-gradle-save
|
||||
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
if: github.ref == 'refs/heads/main'
|
||||
with:
|
||||
path: |
|
||||
@@ -185,13 +185,13 @@ jobs:
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2
|
||||
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version-file: ./mobile/pubspec.yaml
|
||||
@@ -210,7 +210,7 @@ jobs:
|
||||
working-directory: ./mobile
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true
|
||||
@@ -291,7 +291,7 @@ jobs:
|
||||
security delete-keychain build.keychain || true
|
||||
|
||||
- name: Upload IPA artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: ios-release-ipa
|
||||
path: mobile/ios/Runner.ipa
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
actions: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
@@ -19,13 +19,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
# sha is pinning to a commit instead of a tag since the action does not tag versions
|
||||
uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4
|
||||
uses: oasdiff/oasdiff-action/breaking@2a37bc82462349c03a533b8b608bebbaf57b3e60 # v0.0.33
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
working-directory: ./cli
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -42,10 +42,10 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -83,13 +83,13 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
|
||||
- name: Generate docker image tags
|
||||
id: metadata
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
flavor: |
|
||||
latest=false
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
file: cli/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
needs: [get_body, should_run]
|
||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||
container:
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:4f9860d04c88f7f87861f8ee84bfeedaec15ed7ca5ca87bc7db44b036f81645f
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:df7188ba88abb0800d73cc97d3633280f0c0c3d4c441d678225067bf154150fb
|
||||
outputs:
|
||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||
steps:
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
name: Close LLM-generated PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
comment_and_close:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.label.name == 'llm-generated' }}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Comment and close
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
subjectId: $prId,
|
||||
body: $body
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
|
||||
closePullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/autobuild@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
|
||||
|
||||
# âšī¸ 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
|
||||
@@ -83,6 +83,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
@@ -23,14 +23,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
suffix: ['']
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -131,8 +131,8 @@ jobs:
|
||||
- device: rocm
|
||||
suffixes: '-rocm'
|
||||
platforms: linux/amd64
|
||||
runner-mapping: '{"linux/amd64": "pokedex-giant"}'
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
|
||||
runner-mapping: '{"linux/amd64": "pokedex-large"}'
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@61a0fc2b41524edcc7c9fffb8bb178e6b0ccf21d # multi-runner-build-workflow-v2.3.0
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
name: Build and Push Server
|
||||
needs: pre-job
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@61a0fc2b41524edcc7c9fffb8bb178e6b0ccf21d # multi-runner-build-workflow-v2.3.0
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
|
||||
@@ -189,6 +189,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
|
||||
@@ -21,14 +21,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -67,10 +67,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './docs/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload build output
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: docs-build-output
|
||||
path: docs/build/
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
artifact: ${{ steps.get-artifact.outputs.result }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
|
||||
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
|
||||
|
||||
- name: Load parameters
|
||||
id: parameters
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
|
||||
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
|
||||
|
||||
- name: Destroy Docs Subdomain
|
||||
env:
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -29,10 +29,10 @@ jobs:
|
||||
persist-credentials: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Generate a token
|
||||
id: generate_token
|
||||
if: ${{ inputs.skip != true }}
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
@@ -14,13 +14,13 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Require PR to have a changelog label
|
||||
uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5.5.1
|
||||
uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5.5.2
|
||||
with:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
mode: exactly
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -63,13 +63,13 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -136,13 +136,13 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download APK
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-apk-signed
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Create draft release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
|
||||
with:
|
||||
draft: true
|
||||
tag_name: ${{ needs.bump_version.outputs.version }}
|
||||
@@ -151,6 +151,7 @@ jobs:
|
||||
body_path: misc/release/notes.tmpl
|
||||
files: |
|
||||
docker/docker-compose.yml
|
||||
docker/docker-compose.rootless.yml
|
||||
docker/example.env
|
||||
docker/hwaccel.ml.yml
|
||||
docker/hwaccel.transcoding.yml
|
||||
|
||||
@@ -14,12 +14,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
message-id: 'preview-status'
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -48,14 +48,14 @@ jobs:
|
||||
name: 'preview'
|
||||
})
|
||||
|
||||
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
|
||||
if: ${{ github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
message-id: 'preview-status'
|
||||
message: 'PRs from forks cannot have preview environments.'
|
||||
|
||||
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
name: Manage release PR
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: true
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
|
||||
- name: Determine release type
|
||||
id: bump-type
|
||||
uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Bump versions
|
||||
env:
|
||||
TYPE: ${{ steps.bump-type.outputs.bump }}
|
||||
run: |
|
||||
if [ "$TYPE" == "none" ]; then
|
||||
exit 1 # TODO: Is there a cleaner way to abort the workflow?
|
||||
fi
|
||||
misc/release/pump-version.sh -s $TYPE -m true
|
||||
|
||||
- name: Manage Outline release document
|
||||
id: outline
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
|
||||
NEXT_VERSION: ${{ steps.bump-type.outputs.next }}
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
const outlineKey = process.env.OUTLINE_API_KEY;
|
||||
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'
|
||||
const collectionId = 'e2910656-714c-4871-8721-447d9353bd73';
|
||||
const baseUrl = 'https://outline.immich.cloud';
|
||||
|
||||
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${outlineKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ parentDocumentId })
|
||||
});
|
||||
|
||||
if (!listResponse.ok) {
|
||||
throw new Error(`Outline list failed: ${listResponse.statusText}`);
|
||||
}
|
||||
|
||||
const listData = await listResponse.json();
|
||||
const allDocuments = listData.data || [];
|
||||
|
||||
const document = allDocuments.find(doc => doc.title === 'next');
|
||||
|
||||
let documentId;
|
||||
let documentUrl;
|
||||
let documentText;
|
||||
|
||||
if (!document) {
|
||||
// Create new document
|
||||
console.log('No existing document found. Creating new one...');
|
||||
const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8');
|
||||
const createResponse = await fetch(`${baseUrl}/api/documents.create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${outlineKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: 'next',
|
||||
text: notesTmpl,
|
||||
collectionId: collectionId,
|
||||
parentDocumentId: parentDocumentId,
|
||||
publish: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(`Failed to create document: ${createResponse.statusText}`);
|
||||
}
|
||||
|
||||
const createData = await createResponse.json();
|
||||
documentId = createData.data.id;
|
||||
const urlId = createData.data.urlId;
|
||||
documentUrl = `${baseUrl}/doc/next-${urlId}`;
|
||||
documentText = createData.data.text || '';
|
||||
console.log(`Created new document: ${documentUrl}`);
|
||||
} else {
|
||||
documentId = document.id;
|
||||
const docPath = document.url;
|
||||
documentUrl = `${baseUrl}${docPath}`;
|
||||
documentText = document.text || '';
|
||||
console.log(`Found existing document: ${documentUrl}`);
|
||||
}
|
||||
|
||||
// Generate GitHub release notes
|
||||
console.log('Generating GitHub release notes...');
|
||||
const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: `${process.env.NEXT_VERSION}`,
|
||||
});
|
||||
|
||||
// Combine the content
|
||||
const changelog = `
|
||||
# ${process.env.NEXT_VERSION}
|
||||
|
||||
${documentText}
|
||||
|
||||
${releaseNotesResponse.data.body}
|
||||
|
||||
---
|
||||
|
||||
`
|
||||
|
||||
const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '';
|
||||
fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8');
|
||||
|
||||
core.setOutput('document_url', documentUrl);
|
||||
|
||||
- name: Create PR
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||
title: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||
body: 'Release notes: ${{ steps.outline.outputs.document_url }}'
|
||||
labels: 'changelog:skip'
|
||||
branch: 'release/next'
|
||||
draft: true
|
||||
@@ -1,149 +0,0 @@
|
||||
name: release.yml
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
paths:
|
||||
- CHANGELOG.md
|
||||
|
||||
jobs:
|
||||
# Maybe double check PR source branch?
|
||||
|
||||
merge_translations:
|
||||
uses: ./.github/workflows/merge-translations.yml
|
||||
permissions:
|
||||
pull-requests: write
|
||||
secrets:
|
||||
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
|
||||
|
||||
build_mobile:
|
||||
uses: ./.github/workflows/build-mobile.yml
|
||||
needs: merge_translations
|
||||
permissions:
|
||||
contents: read
|
||||
secrets:
|
||||
KEY_JKS: ${{ secrets.KEY_JKS }}
|
||||
ALIAS: ${{ secrets.ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||
# iOS secrets
|
||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
|
||||
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl
|
||||
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||
with:
|
||||
ref: main
|
||||
environment: production
|
||||
|
||||
prepare_release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build_mobile
|
||||
permissions:
|
||||
actions: read # To download the app artifact
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
ref: main
|
||||
|
||||
- name: Extract changelog
|
||||
id: changelog
|
||||
run: |
|
||||
CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
|
||||
sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
|
||||
echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
|
||||
VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download APK
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: release-apk-signed
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Create draft release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.result }}
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
body_path: ${{ steps.changelog.outputs.path }}
|
||||
draft: true
|
||||
files: |
|
||||
docker/docker-compose.yml
|
||||
docker/docker-compose.rootless.yml
|
||||
docker/example.env
|
||||
docker/hwaccel.ml.yml
|
||||
docker/hwaccel.transcoding.yml
|
||||
docker/prometheus.yml
|
||||
*.apk
|
||||
|
||||
- name: Rename Outline document
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
continue-on-error: true
|
||||
env:
|
||||
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
|
||||
VERSION: ${{ steps.changelog.outputs.version }}
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
const outlineKey = process.env.OUTLINE_API_KEY;
|
||||
const version = process.env.VERSION;
|
||||
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
|
||||
const baseUrl = 'https://outline.immich.cloud';
|
||||
|
||||
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${outlineKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ parentDocumentId })
|
||||
});
|
||||
|
||||
if (!listResponse.ok) {
|
||||
throw new Error(`Outline list failed: ${listResponse.statusText}`);
|
||||
}
|
||||
|
||||
const listData = await listResponse.json();
|
||||
const allDocuments = listData.data || [];
|
||||
const document = allDocuments.find(doc => doc.title === 'next');
|
||||
|
||||
if (document) {
|
||||
console.log(`Found document 'next', renaming to '${version}'...`);
|
||||
|
||||
const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${outlineKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: document.id,
|
||||
title: version
|
||||
})
|
||||
});
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
|
||||
}
|
||||
} else {
|
||||
console.log('No document titled "next" found to rename');
|
||||
}
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -30,10 +30,10 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './open-api/typescript-sdk/.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
@@ -20,14 +20,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
working-directory: ./mobile
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
|
||||
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version-file: ./mobile/pubspec.yaml
|
||||
|
||||
+53
-53
@@ -17,14 +17,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -75,9 +75,9 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
working-directory: ./cli
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -119,9 +119,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
working-directory: ./cli
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -166,9 +166,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
working-directory: ./web
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -208,9 +208,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -241,7 +241,7 @@ jobs:
|
||||
working-directory: ./web
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -252,9 +252,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -279,7 +279,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -290,9 +290,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -327,7 +327,7 @@ jobs:
|
||||
working-directory: ./e2e
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -338,9 +338,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -373,7 +373,7 @@ jobs:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -385,9 +385,9 @@ jobs:
|
||||
submodules: 'recursive'
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -412,7 +412,7 @@ jobs:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -424,9 +424,9 @@ jobs:
|
||||
submodules: 'recursive'
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -464,7 +464,7 @@ jobs:
|
||||
run: docker compose logs --no-color > docker-compose-logs.txt
|
||||
working-directory: ./e2e
|
||||
- name: Archive Docker logs
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-server-docker-logs-${{ matrix.runner }}
|
||||
@@ -484,7 +484,7 @@ jobs:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -496,9 +496,9 @@ jobs:
|
||||
submodules: 'recursive'
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -511,7 +511,7 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --only-shell
|
||||
run: pnpm exec playwright install chromium --only-shell
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Docker build
|
||||
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
@@ -522,7 +522,7 @@ jobs:
|
||||
run: pnpm test:web
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Archive e2e test (web) results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: e2e-web-test-results-${{ matrix.runner }}
|
||||
@@ -533,7 +533,7 @@ jobs:
|
||||
run: pnpm test:web:ui
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Archive ui test (web) results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: e2e-ui-test-results-${{ matrix.runner }}
|
||||
@@ -544,7 +544,7 @@ jobs:
|
||||
run: pnpm test:web:maintenance
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Archive maintenance tests (web) results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: e2e-maintenance-isolated-test-results-${{ matrix.runner }}
|
||||
@@ -554,7 +554,7 @@ jobs:
|
||||
run: docker compose logs --no-color > docker-compose-logs.txt
|
||||
working-directory: ./e2e
|
||||
- name: Archive Docker logs
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-web-docker-logs-${{ matrix.runner }}
|
||||
@@ -566,7 +566,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
mobile-unit-tests:
|
||||
@@ -578,7 +578,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -588,7 +588,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
|
||||
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version-file: ./mobile/pubspec.yaml
|
||||
@@ -610,7 +610,7 @@ jobs:
|
||||
working-directory: ./machine-learning
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -620,7 +620,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
python-version: 3.11
|
||||
- name: Install dependencies
|
||||
@@ -650,7 +650,7 @@ jobs:
|
||||
working-directory: ./.github
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -661,9 +661,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './.github/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -680,7 +680,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -701,7 +701,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -712,9 +712,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -763,7 +763,7 @@ jobs:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -774,9 +774,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -24,14 +24,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -68,6 +68,6 @@ jobs:
|
||||
permissions: {}
|
||||
if: always()
|
||||
steps:
|
||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
|
||||
+11
-5
@@ -4,12 +4,18 @@ module.exports = {
|
||||
if (!pkg.name) {
|
||||
return pkg;
|
||||
}
|
||||
// make exiftool-vendored.pl a regular dependency since Docker prod
|
||||
// images build with --no-optional to reduce image size
|
||||
if (pkg.name === "exiftool-vendored") {
|
||||
if (pkg.optionalDependencies["exiftool-vendored.pl"]) {
|
||||
// make exiftool-vendored.pl a regular dependency
|
||||
pkg.dependencies["exiftool-vendored.pl"] =
|
||||
pkg.optionalDependencies["exiftool-vendored.pl"];
|
||||
delete pkg.optionalDependencies["exiftool-vendored.pl"];
|
||||
const binaryPackage =
|
||||
process.platform === "win32"
|
||||
? "exiftool-vendored.exe"
|
||||
: "exiftool-vendored.pl";
|
||||
|
||||
if (pkg.optionalDependencies[binaryPackage]) {
|
||||
pkg.dependencies[binaryPackage] =
|
||||
pkg.optionalDependencies[binaryPackage];
|
||||
delete pkg.optionalDependencies[binaryPackage];
|
||||
}
|
||||
}
|
||||
return pkg;
|
||||
|
||||
Vendored
+8
-1
@@ -5,6 +5,13 @@
|
||||
"dbaeumer.vscode-eslint",
|
||||
"dart-code.flutter",
|
||||
"dart-code.dart-code",
|
||||
"dcmdev.dcm-vscode-extension"
|
||||
"dcmdev.dcm-vscode-extension",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"ms-playwright.playwright",
|
||||
"vitest.explorer",
|
||||
"editorconfig.editorconfig",
|
||||
"foxundermoon.shell-format",
|
||||
"timonwong.shellcheck",
|
||||
"bluebrown.yamlfmt"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+35
-13
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[dart]": {
|
||||
"editor.defaultFormatter": "Dart-Code.dart-code",
|
||||
@@ -19,18 +18,15 @@
|
||||
"source.removeUnusedImports": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[svelte]": {
|
||||
"editor.codeActionsOnSave": {
|
||||
@@ -38,8 +34,7 @@
|
||||
"source.removeUnusedImports": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "svelte.svelte-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.codeActionsOnSave": {
|
||||
@@ -47,18 +42,45 @@
|
||||
"source.removeUnusedImports": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"cSpell.words": ["immich"],
|
||||
"css.lint.unknownAtRules": "ignore",
|
||||
"editor.bracketPairColorization.enabled": true,
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.useFlatConfig": true,
|
||||
"eslint.validate": ["javascript", "typescript", "svelte"],
|
||||
"eslint.workingDirectories": [
|
||||
{ "directory": "cli", "changeProcessCWD": true },
|
||||
{ "directory": "e2e", "changeProcessCWD": true },
|
||||
{ "directory": "server", "changeProcessCWD": true },
|
||||
{ "directory": "web", "changeProcessCWD": true }
|
||||
],
|
||||
"files.watcherExclude": {
|
||||
"**/.jj/**": true,
|
||||
"**/.git/**": true,
|
||||
"**/node_modules/**": true,
|
||||
"**/build/**": true,
|
||||
"**/dist/**": true,
|
||||
"**/.svelte-kit/**": true
|
||||
},
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
||||
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/build": true,
|
||||
"**/dist": true,
|
||||
"**/.svelte-kit": true,
|
||||
"**/open-api/typescript-sdk/src": true
|
||||
},
|
||||
"svelte.enable-ts-plugin": true,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||
"tailwindCSS.experimental.configFile": {
|
||||
"web/src/app.css": "web/src/**"
|
||||
},
|
||||
"js/ts.preferences.importModuleSpecifier": "non-relative",
|
||||
"vitest.maximumConfigs": 10
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ Please try to keep pull requests as focused as possible. A PR should do exactly
|
||||
|
||||
If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on!
|
||||
|
||||
We usually do not assign issues to new contributors, since it happens often that a PR is never even opened. Again, reach out on Discord if you fear putting a lot of time into fixing an issue, but ending up with a duplicate PR.
|
||||
|
||||
## Use of generative AI
|
||||
|
||||
We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request.
|
||||
|
||||
@@ -52,7 +52,7 @@ attach-server:
|
||||
docker exec -it docker_immich-server_1 sh
|
||||
|
||||
renovate:
|
||||
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
|
||||
LOG_LEVEL=debug pnpm exec renovate --platform=local --repository-cache=reset
|
||||
|
||||
# Directories that need to be created for volumes or build output
|
||||
VOLUME_DIRS = \
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
24.13.1
|
||||
24.14.0
|
||||
|
||||
+14
-15
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.5.6",
|
||||
"version": "2.6.3",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
@@ -13,31 +13,30 @@
|
||||
"cli"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@types/byte-size": "^8.1.0",
|
||||
"@types/cli-progress": "^3.11.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^24.10.13",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"@types/node": "^24.12.0",
|
||||
"@vitest/coverage-v8": "^4.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
"commander": "^12.0.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^62.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"eslint-plugin-unicorn": "^63.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"mock-fs": "^5.2.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite-tsconfig-paths": "^6.0.0",
|
||||
"vitest": "^3.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.0.0",
|
||||
"vitest-fetch-mock": "^0.4.0",
|
||||
"yaml": "^2.3.1"
|
||||
},
|
||||
@@ -45,12 +44,12 @@
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --sourcemap true",
|
||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"prepack": "npm run build",
|
||||
"lint:fix": "pnpm run lint --fix",
|
||||
"prepack": "pnpm run build",
|
||||
"test": "vitest",
|
||||
"test:cov": "vitest --coverage",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different .",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"repository": {
|
||||
@@ -69,6 +68,6 @@
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.13.1"
|
||||
"node": "24.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
+136
-37
@@ -1,13 +1,21 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { describe, expect, it, MockedFunction, vi } from 'vitest';
|
||||
|
||||
import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk';
|
||||
import createFetchMock from 'vitest-fetch-mock';
|
||||
|
||||
import { checkForDuplicates, getAlbumName, startWatch, uploadFiles, UploadOptionsDto } from 'src/commands/asset';
|
||||
import {
|
||||
checkForDuplicates,
|
||||
deleteFiles,
|
||||
findSidecar,
|
||||
getAlbumName,
|
||||
startWatch,
|
||||
uploadFiles,
|
||||
UploadOptionsDto,
|
||||
} from 'src/commands/asset';
|
||||
|
||||
vi.mock('@immich/sdk');
|
||||
|
||||
@@ -50,7 +58,7 @@ describe('uploadFiles', () => {
|
||||
});
|
||||
|
||||
it('returns new assets when upload file is successful', async () => {
|
||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
|
||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
||||
return {
|
||||
status: 200,
|
||||
body: JSON.stringify({ id: 'fc5621b1-86f6-44a1-9905-403e607df9f5', status: 'created' }),
|
||||
@@ -67,7 +75,7 @@ describe('uploadFiles', () => {
|
||||
|
||||
it('returns new assets when upload file retry is successful', async () => {
|
||||
let counter = 0;
|
||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
|
||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
||||
counter++;
|
||||
if (counter < retry) {
|
||||
throw new Error('Network error');
|
||||
@@ -88,7 +96,7 @@ describe('uploadFiles', () => {
|
||||
});
|
||||
|
||||
it('returns new assets when upload file retry is failed', async () => {
|
||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
|
||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
||||
throw new Error('Network error');
|
||||
});
|
||||
|
||||
@@ -228,16 +236,19 @@ describe('startWatch', () => {
|
||||
await sleep(100); // to debounce the watcher from considering the test file as a existing file
|
||||
await fs.promises.writeFile(testFilePath, 'testjpg');
|
||||
|
||||
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: [
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: [
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter out unsupported files', async () => {
|
||||
@@ -249,16 +260,19 @@ describe('startWatch', () => {
|
||||
await fs.promises.writeFile(testFilePath, 'testjpg');
|
||||
await fs.promises.writeFile(unsupportedFilePath, 'testtxt');
|
||||
|
||||
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
}),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
@@ -283,16 +297,19 @@ describe('startWatch', () => {
|
||||
await fs.promises.writeFile(testFilePath, 'testjpg');
|
||||
await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg');
|
||||
|
||||
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
}),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
@@ -309,3 +326,85 @@ describe('startWatch', () => {
|
||||
await fs.promises.rm(testFolder, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSidecar', () => {
|
||||
let testDir: string;
|
||||
let testFilePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-sidecar-'));
|
||||
testFilePath = path.join(testDir, 'test.jpg');
|
||||
fs.writeFileSync(testFilePath, 'test');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should find sidecar file with photo.xmp naming convention', () => {
|
||||
const sidecarPath = path.join(testDir, 'test.xmp');
|
||||
fs.writeFileSync(sidecarPath, 'xmp data');
|
||||
|
||||
const result = findSidecar(testFilePath);
|
||||
expect(result).toBe(sidecarPath);
|
||||
});
|
||||
|
||||
it('should find sidecar file with photo.ext.xmp naming convention', () => {
|
||||
const sidecarPath = path.join(testDir, 'test.jpg.xmp');
|
||||
fs.writeFileSync(sidecarPath, 'xmp data');
|
||||
|
||||
const result = findSidecar(testFilePath);
|
||||
expect(result).toBe(sidecarPath);
|
||||
});
|
||||
|
||||
it('should prefer photo.ext.xmp over photo.xmp when both exist', () => {
|
||||
const sidecarPath1 = path.join(testDir, 'test.xmp');
|
||||
const sidecarPath2 = path.join(testDir, 'test.jpg.xmp');
|
||||
fs.writeFileSync(sidecarPath1, 'xmp data 1');
|
||||
fs.writeFileSync(sidecarPath2, 'xmp data 2');
|
||||
|
||||
const result = findSidecar(testFilePath);
|
||||
// Should return the first one found (photo.xmp) based on the order in the code
|
||||
expect(result).toBe(sidecarPath1);
|
||||
});
|
||||
|
||||
it('should return undefined when no sidecar file exists', () => {
|
||||
const result = findSidecar(testFilePath);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFiles', () => {
|
||||
let testDir: string;
|
||||
let testFilePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-delete-'));
|
||||
testFilePath = path.join(testDir, 'test.jpg');
|
||||
fs.writeFileSync(testFilePath, 'test');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should delete asset and sidecar file when main file is deleted', async () => {
|
||||
const sidecarPath = path.join(testDir, 'test.xmp');
|
||||
fs.writeFileSync(sidecarPath, 'xmp data');
|
||||
|
||||
await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: true, concurrency: 1 });
|
||||
|
||||
expect(fs.existsSync(testFilePath)).toBe(false);
|
||||
expect(fs.existsSync(sidecarPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not delete sidecar file when delete option is false', async () => {
|
||||
const sidecarPath = path.join(testDir, 'test.xmp');
|
||||
fs.writeFileSync(sidecarPath, 'xmp data');
|
||||
|
||||
await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: false, concurrency: 1 });
|
||||
|
||||
expect(fs.existsSync(testFilePath)).toBe(true);
|
||||
expect(fs.existsSync(sidecarPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+32
-22
@@ -17,7 +17,7 @@ import { Matcher, watch as watchFs } from 'chokidar';
|
||||
import { MultiBar, Presets, SingleBar } from 'cli-progress';
|
||||
import { chunk } from 'lodash-es';
|
||||
import micromatch from 'micromatch';
|
||||
import { Stats, createReadStream } from 'node:fs';
|
||||
import { Stats, createReadStream, existsSync } from 'node:fs';
|
||||
import { stat, unlink } from 'node:fs/promises';
|
||||
import path, { basename } from 'node:path';
|
||||
import { Queue } from 'src/queue';
|
||||
@@ -403,23 +403,6 @@ export const uploadFiles = async (
|
||||
const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaResponseDto> => {
|
||||
const { baseUrl, headers } = defaults;
|
||||
|
||||
const assetPath = path.parse(input);
|
||||
const noExtension = path.join(assetPath.dir, assetPath.name);
|
||||
|
||||
const sidecarsFiles = await Promise.all(
|
||||
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
|
||||
[`${noExtension}.xmp`, `${input}.xmp`].map(async (sidecarPath) => {
|
||||
try {
|
||||
const stats = await stat(sidecarPath);
|
||||
return new UploadFile(sidecarPath, stats.size);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const sidecarData = sidecarsFiles.find((file): file is UploadFile => file !== false);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, ''));
|
||||
formData.append('deviceId', 'CLI');
|
||||
@@ -429,8 +412,15 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
|
||||
formData.append('isFavorite', 'false');
|
||||
formData.append('assetData', new UploadFile(input, stats.size));
|
||||
|
||||
if (sidecarData) {
|
||||
formData.append('sidecarData', sidecarData);
|
||||
const sidecarPath = findSidecar(input);
|
||||
if (sidecarPath) {
|
||||
try {
|
||||
const stats = await stat(sidecarPath);
|
||||
const sidecarData = new UploadFile(sidecarPath, stats.size);
|
||||
formData.append('sidecarData', sidecarData);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/assets`, {
|
||||
@@ -446,7 +436,19 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise<void> => {
|
||||
export const findSidecar = (filepath: string): string | undefined => {
|
||||
const assetPath = path.parse(filepath);
|
||||
const noExtension = path.join(assetPath.dir, assetPath.name);
|
||||
|
||||
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
|
||||
for (const sidecarPath of [`${noExtension}.xmp`, `${filepath}.xmp`]) {
|
||||
if (existsSync(sidecarPath)) {
|
||||
return sidecarPath;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise<void> => {
|
||||
let fileCount = 0;
|
||||
if (options.delete) {
|
||||
fileCount += uploaded.length;
|
||||
@@ -474,7 +476,15 @@ const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: Uplo
|
||||
|
||||
const chunkDelete = async (files: Asset[]) => {
|
||||
for (const assetBatch of chunk(files, options.concurrency)) {
|
||||
await Promise.all(assetBatch.map((input: Asset) => unlink(input.filepath)));
|
||||
await Promise.all(
|
||||
assetBatch.map(async (input: Asset) => {
|
||||
await unlink(input.filepath);
|
||||
const sidecarPath = findSidecar(input.filepath);
|
||||
if (sidecarPath) {
|
||||
await unlink(sidecarPath);
|
||||
}
|
||||
}),
|
||||
);
|
||||
deletionProgress.update(assetBatch.length);
|
||||
}
|
||||
};
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ export const connect = async (url: string, key: string) => {
|
||||
|
||||
const [error] = await withError(getMyUser());
|
||||
if (isHttpError(error)) {
|
||||
logError(error, 'Failed to connect to server');
|
||||
logError(error, `Failed to connect to server ${url}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
+11
-6
@@ -1,10 +1,12 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig, UserConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: { alias: { src: '/src' } },
|
||||
resolve: {
|
||||
alias: { src: '/src' },
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
rolldownOptions: {
|
||||
input: 'src/index.ts',
|
||||
output: {
|
||||
dir: 'dist',
|
||||
@@ -16,5 +18,8 @@ export default defineConfig({
|
||||
// bundle everything except for Node built-ins
|
||||
noExternal: /^(?!node:).*$/,
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
});
|
||||
test: {
|
||||
name: 'cli:unit',
|
||||
globals: true,
|
||||
},
|
||||
} as UserConfig);
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
terragrunt = "0.98.0"
|
||||
opentofu = "1.11.4"
|
||||
terragrunt = "0.99.4"
|
||||
opentofu = "1.11.5"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
|
||||
@@ -90,6 +90,7 @@ services:
|
||||
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
|
||||
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
|
||||
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
|
||||
IMMICH_HELMET_FILE: 'true'
|
||||
ports:
|
||||
- 9230:9230
|
||||
- 9231:9231
|
||||
@@ -155,7 +156,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
@@ -85,7 +85,7 @@ services:
|
||||
container_name: immich_prometheus
|
||||
ports:
|
||||
- 9090:9090
|
||||
image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702
|
||||
image: prom/prometheus@sha256:4a61322ac1103a0e3aea2a61ef1718422a48fa046441f299d71e660a3bc71ae9
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus-data:/prometheus
|
||||
@@ -97,7 +97,7 @@ services:
|
||||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:12.3.2-ubuntu@sha256:6cca4b429a1dc0d37d401dee54825c12d40056c3c6f3f56e3f0d6318ce77749b
|
||||
image: grafana/grafana:12.4.1-ubuntu@sha256:1a20dea76a2778773df17dbc365db86b1a4f2d57772b8590b6311038a3acb1db
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||
user: '1000:1000'
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
24.13.1
|
||||
24.14.0
|
||||
|
||||
@@ -67,7 +67,8 @@ graph TD
|
||||
C --> D["Thumbnail Generation (Large, small, blurred and person)"]
|
||||
D --> E[Smart Search]
|
||||
D --> F[Face Detection]
|
||||
D --> G[Video Transcoding]
|
||||
E --> H[Duplicate Detection]
|
||||
F --> I[Facial Recognition]
|
||||
D --> G[OCR]
|
||||
D --> H[Video Transcoding]
|
||||
E --> I[Duplicate Detection]
|
||||
F --> J[Facial Recognition]
|
||||
```
|
||||
|
||||
@@ -230,7 +230,7 @@ The default value is `ultrafast`.
|
||||
|
||||
### Audio codec (`ffmpeg.targetAudioCodec`) {#ffmpeg.targetAudioCodec}
|
||||
|
||||
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `libopus`.
|
||||
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `opus`.
|
||||
|
||||
The default value is `aac`.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# OpenAPI
|
||||
# API
|
||||
|
||||
Immich uses the [OpenAPI](https://swagger.io/specification/) standard to generate API documentation. To view the published docs see [here](https://api.immich.app/).
|
||||
|
||||
@@ -24,7 +24,7 @@ Immich has three main clients:
|
||||
3. CLI - Command-line utility for bulk upload
|
||||
|
||||
:::info
|
||||
All three clients use [OpenAPI](./open-api.md) to auto-generate rest clients for easy integration. For more information about this process, see [OpenAPI](./open-api.md).
|
||||
All three clients use [OpenAPI](/api.md) to auto-generate rest clients for easy integration. For more information about this process, see [OpenAPI](/api.md).
|
||||
:::
|
||||
|
||||
### Mobile App
|
||||
@@ -71,7 +71,7 @@ An incoming HTTP request is mapped to a controller (`src/controllers`). Controll
|
||||
|
||||
### Domain Transfer Objects (DTOs)
|
||||
|
||||
The server uses [Domain Transfer Objects](https://en.wikipedia.org/wiki/Data_transfer_object) as public interfaces for the inputs (query, params, and body) and outputs (response) for each endpoint. DTOs translate to [OpenAPI](./open-api.md) schemas and control the generated code used by each client.
|
||||
The server uses [Domain Transfer Objects](https://en.wikipedia.org/wiki/Data_transfer_object) as public interfaces for the inputs (query, params, and body) and outputs (response) for each endpoint. DTOs translate to [OpenAPI](/api.md) schemas and control the generated code used by each client.
|
||||
|
||||
### Background Jobs
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ You can use `dart fix --apply` and `dcm fix lib` to potentially correct some iss
|
||||
|
||||
## OpenAPI
|
||||
|
||||
The OpenAPI client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file. Note that you should not modify this file directly as it is auto-generated. See [OpenAPI](/developer/open-api.md) for more details.
|
||||
The OpenAPI client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file. Note that you should not modify this file directly as it is auto-generated. See [OpenAPI](/api.md) for more details.
|
||||
|
||||
## Database Migrations
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Duplicates Utility
|
||||
|
||||
Immich comes with a duplicates utility to help you detect assets that look visually similar. The duplicate detection feature relies on machine learning and is enabled by default. For more information about when the duplicate detection job runs, see [Jobs and Workers](/administration/jobs-workers). Once an asset has been processed and added to a duplicate group, it becomes available to review in the "Review duplicates" utility, which can be found [here](https://my.immich.app/utilities/duplicates).
|
||||
|
||||
## Reviewing duplicates
|
||||
|
||||
The review duplicates page allows the user to individually select which assets should be kept and which ones should be trashed. When more than one asset is kept, there is an option to automatically put the kept assets into a stack.
|
||||
|
||||
### Automatic preselection
|
||||
|
||||
When using "Deduplicate All" or viewing suggestions, Immich automatically preselects which assets to keep based on:
|
||||
|
||||
1. **Image size in bytes** â larger files are preferred as they typically have higher quality.
|
||||
2. **Count of EXIF data** â assets with more metadata are preferred.
|
||||
|
||||
### Synchronizing metadata
|
||||
|
||||
When resolving duplicates, metadata from trashed assets is automatically synchronized to the kept assets. The following metadata is synchronized:
|
||||
|
||||
| Name | Description |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Album | The kept assets will be added to _every_ album that the other assets in the group belong to. |
|
||||
| Favorite | If any of the assets in the group have been added to favorites, every kept asset will also be added to favorites. |
|
||||
| Rating | If one or more assets in the duplicate group have a rating, the highest rating is selected and synchronized to the kept assets. |
|
||||
| Description | Descriptions from each asset are combined together and synchronized to all the kept assets. |
|
||||
| Visibility | The most restrictive visibility is applied to the kept assets. |
|
||||
| Location | Latitude and longitude are copied if all assets with geolocation data in the group share the same coordinates. |
|
||||
| Tag | Tags from all assets in the group are merged and applied to every kept asset. |
|
||||
@@ -80,6 +80,10 @@ There is an automatic scan job that is scheduled to run once a day. Its schedule
|
||||
|
||||
This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library management page.
|
||||
|
||||
### Deleting a Library
|
||||
|
||||
When deleting an external library, all assets inside are immediately deleted along with the library. Note that while a library can take a long time to fully delete in the background, it is immediately removed from the library list. If the deletion process is interrupted (for example, due to server restart), it will be cleaned up in the next nightly cron job. The cleanup process can also be manually initiated by clicking the "Scan All Libraries" button in the library list.
|
||||
|
||||
## Usage
|
||||
|
||||
Let's show a concrete example where we add an existing gallery to Immich. Here, we have the following folders we want to add:
|
||||
|
||||
@@ -50,6 +50,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele
|
||||
- The GPU must be supported by ROCm. If it isn't officially supported, you can attempt to use the `HSA_OVERRIDE_GFX_VERSION` environmental variable: `HSA_OVERRIDE_GFX_VERSION=<a supported version, e.g. 10.3.0>`. If this doesn't work, you might need to also set `HSA_USE_SVM=0`.
|
||||
- The ROCm image is quite large and requires at least 35GiB of free disk space. However, pulling later updates to the service through Docker will generally only amount to a few hundred megabytes as the rest will be cached.
|
||||
- This backend is new and may experience some issues. For example, GPU power consumption can be higher than usual after running inference, even if the machine learning service is idle. In this case, it will only go back to normal after being idle for 5 minutes (configurable with the [MACHINE_LEARNING_MODEL_TTL](/install/environment-variables) setting).
|
||||
- MIGraphX is a new backend for AMD cards, which compiles models at runtime. As such, the first few inferences will be slow.
|
||||
|
||||
#### OpenVINO
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
You may decide that you'd like to modify the style document which is used to
|
||||
draw the maps in Immich. In addition to visual customization, this also allows
|
||||
you to pick your own map tile provider instead of the default one. The default
|
||||
`style.json` for [light theme](https://github.com/immich-app/immich/tree/main/server/resources/style-light.json)
|
||||
and [dark theme](https://github.com/immich-app/immich/blob/main/server/resources/style-dark.json)
|
||||
`style.json` for [light theme](https://tiles.immich.cloud/v1/style/light.json)
|
||||
and [dark theme](https://tiles.immich.cloud/v1/style/dark.json)
|
||||
can be used as a basis for creating your own style.
|
||||
|
||||
There are several sources for already-made `style.json` map themes, as well as
|
||||
|
||||
@@ -27,7 +27,7 @@ The default configuration looks like this:
|
||||
"ffmpeg": {
|
||||
"accel": "disabled",
|
||||
"accelDecode": false,
|
||||
"acceptedAudioCodecs": ["aac", "mp3", "libopus"],
|
||||
"acceptedAudioCodecs": ["aac", "mp3", "opus"],
|
||||
"acceptedContainers": ["mov", "ogg", "webm"],
|
||||
"acceptedVideoCodecs": ["h264"],
|
||||
"bframes": -1,
|
||||
|
||||
@@ -29,22 +29,23 @@ These environment variables are used by the `docker-compose.yml` file and do **N
|
||||
|
||||
## General
|
||||
|
||||
| Variable | Description | Default | Containers | Workers |
|
||||
| :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
|
||||
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
|
||||
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
|
||||
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
|
||||
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
|
||||
| `IMMICH_MEDIA_LOCATION` | Media location inside the container â ī¸**You probably shouldn't set this**<sup>\*2</sup>â ī¸ | `/data` | server | api, microservices |
|
||||
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
|
||||
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
|
||||
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
|
||||
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
|
||||
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
|
||||
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
|
||||
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
|
||||
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
|
||||
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
|
||||
| Variable | Description | Default | Containers | Workers |
|
||||
| :---------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
|
||||
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
|
||||
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
|
||||
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
|
||||
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
|
||||
| `IMMICH_MEDIA_LOCATION` | Media location inside the container â ī¸**You probably shouldn't set this**<sup>\*2</sup>â ī¸ | `/data` | server | api, microservices |
|
||||
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
|
||||
| `IMMICH_HELMET_FILE` | Path to a json file with [helmet](https://www.npmjs.com/package/helmet) options. Set to `false` to disable. Set to `true` to use `server/helmet.json`. | `false` | server | api, microservices |
|
||||
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
|
||||
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
|
||||
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
|
||||
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
|
||||
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
|
||||
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
|
||||
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
|
||||
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
|
||||
|
||||
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
|
||||
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.
|
||||
@@ -166,6 +167,8 @@ Redis (Sentinel) URL example JSON before encoding:
|
||||
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
|
||||
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
|
||||
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
||||
|
||||
@@ -8,7 +8,7 @@ Hardware and software requirements for Immich:
|
||||
|
||||
## Hardware
|
||||
|
||||
- **OS**: Recommended Linux or \*nix operating system (Ubuntu, Debian, etc).
|
||||
- **OS**: Recommended Linux or \*nix 64-bit operating system (Ubuntu, Debian, etc).
|
||||
- Non-Linux OSes tend to provide a poor Docker experience and are strongly discouraged.
|
||||
Our ability to assist with setup or troubleshooting on non-Linux OSes will be severely reduced.
|
||||
If you still want to try to use a non-Linux OS, you can set it up as follows:
|
||||
@@ -19,6 +19,10 @@ Hardware and software requirements for Immich:
|
||||
If you have issues, we recommend that you switch to a supported VM deployment.
|
||||
- **RAM**: Minimum 6GB, recommended 8GB.
|
||||
- **CPU**: Minimum 2 cores, recommended 4 cores.
|
||||
- Immich runs on the `amd64` and `arm64` platforms.
|
||||
Since `v2.6`, the machine learning container on `amd64` requires the `>= x86-64-v2` [microarchitecture level](https://en.wikipedia.org/wiki/X86-64#Microarchitecture_levels).
|
||||
Most CPUs released since ~2012 support this microarchitecture.
|
||||
If you are using a virtual machine, ensure you have selected a [supported microarchitecture](https://pve.proxmox.com/pve-docs/chapter-qm.html#_qemu_cpu_types).
|
||||
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
|
||||
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
|
||||
|
||||
|
||||
+70
-48
@@ -6,7 +6,7 @@ const prism = require('prism-react-renderer');
|
||||
/** @type {import('@docusaurus/types').Config} */
|
||||
const config = {
|
||||
title: 'Immich',
|
||||
tagline: 'High performance self-hosted photo and video backup solution directly from your mobile phone',
|
||||
tagline: 'Self-hosted photo and video management solution',
|
||||
url: 'https://docs.immich.app',
|
||||
baseUrl: '/',
|
||||
onBrokenLinks: 'throw',
|
||||
@@ -93,35 +93,15 @@ const config = {
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
to: '/overview/quick-start',
|
||||
href: 'https://immich.app/',
|
||||
position: 'right',
|
||||
label: 'Docs',
|
||||
},
|
||||
{
|
||||
href: 'https://immich.app/roadmap',
|
||||
position: 'right',
|
||||
label: 'Roadmap',
|
||||
},
|
||||
{
|
||||
href: 'https://api.immich.app/',
|
||||
position: 'right',
|
||||
label: 'API',
|
||||
},
|
||||
{
|
||||
href: 'https://immich.store',
|
||||
position: 'right',
|
||||
label: 'Merch',
|
||||
label: 'Home',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/immich-app/immich',
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
href: 'https://discord.immich.app',
|
||||
label: 'Discord',
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
type: 'html',
|
||||
position: 'right',
|
||||
@@ -134,19 +114,78 @@ const config = {
|
||||
style: 'light',
|
||||
links: [
|
||||
{
|
||||
title: 'Overview',
|
||||
title: 'Download',
|
||||
items: [
|
||||
{
|
||||
label: 'Quick start',
|
||||
to: '/overview/quick-start',
|
||||
label: 'Android',
|
||||
href: 'https://get.immich.app/android',
|
||||
},
|
||||
{
|
||||
label: 'Installation',
|
||||
to: '/install/requirements',
|
||||
label: 'iOS',
|
||||
href: 'https://get.immich.app/ios',
|
||||
},
|
||||
{
|
||||
label: 'Contributing',
|
||||
to: '/overview/support-the-project',
|
||||
label: 'Server',
|
||||
href: 'https://immich.app/download',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Company',
|
||||
items: [
|
||||
{
|
||||
label: 'FUTO',
|
||||
href: 'https://futo.tech/',
|
||||
},
|
||||
{
|
||||
label: 'Purchase',
|
||||
href: 'https://buy.immich.app/',
|
||||
},
|
||||
{
|
||||
label: 'Merch',
|
||||
href: 'https://immich.store/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Sites',
|
||||
items: [
|
||||
{
|
||||
label: 'Home',
|
||||
href: 'https://immich.app',
|
||||
},
|
||||
{
|
||||
label: 'My Immich',
|
||||
href: 'https://my.immich.app/',
|
||||
},
|
||||
{
|
||||
label: 'Awesome Immich',
|
||||
href: 'https://awesome.immich.app/',
|
||||
},
|
||||
{
|
||||
label: 'Immich API',
|
||||
href: 'https://api.immich.app/',
|
||||
},
|
||||
{
|
||||
label: 'Immich Data',
|
||||
href: 'https://data.immich.app/',
|
||||
},
|
||||
{
|
||||
label: 'Immich Datasets',
|
||||
href: 'https://datasets.immich.app/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Miscellaneous',
|
||||
items: [
|
||||
{
|
||||
label: 'Roadmap',
|
||||
href: 'https://immich.app/roadmap',
|
||||
},
|
||||
{
|
||||
label: 'Cursed Knowledge',
|
||||
href: 'https://immich.app/cursed-knowledge',
|
||||
},
|
||||
{
|
||||
label: 'Privacy Policy',
|
||||
@@ -155,24 +194,7 @@ const config = {
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
items: [
|
||||
{
|
||||
label: 'Roadmap',
|
||||
href: 'https://immich.app/roadmap',
|
||||
},
|
||||
{
|
||||
label: 'API',
|
||||
href: 'https://api.immich.app/',
|
||||
},
|
||||
{
|
||||
label: 'Cursed Knowledge',
|
||||
href: 'https://immich.app/cursed-knowledge',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Links',
|
||||
title: 'Social',
|
||||
items: [
|
||||
{
|
||||
label: 'GitHub',
|
||||
|
||||
+4
-4
@@ -4,11 +4,11 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different .",
|
||||
"start": "docusaurus start --port 3005",
|
||||
"copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
|
||||
"build": "npm run copy:openapi && docusaurus build",
|
||||
"build": "pnpm run copy:openapi && docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
@@ -58,6 +58,6 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.13.1"
|
||||
"node": "24.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -23,6 +23,7 @@
|
||||
/features/storage-template /administration/storage-template 307
|
||||
/features/user-management /administration/user-management 307
|
||||
/developer/contributing /developer/pr-checklist 307
|
||||
/developer/open-api /api 307
|
||||
/guides/machine-learning /guides/remote-machine-learning 307
|
||||
/administration/password-login /administration/system-settings 307
|
||||
/features/search /features/searching 307
|
||||
|
||||
Vendored
+4
@@ -1,4 +1,8 @@
|
||||
[
|
||||
{
|
||||
"label": "v2.6.3",
|
||||
"url": "https://docs.v2.6.3.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.5.6",
|
||||
"url": "https://docs.v2.5.6.archive.immich.app"
|
||||
|
||||
@@ -10,6 +10,7 @@ export enum OAuthClient {
|
||||
export enum OAuthUser {
|
||||
NO_EMAIL = 'no-email',
|
||||
NO_NAME = 'no-name',
|
||||
ID_TOKEN_CLAIMS = 'id-token-claims',
|
||||
WITH_QUOTA = 'with-quota',
|
||||
WITH_USERNAME = 'with-username',
|
||||
WITH_ROLE = 'with-role',
|
||||
@@ -52,12 +53,25 @@ const withDefaultClaims = (sub: string) => ({
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
|
||||
const getClaims = (sub: string, use?: string) => {
|
||||
if (sub === OAuthUser.ID_TOKEN_CLAIMS) {
|
||||
return {
|
||||
sub,
|
||||
email: `oauth-${sub}@immich.app`,
|
||||
email_verified: true,
|
||||
name: use === 'id_token' ? 'ID Token User' : 'Userinfo User',
|
||||
};
|
||||
}
|
||||
return claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
|
||||
};
|
||||
|
||||
const setup = async () => {
|
||||
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
||||
|
||||
const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect'];
|
||||
const redirectUris = [
|
||||
'http://127.0.0.1:2285/auth/login',
|
||||
'https://photos.immich.app/oauth/mobile-redirect',
|
||||
];
|
||||
const port = 2286;
|
||||
const host = '0.0.0.0';
|
||||
const oidc = new Provider(`http://${host}:${port}`, {
|
||||
@@ -66,7 +80,10 @@ const setup = async () => {
|
||||
console.error(error);
|
||||
ctx.body = 'Internal Server Error';
|
||||
},
|
||||
findAccount: (ctx, sub) => ({ accountId: sub, claims: () => getClaims(sub) }),
|
||||
findAccount: (ctx, sub) => ({
|
||||
accountId: sub,
|
||||
claims: (use) => getClaims(sub, use),
|
||||
}),
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
claims: {
|
||||
openid: ['sub'],
|
||||
@@ -94,6 +111,7 @@ const setup = async () => {
|
||||
state: 'oidc.state',
|
||||
},
|
||||
},
|
||||
conformIdTokenClaims: false,
|
||||
pkce: {
|
||||
required: () => false,
|
||||
},
|
||||
@@ -125,7 +143,10 @@ const setup = async () => {
|
||||
],
|
||||
});
|
||||
|
||||
const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`);
|
||||
const onStart = () =>
|
||||
console.log(
|
||||
`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`,
|
||||
);
|
||||
const app = oidc.listen(port, host, onStart);
|
||||
return () => app.close();
|
||||
};
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
24.13.1
|
||||
24.14.0
|
||||
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich-e2e-redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
+20
-19
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.5.6",
|
||||
"version": "2.6.3",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
@@ -8,41 +8,41 @@
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"test:maintenance": "vitest --run --config vitest.maintenance.config.ts",
|
||||
"test:web": "npx playwright test --project=web",
|
||||
"test:web:maintenance": "npx playwright test --project=maintenance",
|
||||
"test:web:ui": "npx playwright test --project=ui",
|
||||
"start:web": "npx playwright test --ui --project=web",
|
||||
"start:web:maintenance": "npx playwright test --ui --project=maintenance",
|
||||
"start:web:ui": "npx playwright test --ui --project=ui",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"test:web": "pnpm exec playwright test --project=web",
|
||||
"test:web:maintenance": "pnpm exec playwright test --project=maintenance",
|
||||
"test:web:ui": "pnpm exec playwright test --project=ui",
|
||||
"start:web": "pnpm exec playwright test --ui --project=web",
|
||||
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
|
||||
"start:web:ui": "pnpm exec playwright test --ui --project=ui",
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different .",
|
||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"lint:fix": "pnpm run lint --fix",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"@immich/cli": "workspace:*",
|
||||
"@immich/e2e-auth-server": "workspace:*",
|
||||
"@immich/e2e-auth-server": "workspace:*",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^62.0.0",
|
||||
"exiftool-vendored": "^34.3.0",
|
||||
"globals": "^16.0.0",
|
||||
"eslint-plugin-unicorn": "^63.0.0",
|
||||
"exiftool-vendored": "^35.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"luxon": "^3.4.4",
|
||||
"pg": "^8.11.3",
|
||||
"pngjs": "^7.0.0",
|
||||
@@ -54,9 +54,10 @@
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"utimes": "^5.2.1",
|
||||
"vitest": "^3.0.0"
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.13.1"
|
||||
"node": "24.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { createUserDto, uuidDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/duplicates', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let user1: LoginResponseDto;
|
||||
let user2: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
|
||||
admin = await utils.adminSetup();
|
||||
|
||||
[user1, user2] = await Promise.all([
|
||||
utils.userSetup(admin.accessToken, createUserDto.user1),
|
||||
utils.userSetup(admin.accessToken, createUserDto.user2),
|
||||
]);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset assets, albums, tags, and stacks between tests to ensure clean state for repeated test runs
|
||||
// Note: We don't reset users since they're set up once in beforeAll
|
||||
// Stack must be reset before asset due to foreign key constraint
|
||||
await utils.resetDatabase(['stack', 'asset', 'album', 'tag']);
|
||||
});
|
||||
|
||||
describe('GET /duplicates', () => {
|
||||
it('should return empty array when no duplicates', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/duplicates')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return duplicate groups with suggestedKeepAssetIds', async () => {
|
||||
// Create assets with different file sizes for duplicate detection
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Manually set duplicateId on both assets to create a duplicate group
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000001';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/duplicates')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([
|
||||
{
|
||||
duplicateId,
|
||||
assets: expect.arrayContaining([
|
||||
expect.objectContaining({ id: asset1.id }),
|
||||
expect.objectContaining({ id: asset2.id }),
|
||||
]),
|
||||
suggestedKeepAssetIds: expect.any(Array),
|
||||
},
|
||||
]);
|
||||
expect(body[0].suggestedKeepAssetIds.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /duplicates/resolve', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.send({
|
||||
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should return failure for non-existent duplicate group', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
status: 'COMPLETED',
|
||||
results: [
|
||||
{
|
||||
duplicateId: uuidDto.dummy,
|
||||
status: 'FAILED',
|
||||
reason: expect.stringContaining('not found or access denied'),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve duplicate group with keepers', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000002';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
status: 'COMPLETED',
|
||||
results: [
|
||||
{
|
||||
duplicateId,
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Verify side effects: duplicateId cleared on kept asset
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
|
||||
// Verify side effects: trashed asset is trashed and duplicateId cleared
|
||||
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
|
||||
expect(trashedAsset.isTrashed).toBe(true);
|
||||
expect(trashedAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject when keepAssetIds and trashAssetIds overlap', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000003';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('disjoint');
|
||||
});
|
||||
|
||||
it('should require keepAssetIds when partially trashing', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000004';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('must cover all assets');
|
||||
});
|
||||
|
||||
it('should reject partial resolution (not all assets covered)', async () => {
|
||||
const [asset1, asset2, asset3] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000010';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset3.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('must cover all assets');
|
||||
});
|
||||
|
||||
it('should reject asset not in duplicate group', async () => {
|
||||
const [asset1, asset2, outsideAsset] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000011';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsideAsset.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('not a member of duplicate group');
|
||||
});
|
||||
|
||||
it('should allow trash-all without keepers', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000012';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
status: 'COMPLETED',
|
||||
results: [
|
||||
{
|
||||
duplicateId,
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Verify both assets are trashed
|
||||
const [asset1Info, asset2Info] = await Promise.all([
|
||||
utils.getAssetInfo(user1.accessToken, asset1.id),
|
||||
utils.getAssetInfo(user1.accessToken, asset2.id),
|
||||
]);
|
||||
|
||||
expect(asset1Info.isTrashed).toBe(true);
|
||||
expect(asset1Info.duplicateId).toBeNull();
|
||||
expect(asset2Info.isTrashed).toBe(true);
|
||||
expect(asset2Info.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject cross-user duplicate group access', async () => {
|
||||
const asset1 = await utils.createAsset(user1.accessToken);
|
||||
const asset2 = await utils.createAsset(user2.accessToken);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000013';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user2.accessToken, asset2.id, duplicateId);
|
||||
|
||||
// User1 tries to resolve a group containing user2's asset
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('not a member of duplicate group');
|
||||
});
|
||||
|
||||
it('should synchronize favorites when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Mark one asset as favorite
|
||||
await request(app)
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset2.id], isFavorite: true });
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000020';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify favorite was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.isFavorite).toBe(true);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize visibility when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Archive one asset
|
||||
await utils.archiveAssets(user1.accessToken, [asset2.id]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000021';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify visibility was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.visibility).toBe('archive');
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize rating when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Set rating on one asset
|
||||
await request(app)
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset2.id], rating: 5 });
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000022';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify rating was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.exifInfo?.rating).toBe(5);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize description when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Set description on one asset
|
||||
await request(app)
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset2.id], description: 'Test description for duplicate' });
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000023';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify description was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.exifInfo?.description).toBe('Test description for duplicate');
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize location when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Set location on one asset
|
||||
await request(app)
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset2.id], latitude: 40.7128, longitude: -74.006 });
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000024';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify location was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.exifInfo?.latitude).toBe(40.7128);
|
||||
expect(keptAsset.exifInfo?.longitude).toBe(-74.006);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize albums when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Create albums and add assets to different albums
|
||||
const album1 = await utils.createAlbum(user1.accessToken, {
|
||||
albumName: 'Album 1',
|
||||
assetIds: [asset1.id],
|
||||
});
|
||||
const album2 = await utils.createAlbum(user1.accessToken, {
|
||||
albumName: 'Album 2',
|
||||
assetIds: [asset2.id],
|
||||
});
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000025';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify keeper is now in both albums
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
|
||||
// Check albums directly
|
||||
const { status: album1Status, body: album1Body } = await request(app)
|
||||
.get(`/albums/${album1.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
const { status: album2Status, body: album2Body } = await request(app)
|
||||
.get(`/albums/${album2.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(album1Status).toBe(200);
|
||||
expect(album2Status).toBe(200);
|
||||
expect(album1Body.assets.map((a: any) => a.id)).toContain(asset1.id);
|
||||
expect(album2Body.assets.map((a: any) => a.id)).toContain(asset1.id);
|
||||
});
|
||||
|
||||
it('should synchronize tags when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Wait for metadata extraction to complete before adding tags
|
||||
// Otherwise, metadata jobs will race and overwrite our tags
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||
|
||||
// Create tags and tag assets differently
|
||||
const tags = await utils.upsertTags(user1.accessToken, ['tag1', 'tag2']);
|
||||
await utils.tagAssets(user1.accessToken, tags[0].id, [asset1.id]);
|
||||
await utils.tagAssets(user1.accessToken, tags[1].id, [asset2.id]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000026';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify keeper has both tags
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
expect(keptAsset.tags).toBeDefined();
|
||||
const tagIds = keptAsset.tags?.map((t) => t.id) || [];
|
||||
expect(tagIds).toContain(tags[0].id);
|
||||
expect(tagIds).toContain(tags[1].id);
|
||||
});
|
||||
|
||||
it('should handle batch resolve with mixed success and failure', async () => {
|
||||
// Create first group that will succeed
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
const duplicateId1 = '00000000-0000-4000-8000-000000000027';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId1);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId1);
|
||||
|
||||
// Create second group with non-existent duplicate ID (will fail)
|
||||
const fakeId = '00000000-0000-4000-8000-000000000099';
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [
|
||||
{ duplicateId: duplicateId1, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] },
|
||||
{ duplicateId: fakeId, keepAssetIds: [], trashAssetIds: [] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.status).toBe('COMPLETED');
|
||||
expect(body.results).toHaveLength(2);
|
||||
|
||||
// First group should succeed
|
||||
expect(body.results[0].duplicateId).toBe(duplicateId1);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Second group should fail
|
||||
expect(body.results[1].duplicateId).toBe(fakeId);
|
||||
expect(body.results[1].status).toBe('FAILED');
|
||||
expect(body.results[1].reason).toContain('not found or access denied');
|
||||
|
||||
// Verify first group was actually resolved despite second failure
|
||||
const asset1Info = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(asset1Info.duplicateId).toBeNull();
|
||||
const asset2Info = await utils.getAssetInfo(user1.accessToken, asset2.id);
|
||||
expect(asset2Info.isTrashed).toBe(true);
|
||||
});
|
||||
|
||||
it('should trash assets when trash is enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000028';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
// Ensure trash is enabled (default)
|
||||
const config = await utils.getSystemConfig(admin.accessToken);
|
||||
expect(config.trash.enabled).toBe(true);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify asset is trashed (not deleted)
|
||||
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
|
||||
expect(trashedAsset.isTrashed).toBe(true);
|
||||
});
|
||||
|
||||
it('should delete assets when trash is disabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000029';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
// Disable trash
|
||||
await request(app)
|
||||
.put('/system-config')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
trash: { enabled: false, days: 30 },
|
||||
});
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Asset should be marked as deleted (force delete)
|
||||
const { status: getStatus } = await request(app)
|
||||
.get(`/assets/${asset2.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
// Asset should still be accessible (soft deleted) but marked as deleted
|
||||
expect(getStatus).toBe(200);
|
||||
|
||||
// Re-enable trash for other tests
|
||||
await utils.resetAdminConfig(admin.accessToken);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ export const uuidDto = {
|
||||
invalid: 'invalid-uuid',
|
||||
// valid uuid v4
|
||||
notFound: '00000000-0000-4000-a000-000000000000',
|
||||
dummy: '00000000-0000-4000-a000-000000000001',
|
||||
dummy2: '00000000-0000-4000-a000-000000000002',
|
||||
};
|
||||
|
||||
const adminLoginDto = {
|
||||
|
||||
@@ -10,7 +10,9 @@ describe('/admin/database-backups', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
admin = await utils.adminSetup({
|
||||
onboarding: false,
|
||||
});
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
});
|
||||
|
||||
@@ -94,7 +96,9 @@ describe('/admin/database-backups', () => {
|
||||
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||
);
|
||||
|
||||
admin = await utils.adminSetup();
|
||||
admin = await utils.adminSetup({
|
||||
onboarding: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.sequential('should not work when the server is configured', async () => {
|
||||
|
||||
@@ -524,14 +524,19 @@ describe('/albums', () => {
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
|
||||
});
|
||||
|
||||
it('should not be able to update as an editor', async () => {
|
||||
it('should be able to update as an editor', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.patch(`/albums/${user1Albums[0].id}`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ albumName: 'New album name' });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
id: user1Albums[0].id,
|
||||
albumName: 'New album name',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -253,7 +253,8 @@ describe('/asset', () => {
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.id).toEqual(facesAsset.id);
|
||||
expect(body.people).toMatchObject(expectedFaces);
|
||||
const sortedPeople = body.people.toSorted((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
expect(sortedPeople).toMatchObject(expectedFaces);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -380,4 +380,23 @@ describe(`/oauth`, () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('idTokenClaims', () => {
|
||||
it('should use claims from the ID token if IDP includes them', async () => {
|
||||
await setupOAuth(admin.accessToken, {
|
||||
enabled: true,
|
||||
clientId: OAuthClient.DEFAULT,
|
||||
clientSecret: OAuthClient.DEFAULT,
|
||||
});
|
||||
const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS);
|
||||
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
||||
expect(status).toBe(201);
|
||||
expect(body).toMatchObject({
|
||||
accessToken: expect.any(String),
|
||||
name: 'ID Token User',
|
||||
userEmail: 'oauth-id-token-claims@immich.app',
|
||||
userId: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -438,6 +438,16 @@ describe('/shared-links', () => {
|
||||
expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
|
||||
});
|
||||
|
||||
it('should reject guests removing assets from an individual shared link', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/shared-links/${linkWithAssets.id}/assets`)
|
||||
.query({ key: linkWithAssets.key })
|
||||
.send({ assetIds: [asset1.id] });
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.forbidden);
|
||||
});
|
||||
|
||||
it('should remove assets from a shared link (individual)', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/shared-links/${linkWithAssets.id}/assets`)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { testAssetDir, utils } from 'src/utils';
|
||||
|
||||
test.describe('Album', () => {
|
||||
let admin: LoginResponseDto;
|
||||
@@ -22,4 +23,41 @@ test.describe('Album', () => {
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: 'Select photos' }).waitFor();
|
||||
});
|
||||
|
||||
test('should keep map view open after viewing an asset from the map and going back', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
const imagePath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
|
||||
const mapAsset = await utils.createAsset(admin.accessToken, {
|
||||
assetData: {
|
||||
bytes: readFileSync(imagePath),
|
||||
filename: 'thompson-springs.jpg',
|
||||
},
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||
|
||||
const mapAlbum = await utils.createAlbum(admin.accessToken, {
|
||||
albumName: 'Map Test Album',
|
||||
assetIds: [mapAsset.id],
|
||||
});
|
||||
|
||||
await page.goto(`/albums/${mapAlbum.id}`);
|
||||
const mapButton = page.getByRole('button', { name: 'Map' });
|
||||
await expect(mapButton).toBeVisible();
|
||||
await mapButton.click();
|
||||
|
||||
const mapModal = page.getByRole('dialog');
|
||||
await expect(mapModal).toBeVisible();
|
||||
|
||||
const mapMarker = mapModal.getByRole('img', { name: /Map marker/i }).first();
|
||||
await expect(mapMarker).toBeVisible();
|
||||
await mapMarker.click();
|
||||
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
await page.getByRole('button', { name: 'Go back' }).click();
|
||||
|
||||
await expect(page.locator('#immich-asset-viewer')).not.toBeVisible();
|
||||
await expect(mapModal).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, Page, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
async function ensureDetailPanelVisible(page: Page) {
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
const isVisible = await page.locator('#detail-panel').isVisible();
|
||||
if (!isVisible) {
|
||||
await page.keyboard.press('i');
|
||||
await page.waitForSelector('#detail-panel');
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Asset Viewer stack', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let assetOne: AssetMediaResponseDto;
|
||||
let assetTwo: AssetMediaResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
await utils.updateMyPreferences(admin.accessToken, { tags: { enabled: true } });
|
||||
|
||||
assetOne = await utils.createAsset(admin.accessToken);
|
||||
assetTwo = await utils.createAsset(admin.accessToken);
|
||||
await utils.createStack(admin.accessToken, [assetOne.id, assetTwo.id]);
|
||||
|
||||
const tags = await utils.upsertTags(admin.accessToken, ['test/1', 'test/2']);
|
||||
const tagOne = tags.find((tag) => tag.value === 'test/1')!;
|
||||
const tagTwo = tags.find((tag) => tag.value === 'test/2')!;
|
||||
await utils.tagAssets(admin.accessToken, tagOne.id, [assetOne.id]);
|
||||
await utils.tagAssets(admin.accessToken, tagTwo.id, [assetTwo.id]);
|
||||
});
|
||||
|
||||
test('stack slideshow is visible', async ({ page, context }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await page.goto(`/photos/${assetOne.id}`);
|
||||
|
||||
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
||||
await expect(stackAssets.first()).toBeVisible();
|
||||
await expect(stackAssets.nth(1)).toBeVisible();
|
||||
});
|
||||
|
||||
test('tags of primary asset are visible', async ({ page, context }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await page.goto(`/photos/${assetOne.id}`);
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||
await expect(tags.first()).toHaveText('test/1');
|
||||
});
|
||||
|
||||
test('tags of second asset are visible', async ({ page, context }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await page.goto(`/photos/${assetOne.id}`);
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
||||
await stackAssets.nth(1).click();
|
||||
|
||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||
await expect(tags.first()).toHaveText('test/2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto, updateAssets } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import crypto from 'node:crypto';
|
||||
import { asBearerAuth, utils } from 'src/utils';
|
||||
|
||||
test.describe('Duplicates Utility', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let firstAsset: AssetMediaResponseDto;
|
||||
let secondAsset: AssetMediaResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
[firstAsset, secondAsset] = await Promise.all([
|
||||
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-a' }),
|
||||
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-b' }),
|
||||
]);
|
||||
|
||||
await updateAssets(
|
||||
{
|
||||
assetBulkUpdateDto: {
|
||||
ids: [firstAsset.id, secondAsset.id],
|
||||
duplicateId: crypto.randomUUID(),
|
||||
},
|
||||
},
|
||||
{ headers: asBearerAuth(admin.accessToken) },
|
||||
);
|
||||
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
});
|
||||
|
||||
test('navigates with arrow keys between duplicate preview assets', async ({ page }) => {
|
||||
await page.goto('/utilities/duplicates');
|
||||
await page.getByRole('button', { name: 'View' }).first().click();
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
const getViewedAssetId = () => new URL(page.url()).pathname.split('/').at(-1) ?? '';
|
||||
const initialAssetId = getViewedAssetId();
|
||||
expect([firstAsset.id, secondAsset.id]).toContain(initialAssetId);
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect.poll(getViewedAssetId).not.toBe(initialAssetId);
|
||||
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect.poll(getViewedAssetId).toBe(initialAssetId);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,13 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
function imageLocator(page: Page) {
|
||||
return page.getByAltText('Image taken').locator('visible=true');
|
||||
}
|
||||
test.describe('Photo Viewer', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
let rawAsset: AssetMediaResponseDto;
|
||||
let websocket: Socket;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
@@ -16,6 +15,11 @@ test.describe('Photo Viewer', () => {
|
||||
admin = await utils.adminSetup();
|
||||
asset = await utils.createAsset(admin.accessToken);
|
||||
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
|
||||
websocket = await utils.connectWebsocket(admin.accessToken);
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
utils.disconnectWebsocket(websocket);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
@@ -26,31 +30,65 @@ test.describe('Photo Viewer', () => {
|
||||
|
||||
test('loads original photo when zoomed', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const box = await imageLocator(page).boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
const { x, y, width, height } = box!;
|
||||
await page.mouse.move(x + width / 2, y + height / 2);
|
||||
|
||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||
await expect(preview).toHaveAttribute('src', /.+/);
|
||||
|
||||
const originalResponse = page.waitForResponse((response) => response.url().includes('/original'));
|
||||
|
||||
const { width, height } = page.viewportSize()!;
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
|
||||
|
||||
await originalResponse;
|
||||
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
await expect(original).toHaveAttribute('src', /original/);
|
||||
});
|
||||
|
||||
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
||||
await page.goto(`/photos/${rawAsset.id}`);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const box = await imageLocator(page).boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
const { x, y, width, height } = box!;
|
||||
await page.mouse.move(x + width / 2, y + height / 2);
|
||||
|
||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||
await expect(preview).toHaveAttribute('src', /.+/);
|
||||
|
||||
const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize'));
|
||||
|
||||
const { width, height } = page.viewportSize()!;
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
|
||||
|
||||
await fullsizeResponse;
|
||||
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
await expect(original).toHaveAttribute('src', /fullsize/);
|
||||
});
|
||||
|
||||
test('right-click targets the img element', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
|
||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||
await expect(preview).toHaveAttribute('src', /.+/);
|
||||
|
||||
const box = await preview.boundingBox();
|
||||
const tagAtCenter = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.tagName, {
|
||||
x: box!.x + box!.width / 2,
|
||||
y: box!.y + box!.height / 2,
|
||||
});
|
||||
expect(tagAtCenter).toBe('IMG');
|
||||
});
|
||||
|
||||
test('reloads photo when checksum changes', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const initialSrc = await imageLocator(page).getAttribute('src');
|
||||
|
||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||
await expect(preview).toHaveAttribute('src', /.+/);
|
||||
const initialSrc = await preview.getAttribute('src');
|
||||
|
||||
const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
|
||||
await utils.replaceAsset(admin.accessToken, asset.id);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
|
||||
await websocketEvent;
|
||||
|
||||
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,15 +12,18 @@ import { asBearerAuth, utils } from 'src/utils';
|
||||
test.describe('Shared Links', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
let asset2: AssetMediaResponseDto;
|
||||
let album: AlbumResponseDto;
|
||||
let sharedLink: SharedLinkResponseDto;
|
||||
let sharedLinkPassword: SharedLinkResponseDto;
|
||||
let individualSharedLink: SharedLinkResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
asset = await utils.createAsset(admin.accessToken);
|
||||
asset2 = await utils.createAsset(admin.accessToken);
|
||||
album = await createAlbum(
|
||||
{
|
||||
createAlbumDto: {
|
||||
@@ -39,14 +42,17 @@ test.describe('Shared Links', () => {
|
||||
albumId: album.id,
|
||||
password: 'test-password',
|
||||
});
|
||||
individualSharedLink = await utils.createSharedLink(admin.accessToken, {
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [asset.id, asset2.id],
|
||||
});
|
||||
});
|
||||
|
||||
test('download from a shared link', async ({ page }) => {
|
||||
await page.goto(`/share/${sharedLink.key}`);
|
||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||
await page.locator(`[data-asset-id="${asset.id}"]`).hover();
|
||||
await page.waitForSelector('[data-group] svg');
|
||||
await page.getByRole('checkbox').click();
|
||||
await page.waitForSelector(`[data-asset-id="${asset.id}"] [role="checkbox"]`);
|
||||
await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]);
|
||||
});
|
||||
|
||||
@@ -110,4 +116,21 @@ test.describe('Shared Links', () => {
|
||||
await page.waitForURL('/photos');
|
||||
await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
|
||||
});
|
||||
|
||||
test('owner can remove assets from an individual shared link', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto(`/share/${individualSharedLink.key}`);
|
||||
await page.locator(`[data-asset="${asset.id}"]`).waitFor();
|
||||
await expect(page.locator(`[data-asset]`)).toHaveCount(2);
|
||||
|
||||
await page.locator(`[data-asset="${asset.id}"]`).hover();
|
||||
await page.locator(`[data-asset="${asset.id}"] [role="checkbox"]`).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Remove from shared link' }).click();
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click();
|
||||
|
||||
await expect(page.locator(`[data-asset="${asset.id}"]`)).toHaveCount(0);
|
||||
await expect(page.locator(`[data-asset="${asset2.id}"]`)).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { randomPreview, randomThumbnail } from 'src/ui/generators/timeline';
|
||||
|
||||
export type MockStack = {
|
||||
id: string;
|
||||
primaryAssetId: string;
|
||||
assets: AssetResponseDto[];
|
||||
brokenAssetIds: Set<string>;
|
||||
assetMap: Map<string, AssetResponseDto>;
|
||||
};
|
||||
|
||||
export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
|
||||
const assetId = faker.string.uuid();
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: assetId,
|
||||
deviceAssetId: `device-${assetId}`,
|
||||
ownerId,
|
||||
owner: {
|
||||
id: ownerId,
|
||||
email: 'admin@immich.cloud',
|
||||
name: 'Admin',
|
||||
profileImagePath: '',
|
||||
profileChangedAt: now,
|
||||
avatarColor: 'blue' as never,
|
||||
},
|
||||
libraryId: `library-${ownerId}`,
|
||||
deviceId: `device-${ownerId}`,
|
||||
type: AssetTypeEnum.Image,
|
||||
originalPath: `/original/${assetId}.jpg`,
|
||||
originalFileName: `${assetId}.jpg`,
|
||||
originalMimeType: 'image/jpeg',
|
||||
thumbhash: null,
|
||||
fileCreatedAt: now,
|
||||
fileModifiedAt: now,
|
||||
localDateTime: now,
|
||||
updatedAt: now,
|
||||
createdAt: now,
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
isTrashed: false,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
duration: '0:00:00.00000',
|
||||
exifInfo: {
|
||||
make: null,
|
||||
model: null,
|
||||
exifImageWidth: 3000,
|
||||
exifImageHeight: 4000,
|
||||
fileSizeInByte: null,
|
||||
orientation: null,
|
||||
dateTimeOriginal: now,
|
||||
modifyDate: null,
|
||||
timeZone: null,
|
||||
lensModel: null,
|
||||
fNumber: null,
|
||||
focalLength: null,
|
||||
iso: null,
|
||||
exposureTime: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
city: null,
|
||||
country: null,
|
||||
state: null,
|
||||
description: null,
|
||||
},
|
||||
livePhotoVideoId: null,
|
||||
tags: [],
|
||||
people: [],
|
||||
unassignedFaces: [],
|
||||
stack: null,
|
||||
isOffline: false,
|
||||
hasMetadata: true,
|
||||
duplicateId: null,
|
||||
resized: true,
|
||||
checksum: faker.string.alphanumeric({ length: 28 }),
|
||||
width: 3000,
|
||||
height: 4000,
|
||||
isEdited: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const createMockStack = (
|
||||
primaryAssetDto: AssetResponseDto,
|
||||
additionalAssets: AssetResponseDto[],
|
||||
brokenAssetIds?: Set<string>,
|
||||
): MockStack => {
|
||||
const stackId = faker.string.uuid();
|
||||
const allAssets = [primaryAssetDto, ...additionalAssets];
|
||||
const resolvedBrokenIds = brokenAssetIds ?? new Set(additionalAssets.map((a) => a.id));
|
||||
const assetMap = new Map(allAssets.map((a) => [a.id, a]));
|
||||
|
||||
primaryAssetDto.stack = {
|
||||
id: stackId,
|
||||
assetCount: allAssets.length,
|
||||
primaryAssetId: primaryAssetDto.id,
|
||||
};
|
||||
|
||||
return {
|
||||
id: stackId,
|
||||
primaryAssetId: primaryAssetDto.id,
|
||||
assets: allAssets,
|
||||
brokenAssetIds: resolvedBrokenIds,
|
||||
assetMap,
|
||||
};
|
||||
};
|
||||
|
||||
export const setupBrokenAssetMockApiRoutes = async (context: BrowserContext, mockStack: MockStack) => {
|
||||
await context.route('**/api/stacks/*', async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
const stackResponse: StackResponseDto = {
|
||||
id: mockStack.id,
|
||||
primaryAssetId: mockStack.primaryAssetId,
|
||||
assets: mockStack.assets,
|
||||
};
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: stackResponse,
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*', async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
const url = new URL(request.url());
|
||||
const segments = url.pathname.split('/');
|
||||
const assetId = segments.at(-1);
|
||||
if (assetId && mockStack.assetMap.has(assetId)) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: mockStack.assetMap.get(assetId),
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
||||
const match = request.url().match(pattern);
|
||||
if (!match?.groups || !mockStack.assetMap.has(match.groups.assetId)) {
|
||||
return route.fallback();
|
||||
}
|
||||
if (mockStack.brokenAssetIds.has(match.groups.assetId)) {
|
||||
return route.fulfill({ status: 404 });
|
||||
}
|
||||
const asset = mockStack.assetMap.get(match.groups.assetId)!;
|
||||
const ratio = (asset.exifInfo?.exifImageWidth ?? 3000) / (asset.exifInfo?.exifImageHeight ?? 4000);
|
||||
const body =
|
||||
match.groups.size === 'preview'
|
||||
? await randomPreview(match.groups.assetId, ratio)
|
||||
: await randomThumbnail(match.groups.assetId, ratio);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { randomThumbnail } from 'src/ui/generators/timeline';
|
||||
|
||||
// Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight
|
||||
const MINIMAL_MP4_BASE64 =
|
||||
'AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAr9tZGF0AAACoAYF//+c' +
|
||||
'3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDEyNSAtIEguMjY0L01QRUctNCBBVkMgY29kZWMg' +
|
||||
'LSBDb3B5bGVmdCAyMDAzLTIwMTIgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwg' +
|
||||
'LSBvcHRpb25zOiBjYWJhYz0xIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDM6MHgxMTMg' +
|
||||
'bWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5n' +
|
||||
'ZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEg' +
|
||||
'ZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02IGxvb2thaGVhZF90aHJl' +
|
||||
'YWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJh' +
|
||||
'eV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2Fk' +
|
||||
'YXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtl' +
|
||||
'eWludD0yNTAga2V5aW50X21pbj0yNCBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9v' +
|
||||
'a2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBt' +
|
||||
'YXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAAA9liIQAV/0TAAYdeBTX' +
|
||||
'zg8AAALvbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAACoAAQAAAQAAAAAAAAAAAAAAAAEAAAAA' +
|
||||
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAA' +
|
||||
'Ahl0cmFrAAAAXHRraGQAAAAPAAAAAAAAAAAAAAABAAAAAAAAACoAAAAAAAAAAAAAAAAAAAAAAAEAAAAA' +
|
||||
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAgAAAAIAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAA' +
|
||||
'AAEAAAAqAAAAAAABAAAAAAGRbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAwAAAAAgBVxAAAAAAA' +
|
||||
'LWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABPG1pbmYAAAAUdm1oZAAA' +
|
||||
'AAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAPxzdGJsAAAAmHN0' +
|
||||
'c2QAAAAAAAAAAQAAAIhhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAgACABIAAAASAAAAAAAAAAB' +
|
||||
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAMmF2Y0MBZAAK/+EAGWdkAAqs' +
|
||||
'2V+WXAWyAAADAAIAAAMAYB4kSywBAAZo6+PLIsAAAAAYc3R0cwAAAAAAAAABAAAAAQAAAgAAAAAcc3Rz' +
|
||||
'YwAAAAAAAAABAAAAAQAAAAEAAAABAAAAFHN0c3oAAAAAAAACtwAAAAEAAAAUc3RjbwAAAAAAAAABAAAA' +
|
||||
'MAAAAGJ1ZHRhAAAAWm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALWls' +
|
||||
'c3QAAAAlqXRvbwAAAB1kYXRhAAAAAQAAAABMYXZmNTQuNjMuMTA0';
|
||||
|
||||
export const MINIMAL_MP4_BUFFER = Buffer.from(MINIMAL_MP4_BASE64, 'base64');
|
||||
|
||||
export type MockPerson = {
|
||||
id: string;
|
||||
name: string;
|
||||
birthDate: string | null;
|
||||
isHidden: boolean;
|
||||
thumbnailPath: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export const createMockPeople = (count: number): MockPerson[] => {
|
||||
const names = [
|
||||
'Alice Johnson',
|
||||
'Bob Smith',
|
||||
'Charlie Brown',
|
||||
'Diana Prince',
|
||||
'Eve Adams',
|
||||
'Frank Castle',
|
||||
'Grace Lee',
|
||||
'Hank Pym',
|
||||
'Iris West',
|
||||
'Jack Ryan',
|
||||
];
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `person-${index}`,
|
||||
name: names[index % names.length],
|
||||
birthDate: null,
|
||||
isHidden: false,
|
||||
thumbnailPath: `/upload/thumbs/person-${index}.jpeg`,
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
}));
|
||||
};
|
||||
|
||||
export type FaceCreateCapture = {
|
||||
requests: Array<{
|
||||
assetId: string;
|
||||
personId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const setupFaceEditorMockApiRoutes = async (
|
||||
context: BrowserContext,
|
||||
mockPeople: MockPerson[],
|
||||
faceCreateCapture: FaceCreateCapture,
|
||||
) => {
|
||||
await context.route('**/api/people?*', async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
hasNextPage: false,
|
||||
hidden: 0,
|
||||
people: mockPeople,
|
||||
total: mockPeople.length,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/faces', async (route, request) => {
|
||||
if (request.method() !== 'POST') {
|
||||
return route.fallback();
|
||||
}
|
||||
|
||||
const body = request.postDataJSON();
|
||||
faceCreateCapture.requests.push(body);
|
||||
|
||||
return route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'text/plain',
|
||||
body: 'OK',
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/people/*/thumbnail', async (route) => {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: await randomThumbnail('person-thumb', 1),
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TimelineData,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { sleep } from 'src/ui/specs/timeline/utils';
|
||||
import { MINIMAL_MP4_BUFFER } from './face-editor-network';
|
||||
|
||||
export class TimelineTestContext {
|
||||
slowBucket = false;
|
||||
@@ -135,6 +136,14 @@ export const setupTimelineMockApiRoutes = async (
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/video/playback*', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: MINIMAL_MP4_BUFFER,
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/albums/**', async (route, request) => {
|
||||
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
|
||||
if (albumsMatch) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { toAssetResponseDto } from 'src/ui/generators/timeline';
|
||||
import {
|
||||
createMockStack,
|
||||
createMockStackAsset,
|
||||
MockStack,
|
||||
setupBrokenAssetMockApiRoutes,
|
||||
} from 'src/ui/mock-network/broken-asset-network';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { setupAssetViewerFixture } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('broken-asset responsiveness', () => {
|
||||
const fixture = setupAssetViewerFixture(889);
|
||||
let mockStack: MockStack;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
|
||||
const brokenAssets = [
|
||||
createMockStackAsset(fixture.adminUserId),
|
||||
createMockStackAsset(fixture.adminUserId),
|
||||
createMockStackAsset(fixture.adminUserId),
|
||||
];
|
||||
|
||||
mockStack = createMockStack(primaryAssetDto, brokenAssets);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
||||
});
|
||||
|
||||
test('broken asset in stack strip hides icon at small size', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const stackSlideshow = page.locator('#stack-slideshow');
|
||||
await expect(stackSlideshow).toBeVisible();
|
||||
|
||||
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
|
||||
await expect(brokenAssets.first()).toBeVisible();
|
||||
await expect(brokenAssets).toHaveCount(mockStack.brokenAssetIds.size);
|
||||
|
||||
for (const brokenAsset of await brokenAssets.all()) {
|
||||
await expect(brokenAsset.locator('svg')).not.toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('broken asset in stack strip uses text-xs class', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const stackSlideshow = page.locator('#stack-slideshow');
|
||||
await expect(stackSlideshow).toBeVisible();
|
||||
|
||||
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
|
||||
await expect(brokenAssets.first()).toBeVisible();
|
||||
|
||||
for (const brokenAsset of await brokenAssets.all()) {
|
||||
const messageSpan = brokenAsset.locator('span');
|
||||
await expect(messageSpan).toHaveClass(/text-xs/);
|
||||
}
|
||||
});
|
||||
|
||||
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
|
||||
await context.route(
|
||||
(url) =>
|
||||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) ||
|
||||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`),
|
||||
async (route) => {
|
||||
return route.fulfill({ status: 404 });
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first();
|
||||
await expect(viewerBrokenAsset).toBeVisible();
|
||||
|
||||
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
||||
|
||||
const messageSpan = viewerBrokenAsset.locator('span');
|
||||
await expect(messageSpan).toHaveClass(/text-base/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { expect, Page, test } from '@playwright/test';
|
||||
import { SeededRandom, selectRandom, TimelineAssetConfig } from 'src/ui/generators/timeline';
|
||||
import {
|
||||
createMockPeople,
|
||||
FaceCreateCapture,
|
||||
MockPerson,
|
||||
setupFaceEditorMockApiRoutes,
|
||||
} from 'src/ui/mock-network/face-editor-network';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { setupAssetViewerFixture } from './utils';
|
||||
|
||||
const waitForSelectorTransition = async (page: Page) => {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const selector = document.querySelector('#face-selector') as HTMLElement | null;
|
||||
if (!selector) {
|
||||
return false;
|
||||
}
|
||||
return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished');
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 1000, polling: 50 },
|
||||
);
|
||||
};
|
||||
|
||||
const openFaceEditor = async (page: Page, asset: TimelineAssetConfig) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.keyboard.press('i');
|
||||
await page.locator('#detail-panel').waitFor({ state: 'visible' });
|
||||
await page.getByLabel('Tag people').click();
|
||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
||||
await waitForSelectorTransition(page);
|
||||
};
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('face-editor', () => {
|
||||
const fixture = setupAssetViewerFixture(777);
|
||||
const rng = new SeededRandom(777);
|
||||
let mockPeople: MockPerson[];
|
||||
let faceCreateCapture: FaceCreateCapture;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
mockPeople = createMockPeople(8);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
faceCreateCapture = { requests: [] };
|
||||
await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture);
|
||||
});
|
||||
|
||||
type ScreenRect = { top: number; left: number; width: number; height: number };
|
||||
|
||||
const getFaceBoxRect = async (page: Page): Promise<ScreenRect> => {
|
||||
const dataEl = page.locator('#face-editor-data');
|
||||
await expect(dataEl).toHaveAttribute('data-face-left', /^-?\d+/);
|
||||
await expect(dataEl).toHaveAttribute('data-face-top', /^-?\d+/);
|
||||
await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/);
|
||||
await expect(dataEl).toHaveAttribute('data-face-height', /^[1-9]/);
|
||||
const canvasBox = await page.locator('#face-editor').boundingBox();
|
||||
if (!canvasBox) {
|
||||
throw new Error('Canvas element not found');
|
||||
}
|
||||
const left = Number(await dataEl.getAttribute('data-face-left'));
|
||||
const top = Number(await dataEl.getAttribute('data-face-top'));
|
||||
const width = Number(await dataEl.getAttribute('data-face-width'));
|
||||
const height = Number(await dataEl.getAttribute('data-face-height'));
|
||||
return {
|
||||
top: canvasBox.y + top,
|
||||
left: canvasBox.x + left,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
|
||||
const getSelectorRect = async (page: Page): Promise<ScreenRect> => {
|
||||
const box = await page.locator('#face-selector').boundingBox();
|
||||
if (!box) {
|
||||
throw new Error('Face selector element not found');
|
||||
}
|
||||
return { top: box.y, left: box.x, width: box.width, height: box.height };
|
||||
};
|
||||
|
||||
const computeOverlapArea = (a: ScreenRect, b: ScreenRect): number => {
|
||||
const overlapX = Math.max(0, Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left));
|
||||
const overlapY = Math.max(0, Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top));
|
||||
return overlapX * overlapY;
|
||||
};
|
||||
|
||||
const dragFaceBox = async (page: Page, deltaX: number, deltaY: number) => {
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const centerX = faceBox.left + faceBox.width / 2;
|
||||
const centerY = faceBox.top + faceBox.height / 2;
|
||||
await page.mouse.move(centerX, centerY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(300);
|
||||
};
|
||||
|
||||
test('Face editor opens with person list', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeVisible();
|
||||
await expect(page.locator('#face-editor')).toBeVisible();
|
||||
|
||||
for (const person of mockPeople) {
|
||||
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Search filters people by name', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const searchInput = page.locator('#face-selector input');
|
||||
await searchInput.fill('Alice');
|
||||
|
||||
await expect(page.locator('#face-selector').getByText('Alice Johnson')).toBeVisible();
|
||||
await expect(page.locator('#face-selector').getByText('Bob Smith')).toBeHidden();
|
||||
|
||||
await searchInput.clear();
|
||||
|
||||
for (const person of mockPeople) {
|
||||
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Search with no results shows empty message', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const searchInput = page.locator('#face-selector input');
|
||||
await searchInput.fill('Nonexistent Person XYZ');
|
||||
|
||||
for (const person of mockPeople) {
|
||||
await expect(page.locator('#face-selector').getByText(person.name)).toBeHidden();
|
||||
}
|
||||
});
|
||||
|
||||
test('Selecting a person shows confirmation dialog', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const personToTag = mockPeople[0];
|
||||
await page.locator('#face-selector').getByText(personToTag.name).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Confirming tag calls createFace API and closes editor', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const personToTag = mockPeople[0];
|
||||
await page.locator('#face-selector').getByText(personToTag.name).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.getByRole('button', { name: /confirm/i }).click();
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeHidden();
|
||||
await expect(page.locator('#face-editor')).toBeHidden();
|
||||
|
||||
expect(faceCreateCapture.requests).toHaveLength(1);
|
||||
expect(faceCreateCapture.requests[0].assetId).toBe(asset.id);
|
||||
expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id);
|
||||
});
|
||||
|
||||
test('Cancel button closes face editor', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeVisible();
|
||||
await expect(page.locator('#face-editor')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeHidden();
|
||||
await expect(page.locator('#face-editor')).toBeHidden();
|
||||
});
|
||||
|
||||
test('Selector does not overlap face box on initial open', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector repositions without overlap after dragging face box down', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, 0, 150);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector repositions without overlap after dragging face box right', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, 200, 0);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector repositions without overlap after dragging face box to top-left corner', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, -300, -300);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector repositions without overlap after dragging face box to bottom-right', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, 300, 300);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector stays within viewport bounds', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const viewportSize = page.viewportSize()!;
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
|
||||
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
|
||||
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
|
||||
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
|
||||
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
|
||||
});
|
||||
|
||||
test('Selector stays within viewport after dragging to edge', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, -400, -400);
|
||||
|
||||
const viewportSize = page.viewportSize()!;
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
|
||||
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
|
||||
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
|
||||
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
|
||||
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
|
||||
});
|
||||
|
||||
test('Face box is draggable on the canvas', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const beforeDrag = await getFaceBoxRect(page);
|
||||
await dragFaceBox(page, 100, 50);
|
||||
const afterDrag = await getFaceBoxRect(page);
|
||||
|
||||
expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50);
|
||||
expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { toAssetResponseDto } from 'src/ui/generators/timeline';
|
||||
import {
|
||||
createMockStack,
|
||||
createMockStackAsset,
|
||||
MockStack,
|
||||
setupBrokenAssetMockApiRoutes,
|
||||
} from 'src/ui/mock-network/broken-asset-network';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { enableTagsPreference, ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('asset-viewer stack', () => {
|
||||
const fixture = setupAssetViewerFixture(888);
|
||||
let mockStack: MockStack;
|
||||
let primaryAssetDto: AssetResponseDto;
|
||||
let secondAssetDto: AssetResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
primaryAssetDto.tags = [
|
||||
{
|
||||
id: faker.string.uuid(),
|
||||
name: '1',
|
||||
value: 'test/1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
secondAssetDto = createMockStackAsset(fixture.adminUserId);
|
||||
secondAssetDto.tags = [
|
||||
{
|
||||
id: faker.string.uuid(),
|
||||
name: '2',
|
||||
value: 'test/2',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
||||
});
|
||||
|
||||
test('stack slideshow is visible', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const stackSlideshow = page.locator('#stack-slideshow');
|
||||
await expect(stackSlideshow).toBeVisible();
|
||||
|
||||
const stackAssets = stackSlideshow.locator('[data-asset]');
|
||||
await expect(stackAssets).toHaveCount(mockStack.assets.length);
|
||||
});
|
||||
|
||||
test('tags of primary asset are visible', async ({ context, page }) => {
|
||||
await enableTagsPreference(context);
|
||||
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||
await expect(tags.first()).toHaveText('test/1');
|
||||
});
|
||||
|
||||
test('tags of second asset are visible', async ({ context, page }) => {
|
||||
await enableTagsPreference(context);
|
||||
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
||||
await stackAssets.nth(1).click();
|
||||
|
||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||
await expect(tags.first()).toHaveText('test/2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { BrowserContext, Page, test } from '@playwright/test';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
SeededRandom,
|
||||
selectRandom,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
toAssetResponseDto,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
export type AssetViewerTestFixture = {
|
||||
adminUserId: string;
|
||||
timelineRestData: TimelineData;
|
||||
assets: TimelineAssetConfig[];
|
||||
testContext: TimelineTestContext;
|
||||
changes: Changes;
|
||||
primaryAsset: TimelineAssetConfig;
|
||||
primaryAssetDto: AssetResponseDto;
|
||||
};
|
||||
|
||||
export function setupAssetViewerFixture(seed: number): AssetViewerTestFixture {
|
||||
const rng = new SeededRandom(seed);
|
||||
const testContext = new TimelineTestContext();
|
||||
|
||||
const fixture: AssetViewerTestFixture = {
|
||||
adminUserId: undefined!,
|
||||
timelineRestData: undefined!,
|
||||
assets: [],
|
||||
testContext,
|
||||
changes: {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
},
|
||||
primaryAsset: undefined!,
|
||||
primaryAssetDto: undefined!,
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.fail(
|
||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
|
||||
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
|
||||
);
|
||||
utils.initSdk();
|
||||
fixture.adminUserId = faker.string.uuid();
|
||||
testContext.adminId = fixture.adminUserId;
|
||||
fixture.timelineRestData = generateTimelineData({
|
||||
...createDefaultTimelineConfig(),
|
||||
ownerId: fixture.adminUserId,
|
||||
});
|
||||
for (const timeBucket of fixture.timelineRestData.buckets.values()) {
|
||||
fixture.assets.push(...timeBucket);
|
||||
}
|
||||
|
||||
fixture.primaryAsset = selectRandom(
|
||||
fixture.assets.filter((a) => a.isImage),
|
||||
rng,
|
||||
);
|
||||
fixture.primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, fixture.adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, fixture.timelineRestData, fixture.changes, fixture.testContext);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
fixture.testContext.slowBucket = false;
|
||||
fixture.changes.albumAdditions = [];
|
||||
fixture.changes.assetDeletions = [];
|
||||
fixture.changes.assetArchivals = [];
|
||||
fixture.changes.assetFavorites = [];
|
||||
});
|
||||
|
||||
return fixture;
|
||||
}
|
||||
|
||||
export async function ensureDetailPanelVisible(page: Page) {
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
const isVisible = await page.locator('#detail-panel').isVisible();
|
||||
if (!isVisible) {
|
||||
await page.keyboard.press('i');
|
||||
await page.waitForSelector('#detail-panel');
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableTagsPreference(context: BrowserContext) {
|
||||
await context.route('**/users/me/preferences', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
albums: { defaultAssetOrder: 'desc' },
|
||||
folders: { enabled: false, sidebarWeb: false },
|
||||
memories: { enabled: true, duration: 5 },
|
||||
people: { enabled: true, sidebarWeb: false },
|
||||
sharedLinks: { enabled: true, sidebarWeb: false },
|
||||
ratings: { enabled: false },
|
||||
tags: { enabled: true, sidebarWeb: false },
|
||||
emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true },
|
||||
download: { archiveSize: 4_294_967_296, includeEmbeddedVideos: false },
|
||||
purchase: { showSupportBadge: true, hideBuyButtonUntil: '2100-02-12T00:00:00.000Z' },
|
||||
cast: { gCastEnabled: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -438,7 +438,7 @@ test.describe('Timeline', () => {
|
||||
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
||||
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
|
||||
await thumbnailUtils.expectSelectedDisabled(page, asset.id);
|
||||
});
|
||||
test('Add photos to album', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
@@ -447,7 +447,7 @@ test.describe('Timeline', () => {
|
||||
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
||||
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
|
||||
await thumbnailUtils.expectSelectedDisabled(page, asset.id);
|
||||
await pageUtils.selectDay(page, 'Tue, Feb 27, 2024');
|
||||
const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
|
||||
@@ -65,7 +65,7 @@ export const thumbnailUtils = {
|
||||
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
|
||||
},
|
||||
selectedAsset(page: Page) {
|
||||
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
|
||||
return page.locator('[data-thumbnail-focus-container][data-selected]');
|
||||
},
|
||||
async clickAssetId(page: Page, assetId: string) {
|
||||
await thumbnailUtils.withAssetId(page, assetId).click();
|
||||
@@ -102,12 +102,9 @@ export const thumbnailUtils = {
|
||||
async expectThumbnailIsNotArchive(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
|
||||
},
|
||||
async expectSelectedReadonly(page: Page, assetId: string) {
|
||||
// todo - need a data attribute for selected
|
||||
async expectSelectedDisabled(page: Page, assetId: string) {
|
||||
await expect(
|
||||
page.locator(
|
||||
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
|
||||
),
|
||||
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected][data-disabled]`),
|
||||
).toBeVisible();
|
||||
},
|
||||
async expectTimelineHasOnScreenAssets(page: Page) {
|
||||
@@ -218,8 +215,9 @@ export const pageUtils = {
|
||||
await page.getByText('Confirm').click();
|
||||
},
|
||||
async selectDay(page: Page, day: string) {
|
||||
await page.getByTitle(day).hover();
|
||||
await page.locator('[data-group] .w-8').click();
|
||||
const section = page.getByTitle(day).locator('xpath=ancestor::section[@data-group]');
|
||||
await section.hover();
|
||||
await section.locator('.w-8').click();
|
||||
},
|
||||
async pauseTestDebug() {
|
||||
console.log('NOTE: pausing test indefinately for debug');
|
||||
|
||||
+43
-29
@@ -177,40 +177,51 @@ export const utils = {
|
||||
},
|
||||
|
||||
resetDatabase: async (tables?: string[]) => {
|
||||
try {
|
||||
client = await utils.connectDatabase();
|
||||
client = await utils.connectDatabase();
|
||||
|
||||
tables = tables || [
|
||||
// TODO e2e test for deleting a stack, since it is quite complex
|
||||
'stack',
|
||||
'library',
|
||||
'shared_link',
|
||||
'person',
|
||||
'album',
|
||||
'asset',
|
||||
'asset_face',
|
||||
'activity',
|
||||
'api_key',
|
||||
'session',
|
||||
'user',
|
||||
'system_metadata',
|
||||
'tag',
|
||||
];
|
||||
tables = tables || [
|
||||
// TODO e2e test for deleting a stack, since it is quite complex
|
||||
'stack',
|
||||
'library',
|
||||
'shared_link',
|
||||
'person',
|
||||
'album',
|
||||
'asset',
|
||||
'asset_face',
|
||||
'activity',
|
||||
'api_key',
|
||||
'session',
|
||||
'user',
|
||||
'system_metadata',
|
||||
'tag',
|
||||
];
|
||||
|
||||
const sql: string[] = [];
|
||||
const truncateTables = tables.filter((table) => table !== 'system_metadata');
|
||||
const sql: string[] = [];
|
||||
|
||||
for (const table of tables) {
|
||||
if (table === 'system_metadata') {
|
||||
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
|
||||
} else {
|
||||
sql.push(`DELETE FROM "${table}" CASCADE;`);
|
||||
if (truncateTables.length > 0) {
|
||||
sql.push(`TRUNCATE "${truncateTables.join('", "')}" CASCADE;`);
|
||||
}
|
||||
|
||||
if (tables.includes('system_metadata')) {
|
||||
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
|
||||
}
|
||||
|
||||
const query = sql.join('\n');
|
||||
const maxRetries = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await client.query(query);
|
||||
return;
|
||||
} catch (error: any) {
|
||||
if (error?.code === '40P01' && attempt < maxRetries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
|
||||
continue;
|
||||
}
|
||||
console.error('Failed to reset database', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await client.query(sql.join('\n'));
|
||||
} catch (error) {
|
||||
console.error('Failed to reset database', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -499,6 +510,9 @@ export const utils = {
|
||||
createStack: (accessToken: string, assetIds: string[]) =>
|
||||
createStack({ stackCreateDto: { assetIds } }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
setAssetDuplicateId: (accessToken: string, assetId: string, duplicateId: string | null) =>
|
||||
updateAssets({ assetBulkUpdateDto: { ids: [assetId], duplicateId } }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
upsertTags: (accessToken: string, tags: string[]) =>
|
||||
upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
|
||||
+1
-1
@@ -17,6 +17,6 @@
|
||||
"esModuleInterop": true,
|
||||
"baseUrl": "./"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "vitest*.config.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||
@@ -14,15 +15,14 @@ if (!skipDockerSetup) {
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
name: 'e2e:server',
|
||||
retry: process.env.CI ? 4 : 0,
|
||||
include: ['src/specs/server/**/*.e2e-spec.ts'],
|
||||
globalSetup,
|
||||
testTimeout: 15_000,
|
||||
pool: 'threads',
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
},
|
||||
},
|
||||
maxWorkers: 1,
|
||||
isolate: false,
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||
@@ -14,15 +15,14 @@ if (!skipDockerSetup) {
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
name: 'e2e:maintenance',
|
||||
retry: process.env.CI ? 4 : 0,
|
||||
include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'],
|
||||
globalSetup,
|
||||
testTimeout: 15_000,
|
||||
pool: 'threads',
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
},
|
||||
},
|
||||
maxWorkers: 1,
|
||||
isolate: false,
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
});
|
||||
|
||||
+211
-125
@@ -2,147 +2,147 @@
|
||||
"about": "Oor",
|
||||
"account": "Rekening",
|
||||
"account_settings": "Rekeninginstellings",
|
||||
"acknowledge": "Erken",
|
||||
"acknowledge": "Neem kennis",
|
||||
"action": "Aksie",
|
||||
"action_common_update": "Opdateur",
|
||||
"action_common_update": "Werk by",
|
||||
"actions": "Aksies",
|
||||
"active": "Aktief",
|
||||
"activity": "Aktiwiteite",
|
||||
"activity_changed": "Aktiwiteit is {enabled, select, true {aangeskakel} other {afgeskakel}}",
|
||||
"add": "Voegby",
|
||||
"add_a_description": "Voeg 'n beskrywing by",
|
||||
"add_a_location": "Voeg 'n ligging by",
|
||||
"add_a_name": "Voeg 'n naam by",
|
||||
"add_a_title": "Voeg 'n titel by",
|
||||
"add_birthday": "Voeg 'n verjaarsdag by",
|
||||
"add_endpoint": "Voeg Koppelvlakpunt by",
|
||||
"add_exclusion_pattern": "Voeg uitsgluitingspatrone by",
|
||||
"add_location": "Voeg ligging by",
|
||||
"add_more_users": "Voeg meer gebruikers by",
|
||||
"add_partner": "Voeg vennoot by",
|
||||
"add_path": "Voeg pad by",
|
||||
"add_photos": "Voeg foto's by",
|
||||
"add_tag": "Voeg tag by",
|
||||
"add_to": "Voeg byâĻ",
|
||||
"add_to_album": "Voeg na album",
|
||||
"add_to_album_bottom_sheet_added": "By {album} bygevoeg",
|
||||
"activity_changed": "Aktiwiteit is {enabled, select, true {geaktiveer} other {gedeaktiveer}}",
|
||||
"add": "Voeg toe",
|
||||
"add_a_description": "Voeg ân beskrywing toe",
|
||||
"add_a_location": "Voeg ân ligging toe",
|
||||
"add_a_name": "Voeg ân naam toe",
|
||||
"add_a_title": "Voeg ân titel toe",
|
||||
"add_birthday": "Voeg ân verjaarsdag toe",
|
||||
"add_endpoint": "Voeg eindpunt toe",
|
||||
"add_exclusion_pattern": "Voeg uitsluitingspatroon toe",
|
||||
"add_location": "Voeg ligging toe",
|
||||
"add_more_users": "Voeg meer gebruikers toe",
|
||||
"add_partner": "Voeg vennoot toe",
|
||||
"add_path": "Voeg pad toe",
|
||||
"add_photos": "Voeg fotoâs toe",
|
||||
"add_tag": "Voeg etiket toe",
|
||||
"add_to": "Voeg toe totâĻ",
|
||||
"add_to_album": "Voeg toe tot album",
|
||||
"add_to_album_bottom_sheet_added": "Tot {album} toegevoeg",
|
||||
"add_to_album_bottom_sheet_already_exists": "Reeds in {album}",
|
||||
"add_to_albums": "Voeg by albums",
|
||||
"add_to_albums_count": "Voeg by ({count}) albums",
|
||||
"add_to_shared_album": "Voeg toe aan gedeelde album",
|
||||
"add_url": "Voeg URL by",
|
||||
"added_to_archive": "By argief toegevoegd",
|
||||
"added_to_favorites": "By gunstelinge toegevoegd",
|
||||
"added_to_favorites_count": "Het {count, number} by gunstelinge toegevoegd",
|
||||
"add_to_albums": "Voeg toe tot albums",
|
||||
"add_to_albums_count": "Voeg toe tot albums ({count})",
|
||||
"add_to_shared_album": "Voeg toe tot gedeelde album",
|
||||
"add_url": "Voeg bronadres toe",
|
||||
"added_to_archive": "Tot argief toegevoeg",
|
||||
"added_to_favorites": "Tot gunstelinge toegevoeg",
|
||||
"added_to_favorites_count": "{count, number} tot gunstelinge toegevoeg",
|
||||
"admin": {
|
||||
"add_exclusion_pattern_description": "Voeg uitsluitingspatrone by. Globbing met *, ** en ? word ondersteun. Om alle lÃĒers in enige lÃĒergids genaamd \"Raw\" te ignoreer, gebruik \"**/Raw/**\". Om alle lÃĒers wat op \".tif\" eindig, te ignoreer, gebruik \"**/*.tif\". Om 'n absolute pad te ignoreer, gebruik \"/path/to/ignore/**\".",
|
||||
"admin_user": "Admin gebruiker",
|
||||
"asset_offline_description": "Hierdie eksterne biblioteekbate word nie meer op skyf gevind nie en is na die asblik geskuif. As die lÃĒer binne die biblioteek geskuif is, gaan jou tydlyn na vir die nuwe ooreenstemmende bate. Om hierdie bate te herstel, maak asseblief seker dat die lÃĒerpad hieronder deur Immich verkry kan word en skandeer die biblioteek.",
|
||||
"authentication_settings": "Verifikasie instellings",
|
||||
"authentication_settings_description": "Bestuur wagwoord, OAuth en ander verifikasie instellings",
|
||||
"authentication_settings_disable_all": "Is jy seker jy wil alle aanmeldmetodes deaktiveer? Aanmelding sal heeltemal gedeaktiveer word.",
|
||||
"authentication_settings_reenable": "Om te heraktiveer, gebruik 'n <link>Server Command</link>.",
|
||||
"add_exclusion_pattern_description": "Voeg uitsluitingspatrone toe. Plekhouers met *, ** en ? word ondersteun. Om alle lÃĒers in enige vouer genaamd âRawâ te ignoreer, gebruik â**/Raw/**â. Om alle lÃĒers wat op â.tifâ eindig, te ignoreer, gebruik â**/*.tifâ. Om ân absolute pad te ignoreer, gebruik â/path/to/ignore/**â.",
|
||||
"admin_user": "Admingebruiker",
|
||||
"asset_offline_description": "Hierdie eksterne biblioteekitem word nie meer op skyf gevind nie en is na die asblik geskuif. As die lÃĒer binne die biblioteek geskuif is, gaan u tydlyn na vir die nuwe ooreenstemmende item. Om hierdie item te herstel, maak asseblief seker dat die lÃĒerpad hieronder deur Immich verkry kan word en skandeer die biblioteek.",
|
||||
"authentication_settings": "Waarmerkinstellings",
|
||||
"authentication_settings_description": "Bestuur wagwoord, OAuth en ander waarmerkinstellings",
|
||||
"authentication_settings_disable_all": "Is u seker u wil alle aantekenmetodes deaktiveer? Aantekening sal heeltemal gedeaktiveer word.",
|
||||
"authentication_settings_reenable": "Gebruik ân <link>bedienerbevel</link> om te heraktiveer.",
|
||||
"background_task_job": "Agtergrondtake",
|
||||
"backup_database": "Skep DatastortlÃĒer",
|
||||
"backup_database_enable_description": "Aktiveer databasisrugsteun",
|
||||
"backup_keep_last_amount": "Aantal vorige rugsteune om te hou",
|
||||
"backup_onboarding_3_description": "totale kopieÃĢ van jou data, insluitende die oorspronklikke lÃĒers. Dit sluit in 1 kopie op 'n ander perseel en 2 kopieÃĢ om die huidige rekenaar.",
|
||||
"backup_onboarding_description": "'N <backblaze-link>3-2-1 rugsteun strategie</backblaze-link> word sterk aanbeveel om jou data veilig te hou. Hou kopieÃĢ van jou fotos/videos so wel as die Immich databasis vir 'n volledige rugsteun oplossing.",
|
||||
"backup_onboarding_footer": "Vir meer inligting oor hoe om 'n rugsteun kopie van Immich te maak, gaan lees asseblief hierdie <link>dokument</link>.",
|
||||
"backup_onboarding_parts_title": "'N 3-2-1 rugsteun sluit in:",
|
||||
"backup_onboarding_title": "Rugsteun kopieÃĢ",
|
||||
"backup_settings": "Rugsteun instellings",
|
||||
"backup_settings_description": "Bestuur databasis rugsteun instellings.",
|
||||
"cleared_jobs": "Poste gevee vir: {job}",
|
||||
"config_set_by_file": "Config word tans deur 'n konfigurasielÃĒer gestel",
|
||||
"confirm_delete_library": "Is jy seker jy wil {library}-biblioteek uitvee?",
|
||||
"confirm_delete_library_assets": "Is jy seker jy wil hierdie biblioteek uitvee? Dit sal {count, plural, one {# bevatte base} other {# bevatte bates}} uit Immich uitvee en kan nie ongedaan gemaak word nie. LÃĒers sal op skyf bly.",
|
||||
"confirm_email_below": "Om te bevestig, tik \"{email}\" hieronder",
|
||||
"confirm_reprocess_all_faces": "Is jy seker jy wil alle gesigte herverwerk? Dit sal ook genoemde mense skoonmaak.",
|
||||
"confirm_user_password_reset": "Is jy seker jy wil {user} se wagwoord terugstel?",
|
||||
"confirm_user_pin_code_reset": "Is jy seker jy wil {user} se PIN kode herstel?",
|
||||
"create_job": "Skep werk",
|
||||
"cron_expression": "Cron uitdrukking",
|
||||
"cron_expression_description": "Stel die skanderingsinterval in met die cron-formaat. Vir meer inligting verwys asseblief na bv. <link>Crontab Guru</link>",
|
||||
"cron_expression_presets": "Cron uitdrukking voorafinstellings",
|
||||
"disable_login": "Deaktiveer aanmelding",
|
||||
"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.",
|
||||
"face_detection": "Gesig herkenning",
|
||||
"face_detection_description": "Identifiseer die gesigte in media deur middel van masjienleer. Vir videos word slegs die duimnaelskets oorweeg. âHerlaaiâ (ver)werk al die media weer. âStel terugâ verwyder alle huidige gesigdata. âOnverwerkâ plaas bates in die tou wat nog nie verwerk is nie. Geidentifiseerde gesigte sal nÃĄ voltooiing van Gesigidentifikasie vir Gesigherkenning in die tou geplaas word, om hulle in bestaande of nuwe persone te groepeer.",
|
||||
"facial_recognition_job_description": "Groepeer gesigte in mense in. Die stap is vinniger nadat Gesig Deteksie klaar is. \"Herstel\" (her-)groepeer alle gesigte. \"Vermiste\" plaas gesigte in ry wat nie 'n persoon gekoppel het nie.",
|
||||
"failed_job_command": "Opdrag {command} het misluk vir werk: {job}",
|
||||
"force_delete_user_warning": "WAARSKUWING: Dit sal onmiddellik die gebruiker en alle bates verwyder. Dit kan nie ontdoen word nie en die lÃĒers kan nie herstel word nie.",
|
||||
"backup_database": "Skep DatabasisstortlÃĒer",
|
||||
"backup_database_enable_description": "Aktiveer databasisstortlÃĒers",
|
||||
"backup_keep_last_amount": "Aantal vorige stortlÃĒers om te hou",
|
||||
"backup_onboarding_3_description": "totale kopieÃĢ van u data, insluitend die oorspronklike lÃĒers. Dit sluit 1 kopie op ân ander perseel en 2 lokale kopieÃĢ in.",
|
||||
"backup_onboarding_description": "ân <backblaze-link>3-2-1-rugsteunstrategie</backblaze-link> word sterk aanbeveel om u data veilig te hou. Hou kopieÃĢ van u fotoâs/videoâs sowel as die Immich-databasis vir ân volledige rugsteunoplossing.",
|
||||
"backup_onboarding_footer": "Lees hierdie <link>dokument</link> vir meer inligting oor hoe om ân rugsteunkopie van Immich te maak.",
|
||||
"backup_onboarding_parts_title": "ân 3-2-1-rugsteun sluit in:",
|
||||
"backup_onboarding_title": "RugsteunkopieÃĢ",
|
||||
"backup_settings": "Databasisstortinstellings",
|
||||
"backup_settings_description": "Bestuur databasisrugsteuninstellings.",
|
||||
"cleared_jobs": "Take gewis vir: {job}",
|
||||
"config_set_by_file": "Config word tans deur ân konfigurasielÃĒer gestel",
|
||||
"confirm_delete_library": "Is u seker u wil {library}-biblioteek skrap?",
|
||||
"confirm_delete_library_assets": "Is u seker u wil hierdie biblioteek skrap? Dit sal {count, plural, one {# bevatte item} other {# bevatte items}} uit Immich skrap en kan nie ongedaan gemaak word nie. LÃĒers sal op skyf bly.",
|
||||
"confirm_email_below": "Tik â{email}â hieronder ter bevestiging",
|
||||
"confirm_reprocess_all_faces": "Is u seker u wil alle gesigte herverwerk? Dit sal ook genoemde mense skoonmaak.",
|
||||
"confirm_user_password_reset": "Is u seker u wil {user} se wagwoord terugstel?",
|
||||
"confirm_user_pin_code_reset": "Is u seker u wil {user} se PIN-kode herstel?",
|
||||
"create_job": "Skep taak",
|
||||
"cron_expression": "Cron-uitdrukking",
|
||||
"cron_expression_description": "Stel die skanderingsinterval in met die cron-formaat. Kyk gerus na bv. <link>Crontab Guru</link> vir meer inligting",
|
||||
"cron_expression_presets": "Cron-uitdrukking voorafinstellings",
|
||||
"disable_login": "Deaktiveer aantekening",
|
||||
"duplicate_detection_job_description": "Begin masjienleer op items om soortgelyke beelde op te spoor. Maak staat op Slimsoek",
|
||||
"exclusion_pattern_description": "Met uitsluitingspatrone kan u lÃĒers en vouers ignoreer wanneer u u biblioteek skandeer. Dit is nuttig as u vouers het wat lÃĒers bevat wat u nie wil invoer nie, soos RAW-lÃĒers.",
|
||||
"face_detection": "Gesigherkenning",
|
||||
"face_detection_description": "Identifiseer die gesigte in media d.m.v. masjienleer. Vir videoâs word slegs die duimnael oorweeg. âHerlaaiâ (ver)werk al die media weer. âStel terugâ verwyder alle huidige gesigdata. âOnverwerkâ plaas items in die ry wat nog nie verwerk is nie. Geïdentifiseerde gesigte sal nÃĄ voltooiing van Gesigidentifikasie vir Gesigherkenning in die ry geplaas word om hulle in bestaande of nuwe persone te groepeer.",
|
||||
"facial_recognition_job_description": "Groepeer gesigte in mense. Die stap is vinniger nadat Gesigherkenning klaar is. âHerstelâ (her-)groepeer alle gesigte. âVermisteâ plaas gesigte in ry wat nie ân persoon gekoppel het nie.",
|
||||
"failed_job_command": "Bevel {command} het misluk vir taak: {job}",
|
||||
"force_delete_user_warning": "WAARSKUWING: Dit sal onmiddellik die gebruiker en alle items verwyder. Dit kan nie ontdaan word nie en die lÃĒers kan nie herstel word nie.",
|
||||
"image_format": "Formaat",
|
||||
"image_format_description": "WebP produseer kleiner lÃĒers as JPEG, maar is stadiger om te enkodeer.",
|
||||
"image_fullsize_description": "Vol grote prent met geen metadata, gebruik wanner ingezoem",
|
||||
"image_fullsize_enabled": "Skakel aan vol grote prent generasie",
|
||||
"image_format_description": "WebP lewer kleiner lÃĒers as JPEG, maar is stadiger om te enkodeer.",
|
||||
"image_fullsize_description": "Volgrootte prent met geen metadata, gebruik wanner ingezoem",
|
||||
"image_fullsize_enabled": "Aktiveer spek van volgrootte prent",
|
||||
"image_prefer_embedded_preview": "Verkies ingebedde voorskou",
|
||||
"image_prefer_wide_gamut": "Verkies wide gamut",
|
||||
"image_prefer_wide_gamut_setting_description": "Gebruik Display P3 vir kleinkiekies. Dit behou die lewendheid van beelde met wye kleurruimtes beter, maar beelde kan anders verskyn op ou apparate met 'n ou blaaierweergawe. sRGB-beelde gebruik steeds sRGB om kleurverskuiwings te voorkom.",
|
||||
"image_preview_description": "Mediumgrootte prent met gestroopte metadata, wat gebruik word wanneer 'n enkele bate bekyk word en vir masjienleer",
|
||||
"image_preview_quality_description": "Voorskou kwaliteit van 1-100. HoÃĢr is beter, maar produseer groter lÃĒers en kan app-reaksie verminder. Die stel van 'n lae waarde kan masjienleerkwaliteit beïnvloed.",
|
||||
"image_preview_title": "Voorskou Instellings",
|
||||
"image_prefer_wide_gamut": "Verkies breÃĢspektrum",
|
||||
"image_prefer_wide_gamut_setting_description": "Gebruik Display P3 vir duimnaels. Dit behou die lewendheid van beelde met wye kleurruimtes beter, maar beelde kan anders verskyn op ou toestelle met ân ou blaaierweergawe. sRGB-beelde gebruik steeds sRGB om kleurverskuiwings te voorkom.",
|
||||
"image_preview_description": "Mediumgrootte prent met gestroopte metadata, wat gebruik word wanneer ân enkele item bekyk word en vir masjienleer",
|
||||
"image_preview_quality_description": "Voorskoukwaliteit van 1-100. HoÃĢr is beter, maar lewer groter lÃĒers en kan die toep vertraag. Die stel van ân lae waarde kan masjienleerkwaliteit beïnvloed.",
|
||||
"image_preview_title": "Voorskou-instellings",
|
||||
"image_quality": "Kwaliteit",
|
||||
"image_resolution": "Resolusie",
|
||||
"image_resolution_description": "HoÃĢr resolusies kan meer detail bewaar, maar neem langer om te enkodeer, het groter lÃĒergroottes en kan app-reaksie verminder.",
|
||||
"image_settings": "Prent Instellings",
|
||||
"image_resolution_description": "HoÃĢr resolusies kan meer detail bewaar, maar neem langer om te enkodeer, het groter lÃĒergroottes en kan die toep vertraag.",
|
||||
"image_settings": "Prentinstellings",
|
||||
"image_settings_description": "Bestuur die kwaliteit en resolusie van gegenereerde beelde",
|
||||
"image_thumbnail_description": "Klein kleinkiekies sonder metadata, gebruik om groepe foto's soos die tydlyn te bekyk",
|
||||
"image_thumbnail_quality_description": "Kleinkiekiekwaliteit van 1-100. HoÃĢr is beter, maar produseer groter lÃĒers en kan die toepassing vertraag.",
|
||||
"image_thumbnail_title": "Kleinkiekie-instellings",
|
||||
"image_thumbnail_description": "Klein duimnaels sonder metadata, gebruik om groepe fotoâs soos die tydlyn te bekyk",
|
||||
"image_thumbnail_quality_description": "Duinmaelkwaliteit van 1-100. HoÃĢr is beter, maar lewer groter lÃĒers en kan die toep vertraag.",
|
||||
"image_thumbnail_title": "Duimnaelinstellings",
|
||||
"job_concurrency": "{job} gelyktydigheid",
|
||||
"job_created": "Taak gemaak",
|
||||
"job_created": "Taak geskep",
|
||||
"job_not_concurrency_safe": "Hierdie taak kan nie gelyktydig uitgevoer word nie.",
|
||||
"job_settings": "Agtergrondtaakinstellings",
|
||||
"job_settings_description": "Bestuur werkgelyktydigheid",
|
||||
"job_settings": "Taakinstellings",
|
||||
"job_settings_description": "Bestuur taakgelyktydigheid",
|
||||
"library_created": "Biblioteek geskep: {library}",
|
||||
"library_deleted": "Biblioteek verwyder",
|
||||
"library_scanning": "Periodieke Soek",
|
||||
"library_scanning_description": "Stel periodieke deursoek van biblioteek in",
|
||||
"library_deleted": "Biblioteek geskrap",
|
||||
"library_scanning": "Periodieke skandering",
|
||||
"library_scanning_description": "Stel periodieke skandering van biblioteek in",
|
||||
"library_scanning_enable_description": "Aktiveer periodieke biblioteekskandering",
|
||||
"library_settings": "Eksterne Biblioteek",
|
||||
"library_settings_description": "Eksterne biblioteek verstellings",
|
||||
"library_tasks_description": "Deursoek eksterne biblioteke vir nuwe of veranderde bates",
|
||||
"library_watching_enable_description": "Hou eksterne biblioteke dop vir leer veranderinge",
|
||||
"library_watching_settings": "Biblioteek dop hou (EKSPERIMENTEEL)",
|
||||
"library_settings": "Eksterne biblioteek",
|
||||
"library_settings_description": "Eksternebiblioteekinstellings",
|
||||
"library_tasks_description": "Skandeer eksterne biblioteke vir nuwe of veranderde items",
|
||||
"library_watching_enable_description": "Hou eksterne biblioteke dop vir lÃĒerveranderinge",
|
||||
"library_watching_settings": "Biblioteekdophou [EKSPERIMENTEEL]",
|
||||
"library_watching_settings_description": "Hou automaties dop vir veranderinge",
|
||||
"logging_enable_description": "Aktifeer \"logging\"",
|
||||
"logging_level_description": "Wanneer aktief, watter vlak van \"logs\" om te skep.",
|
||||
"logging_settings": "\"Logs\"",
|
||||
"machine_learning_clip_model": "CLIP model",
|
||||
"machine_learning_duplicate_detection": "Duplikaat herkenning",
|
||||
"machine_learning_duplicate_detection_enabled": "Aktifeer duplikaat herkenning",
|
||||
"machine_learning_enabled": "Aktifeer masjienleer",
|
||||
"machine_learning_facial_recognition": "Gesigsherkenning",
|
||||
"machine_learning_facial_recognition_description": "Herken, identifiseer en groepeer gesigte in fotos",
|
||||
"machine_learning_facial_recognition_model": "Gesigsherkennings model",
|
||||
"machine_learning_facial_recognition_setting": "Aktifeer gesigsherkenning",
|
||||
"machine_learning_max_detection_distance": "Maksimum herkennings afstand",
|
||||
"logging_enable_description": "Aktiveer logboekbyhouding",
|
||||
"logging_level_description": "Wanneer aktief, welke logboekvlak om te gebruik.",
|
||||
"logging_settings": "Logboek",
|
||||
"machine_learning_clip_model": "CLIP-model",
|
||||
"machine_learning_duplicate_detection": "Duplikaatbespeuring",
|
||||
"machine_learning_duplicate_detection_enabled": "Aktiveer duplikaatbespeuring",
|
||||
"machine_learning_enabled": "Aktiveer masjienleer",
|
||||
"machine_learning_facial_recognition": "Gesigherkenning",
|
||||
"machine_learning_facial_recognition_description": "Bespeur, identifiseer en groepeer gesigte in fotoâs",
|
||||
"machine_learning_facial_recognition_model": "Gesigherkenningsmodel",
|
||||
"machine_learning_facial_recognition_setting": "Aktiveer gesigherkenning",
|
||||
"machine_learning_max_detection_distance": "Maksimum herkenningsafstand",
|
||||
"map_settings": "Kaart",
|
||||
"migration_job": "Migrasie",
|
||||
"oauth_settings": "OAuth",
|
||||
"transcoding_acceleration_vaapi": "VAAPI",
|
||||
"transcoding_preferred_hardware_device": "Verkiesde hardeware"
|
||||
"transcoding_preferred_hardware_device": "Voorkeurapparatuur"
|
||||
},
|
||||
"administration": "Administrasie",
|
||||
"advanced": "Gevorderde",
|
||||
"advanced": "Gevorderd",
|
||||
"albums": "Albums",
|
||||
"all": "Alle",
|
||||
"anti_clockwise": "Anti-kloksgewys",
|
||||
"anti_clockwise": "Linksom",
|
||||
"archive": "Argief",
|
||||
"asset_skipped": "Oorgeslaan",
|
||||
"asset_uploaded": "Opgelaai",
|
||||
"asset_uploading": "OplaaiâĻ",
|
||||
"assets": "Bates",
|
||||
"asset_uploading": "Laai tans opâĻ",
|
||||
"assets": "Items",
|
||||
"back": "Terug",
|
||||
"backward": "Agteruit",
|
||||
"build": "Bou",
|
||||
"camera": "Kamera",
|
||||
"cancel": "Kanselleer",
|
||||
"city": "Stad",
|
||||
"clockwise": "Kloksgewys",
|
||||
"close": "Maak toe",
|
||||
"clockwise": "Regsom",
|
||||
"close": "Sluit",
|
||||
"color": "Kleur",
|
||||
"confirm": "Bevestig",
|
||||
"contain": "Bevat",
|
||||
@@ -154,54 +154,140 @@
|
||||
"created": "Geskep",
|
||||
"dark": "Donker",
|
||||
"day": "Dag",
|
||||
"delete": "Verwyder",
|
||||
"delete": "Skrap",
|
||||
"description": "Beskrywing",
|
||||
"details": "Besonderhede",
|
||||
"direction": "Rigting",
|
||||
"discover": "Ontdek",
|
||||
"documentation": "Dokumentasie",
|
||||
"done": "Klaar",
|
||||
"download": "Aflaai",
|
||||
"download_settings": "Aflaai",
|
||||
"done": "Gereed",
|
||||
"download": "Laai af",
|
||||
"download_settings": "Laai af",
|
||||
"duplicates": "Duplikate",
|
||||
"duration": "Duur",
|
||||
"edit": "Wysig",
|
||||
"search_by_description": "Soek by beskrywing",
|
||||
"search_by_description": "Soek op beskrywing",
|
||||
"search_by_description_example": "Stapdag in Sapa",
|
||||
"stacktrace": "Stapelnasporing",
|
||||
"start": "Begin",
|
||||
"start_date": "Begindatum",
|
||||
"start_date_before_end_date": "Begindatum moet voor einddatum wees",
|
||||
"state": "Staat",
|
||||
"status": "Status",
|
||||
"stop_casting": "Stop sending",
|
||||
"stop_motion_photo": "Stop bewegingsfoto",
|
||||
"stop_photo_sharing": "Staak die deel van u fotoâs?",
|
||||
"stop_photo_sharing_description": "{partner} sal nie meer toegang tot u fotoâs hÃĒ nie.",
|
||||
"untitled_workflow": "Naamlose werkvloei",
|
||||
"up_next": "Volgende",
|
||||
"update_location_action_prompt": "Werk die ligging van {count} gekose items by met:",
|
||||
"updated_at": "Bygewerk",
|
||||
"updated_password": "Wagwoord bygewerk",
|
||||
"upload": "Laai op",
|
||||
"upload_concurrency": "Aantal gelyktydige oplaaie",
|
||||
"upload_details": "Oplaaidetails",
|
||||
"upload_dialog_info": "Wil u ân rugsteun maak van die gekose item(s) op die bediener?",
|
||||
"upload_error_with_count": "Oplaaifout vir {count, plural, one {# item} other {# items}}",
|
||||
"upload_errors": "Oplaai voltooi met {count, plural, one {# fout} other {# foute}}, verfris die blad om die nuwe items te sien.",
|
||||
"upload_finished": "Klaar opgelaai",
|
||||
"upload_progress": "Oorblywend {remaining, number} - Verwerk {processed, number}/{total, number}",
|
||||
"upload_skipped_duplicates": "{count, plural, one {# duplikaat item} other {# duplikaat items}} oorgeslaan",
|
||||
"upload_status_duplicates": "Duplikate",
|
||||
"upload_status_errors": "Foute",
|
||||
"upload_status_uploaded": "Opgelaai",
|
||||
"upload_success": "Oplaai suksesvol, verfris die blad om nuut opgelaaide items te sien.",
|
||||
"upload_to_immich": "Laai op na Immich ({count})",
|
||||
"uploading": "Word opgelaai",
|
||||
"uploading_media": "Media word opgelaai",
|
||||
"url": "URL",
|
||||
"usage": "Gebruik",
|
||||
"use_biometric": "Gebruik biometrie",
|
||||
"use_browser_locale": "Gebruik blaaier se landinstelling",
|
||||
"use_browser_locale_description": "Formatteer datums, tye en getalle gebaseer op u blaaier se landinstelling",
|
||||
"use_current_connection": "Gebruik huidige verbinding",
|
||||
"use_custom_date_range": "Gebruik eerder pasgemaakte datumomvang",
|
||||
"user": "Gebruiker",
|
||||
"user_has_been_deleted": "Hierdie gebruiker is geskrap.",
|
||||
"user_id": "Gebruiker ID",
|
||||
"user_liked": "{user} het van {type, select, photo {hierdie foto} video {hierdie video} asset {} other {hierdie item}} gehou",
|
||||
"user_pin_code_settings": "PIN-kode",
|
||||
"user_pin_code_settings_description": "Bestuur u PIN-kode",
|
||||
"user_privacy": "Gebruikersprivaatheid",
|
||||
"user_purchase_settings": "Koop",
|
||||
"user_purchase_settings_description": "Bestuur u aankoop",
|
||||
"user_role_set": "Stel {user} in as {role}",
|
||||
"user_usage_detail": "Gedetailleerde gebruik van gebruikers",
|
||||
"user_usage_stats": "Statistieke vir rekeninggebruik",
|
||||
"user_usage_stats_description": "Bekyk statistieke van rekeninggebruik",
|
||||
"username": "Gebruikersnaam",
|
||||
"users": "Gebruikers",
|
||||
"users_added_to_album_count": "{count, plural, one {# Gebruiker} other {# Gebruikers}} tot album toegevoeg",
|
||||
"utilities": "Gereedskap",
|
||||
"validate": "Valideer",
|
||||
"validate_endpoint_error": "Voer asb. ân geldige bronadres in",
|
||||
"validation_error": "Valideerfout",
|
||||
"variables": "Veranderlikes",
|
||||
"version": "Weergawe",
|
||||
"version_announcement_closing": "Jou friend, Alex",
|
||||
"version_announcement_message": "Hallo! Daar is ân nuwe weergawe van Immich beskikbaar. Neem gerus bietjie tyd om die <link>vrystellingsnotas</link> te lees en maak seker u opstelling is op datum om wanopstellings te voorkom, veral as u WatchTower of ân ander bywerkmeganisme gebruik.",
|
||||
"version_history": "Weergawegeskiedenis",
|
||||
"version_history_item": "{version} geinstaleerd op {date}",
|
||||
"version_history_item": "{version} geïnstaleer op {date}",
|
||||
"video": "Video",
|
||||
"videos": "Video's",
|
||||
"video_hover_setting": "Speel videoduimnael by muishang",
|
||||
"video_hover_setting_description": "Speel videoduimnael wanneer muis oor item hang. Selfs indien gedeaktiveer kan afspeel begin deur oor die afspeelknop te hang.",
|
||||
"videos": "Videoâs",
|
||||
"videos_count": "{count, plural, one {# video} other {# videoâs}}",
|
||||
"videos_only": "Slegs videoâs",
|
||||
"view": "Bekyk",
|
||||
"view_album": "Bekyk Album",
|
||||
"view_album": "Bekyk album",
|
||||
"view_all": "Bekyk alle",
|
||||
"view_all_users": "Bekyk alle gebruikers",
|
||||
"view_asset_owners": "Bekyk itemeienaars",
|
||||
"view_details": "Bekyk detail",
|
||||
"view_in_timeline": "Bekyk in tydlyn",
|
||||
"view_link": "Bekyk skakel",
|
||||
"view_links": "Bekyk skakels",
|
||||
"view_name": "Bekyk",
|
||||
"view_next_asset": "Bekyk volgende bate",
|
||||
"view_previous_asset": "Bekyk vorige bate",
|
||||
"view_next_asset": "Bekyk volgende item",
|
||||
"view_previous_asset": "Bekyk vorige item",
|
||||
"view_qr_code": "Bekyk QR-kode",
|
||||
"view_similar_photos": "Bekyk soortgelyke fotoâs",
|
||||
"view_stack": "Bekyk stapel",
|
||||
"view_user": "Bekyk gebruiker",
|
||||
"viewer_remove_from_stack": "Verwyder van stapel",
|
||||
"viewer_stack_use_as_main_asset": "Gebruik as hoofbate",
|
||||
"viewer_stack_use_as_main_asset": "Gebruik as hoofitem",
|
||||
"viewer_unstack": "Ontstapel",
|
||||
"visibility_changed": "Sigbaarheid verander voor {count, plural, one {# person} other {# people}}",
|
||||
"visibility_changed": "Sigbaarheid verander vir {count, plural, one {# mens} other {# mense}}",
|
||||
"visual": "Visueel",
|
||||
"visual_builder": "Visuele bouer",
|
||||
"waiting": "Wag",
|
||||
"warning": "Waaskuwing",
|
||||
"waiting_count": "Wagtend: {count}",
|
||||
"warning": "Waarskuwing",
|
||||
"week": "Week",
|
||||
"welcome": "Welkom",
|
||||
"welcome_to_immich": "Welkom by Immich",
|
||||
"wifi_name": "Wi-Fi Naam",
|
||||
"width": "Breedte",
|
||||
"wifi_name": "Wi-Fi-naam",
|
||||
"workflow_delete_prompt": "Is u seker u wil hierdie werkvloei skrap?",
|
||||
"workflow_deleted": "Werkvloei geskrap",
|
||||
"workflow_description": "Werkvloeibeskrywing",
|
||||
"workflow_info": "Werkvloei-inligting",
|
||||
"workflow_json": "Werkvloei-JSON",
|
||||
"workflow_json_help": "Wysig die werkvloei-opstelling in JSON-formaat. Veranderinge sal na die visuele bouer sinchroniseer.",
|
||||
"workflow_name": "Werkvloeinaam",
|
||||
"workflow_navigation_prompt": "Is u seker u wil verlaat sonder om u veranderinge te bewaar?",
|
||||
"workflow_summary": "Werkvloei-opsomming",
|
||||
"workflow_update_success": "Werkvloei suksesvol bygewerk",
|
||||
"workflow_updated": "Werkvloei bygewerk",
|
||||
"workflows": "Werkvloeie",
|
||||
"workflows_help_text": "Werkvloeie outomatiseer aksies op u items gebaseer op snellers en filters",
|
||||
"wrong_pin_code": "Verkeerde PIN-kode",
|
||||
"year": "Jaar",
|
||||
"years_ago": "{years, plural, one {# year} other {# years}} gelede",
|
||||
"years_ago": "{years, plural, one {# jaar} other {# jaar}} gelede",
|
||||
"yes": "Ja",
|
||||
"you_dont_have_any_shared_links": "Jy het geen gedeelde skakels",
|
||||
"your_wifi_name": "Jou Wi-Fi naam",
|
||||
"zoom_image": "Vergroot Prent"
|
||||
"you_dont_have_any_shared_links": "U het geen gedeelde skakels nie",
|
||||
"your_wifi_name": "U Wi-Fi-naam",
|
||||
"zero_to_clear_rating": "druk 0 om itemgradering te wis",
|
||||
"zoom_image": "Zoem in",
|
||||
"zoom_to_bounds": "Zoem na rande"
|
||||
}
|
||||
|
||||
+28
-8
@@ -311,7 +311,7 @@
|
||||
"search_jobs": "Ø§ŲØ¨ØØĢ ØšŲ ŲØ¸Ø§ØĻŲâĻ",
|
||||
"send_welcome_email": "ØĨØąØŗØ§Ų Ø¨ØąŲØ¯ ØĒØąØŲبŲ",
|
||||
"server_external_domain_settings": "ØĨØŗŲ
اŲŲØˇØ§Ų Ø§ŲØŽØ§ØąØŦŲ",
|
||||
"server_external_domain_settings_description": "ØĨØŗŲ
اŲŲØˇØ§Ų ŲØąŲØ§Ø¨Øˇ اŲŲ
Ø´Ø§ØąŲØŠ Ø§ŲØšØ§Ų
ØŠØ Ø¨Ų
ا ŲŲ Ø°ŲŲ http(s)://",
|
||||
"server_external_domain_settings_description": "اŲŲØˇØ§Ų Ų
ØŗØĒ؎دŲ
ŲØąŲØ§Ø¨Øˇ ØŽØ§ØąØŦŲØŠ",
|
||||
"server_public_users": "اŲŲ
ØŗØĒ؎دŲ
ŲŲ Ø§ŲØšØ§Ų
ŲŲ",
|
||||
"server_public_users_description": "ŲØĒŲ
ØĨØ¯ØąØ§ØŦ ØŦŲ
ب𠨧ŲŲ
ØŗØĒ؎دŲ
ŲŲ (Ø§ŲØ§ØŗŲ
ŲØ§ŲØ¨ØąŲØ¯ Ø§ŲØĨŲŲØĒØąŲŲŲ) ØšŲØ¯ ØĨØļØ§ŲØŠ Ų
ØŗØĒ؎دŲ
ØĨŲŲ Ø§ŲØŖŲبŲŲ
اØĒ اŲŲ
Ø´ØĒØąŲØŠ. ØšŲØ¯ ØĒØšØˇŲŲ ŲØ°Ų اŲŲ
ŲØ˛ØŠØ ØŗØĒŲŲŲ ŲØ§ØĻŲ
ØŠ اŲŲ
ØŗØĒ؎دŲ
ŲŲ Ų
ØĒØ§ØØŠ ŲŲØˇ ŲŲ
ØŗØĒ؎دŲ
Ų Ø§ŲØĨØ¯Ø§ØąØŠ.",
|
||||
"server_settings": "ØĨؚداداØĒ Ø§ŲØŽØ§Ø¯Ų
",
|
||||
@@ -411,7 +411,7 @@
|
||||
"transcoding_tone_mapping": "ØąØŗŲ
Ø§ŲØŽØąØ§ØĻØˇ اŲŲØēŲ
ŲØŠ",
|
||||
"transcoding_tone_mapping_description": "ØĒØØ§ŲŲ Ø§ŲØŲØ§Ø¸ ØšŲŲ Ų
Ø¸ŲØą Ų
ŲØ§ØˇØš اŲŲŲØ¯ŲŲ HDR ØšŲØ¯ ØĒØŲŲŲŲØ§ ØĨŲŲ SDR. ŲŲØ¯Ų
ŲŲ ØŽŲØ§ØąØ˛Ų
ŲØŠ ØĒŲØ§Ø˛ŲاØĒ Ų
ØŽØĒŲŲØŠ Ø¨ŲŲ Ø§ŲŲŲŲ ŲØ§ŲØĒŲØ§ØĩŲŲ ŲØ§ŲØŗØˇŲØš. Hable ØĒØØ§Ųظ ØšŲŲ Ø§ŲØĒŲØ§ØĩŲŲØ Mobius ØĒØØ§Ųظ ØšŲŲ Ø§ŲØŖŲŲØ§ŲØ Ų Reinhard ØĒØØ§Ųظ ØšŲŲ Ø§ŲØŗØˇŲØš.",
|
||||
"transcoding_transcode_policy": "ØŗŲØ§ØŗØŠ Ø§ŲØĒØąŲ
ŲØ˛",
|
||||
"transcoding_transcode_policy_description": "ØŗŲØ§ØŗØŠ ØĒØØ¯Ųد Ų
ØĒŲ ŲØŦب ØĒØąŲ
ŲØ˛ اŲŲŲØ¯ŲŲ. ØŗŲØĒŲ
داØĻŲ
ŲØ§ ØĒØąŲ
ŲØ˛ Ų
ŲØ§ØˇØš اŲŲŲØ¯ŲŲ HDR (Ų
ا ŲŲ
ŲØĒŲ
ØĒØšØˇŲŲ Ø§ŲØĒØąŲ
ŲØ˛).",
|
||||
"transcoding_transcode_policy_description": "ØŗŲØ§ØŗØŠ ØĒØØ¯Ųد Ų
ØĒŲ ŲØŦب ØĒØąŲ
ŲØ˛ اŲŲŲØ¯ŲŲ. ØŗŲØĒŲ
داØĻŲ
ŲØ§ ØĒØąŲ
ŲØ˛ Ų
ŲØ§ØˇØš اŲŲŲØ¯ŲŲ HDR Ų Ų
ŲØ§ØˇØš اŲŲØ¯ŲŲ Ø§ŲŲØĒŲ ØĒØŗØĒد؎Ų
ØĒŲØŗŲŲ ØēŲØą YUV 4:2:0. (Ų
ا ŲŲ
ŲØĒŲ
ØĒØšØˇŲŲ Ø§ŲØĒØąŲ
ŲØ˛).",
|
||||
"transcoding_two_pass_encoding": "Ø§ŲØĒØąŲ
ŲØ˛ بŲ
ØąŲØąŲŲ",
|
||||
"transcoding_two_pass_encoding_setting_description": "ØĒØąŲ
ŲØ˛ بŲ
ØąŲØąŲŲ ŲØĨŲØĒØ§ØŦ Ų
ŲØ§ØˇØš ŲŲØ¯ŲŲ Ø¨ØĒØąŲ
ŲØ˛ ØŖŲØļŲ. ØšŲØ¯ ØĒŲ
ŲŲŲ Ø§ŲØØ¯ Ø§ŲØŖŲØĩŲ ŲŲ
ØšØ¯Ų Ø§ŲØ¨ØĒ (Ų
ØˇŲŲØ¨ ŲŲŲ ŲØšŲ
Ų Ų
Øš H.264 Ų HEVC)Ø ŲØŗØĒ؎دŲ
ŲØ°Ø§ اŲŲØļØš ŲØˇØ§Ų Ų
ØšØ¯Ų Ø§ŲØ¨ØĒ Ø§ØŗØĒŲØ§Ø¯Ųا ØĨŲŲ Ø§ŲØØ¯ Ø§ŲØŖŲØĩŲ ŲŲ
ØšØ¯Ų Ø§ŲØ¨ØĒ ŲŲØĒØŦاŲŲ CRF. باŲŲØŗØ¨ØŠ ŲŲ VP9Ø ŲŲ
ŲŲ Ø§ØŗØĒ؎داŲ
CRF ØĨذا ØĒŲ
ØĒØšØˇŲŲ Ø§ŲØØ¯ Ø§ŲØŖŲØĩŲ ŲŲ
ØšØ¯Ų Ø§ŲØ¨ØĒ.",
|
||||
"transcoding_video_codec": "ØĒØąŲ
ŲØ˛ اŲŲŲØ¯ŲŲ",
|
||||
@@ -794,6 +794,11 @@
|
||||
"color": "اŲŲŲŲ",
|
||||
"color_theme": "ŲŲ
Øˇ Ø§ŲØŖŲŲØ§Ų",
|
||||
"command": "اŲ
Øą",
|
||||
"command_palette_prompt": "اؚØĢØą Ø¨ØŗØąØšØŠ ØšŲŲ Ø§ŲØĩŲØØ§ØĒ ØŖŲ Ø§ŲØĨØŦØąØ§ØĄØ§ØĒ ØŖŲ Ø§ŲØŖŲاŲ
Øą",
|
||||
"command_palette_to_close": "ŲŲØ§ØēŲØ§Ų",
|
||||
"command_palette_to_navigate": "ŲŲØ¯ØŽŲŲ",
|
||||
"command_palette_to_select": "ŲŲØ§ØŽØĒŲØ§Øą",
|
||||
"command_palette_to_show_all": "ŲØšØąØļ Ø§ŲŲŲ",
|
||||
"comment_deleted": "ØĒŲ
ØØ°Ų Ø§ŲØĒØšŲŲŲ",
|
||||
"comment_options": "ØŽŲØ§ØąØ§ØĒ Ø§ŲØĒØšŲŲŲ",
|
||||
"comments_and_likes": "Ø§ŲØĒØšŲŲŲØ§ØĒ ŲØ§ŲØĨØšØŦاباØĒ",
|
||||
@@ -867,7 +872,7 @@
|
||||
"current_server_address": "ØšŲŲØ§Ų Ø§ŲØŽØ§Ø¯Ų
Ø§ŲØØ§ŲŲ",
|
||||
"custom_date": "ØĒØ§ØąŲØŽ Ų
ØŽØĩØĩ",
|
||||
"custom_locale": "ŲØēØŠ Ų
ØŽØĩØĩØŠ",
|
||||
"custom_locale_description": "ØĒŲØŗŲŲ Ø§ŲØĒŲØ§ØąŲØŽ ŲØ§ŲØŖØąŲØ§Ų
Ø¨ŲØ§ØĄŲ ØšŲŲ Ø§ŲŲØēØŠ ŲØ§ŲŲ
ŲØˇŲØŠ",
|
||||
"custom_locale_description": "ØĒŲØŗŲŲ Ø§ŲØĒŲØ§ØąŲØŽ, Ø§ŲØŖŲŲØ§ØĒ ŲØ§ŲØŖØąŲØ§Ų
Ø¨ŲØ§ØĄŲ ØšŲŲ Ø§ŲŲØēØŠ ŲØ§ŲŲ
ŲØˇŲØŠ اŲŲ
ØŽØĒØ§ØąŲ",
|
||||
"custom_url": "ØąØ§Ø¨Øˇ Ų
ØŽØĩØĩ",
|
||||
"cutoff_date_description": "Ø§ØØĒŲØ¸ Ø¨Ø§ŲØĩŲØą Ų
Ų ØĸØŽØąâĻ",
|
||||
"cutoff_day": "{count, plural, one {ŲŲŲ
} other {Ø§ŲØ§Ų
}}",
|
||||
@@ -890,8 +895,6 @@
|
||||
"deduplication_criteria_2": "ؚدد Ø¨ŲØ§ŲاØĒ EXIF",
|
||||
"deduplication_info": "Ų
ØšŲŲŲ
اØĒ ØĨŲØēØ§ØĄ Ø§ŲØ¨ŲØ§ŲØ§ØĒ اŲŲ
ŲØąØąØŠ",
|
||||
"deduplication_info_description": "ŲØĒØØ¯ŲØ¯ Ø§ŲØŖØĩŲŲ Ų
ØŗØ¨ŲØ§ ØĒŲŲØ§ØĻŲØ§ ŲØĨØ˛Ø§ŲØŠ Ø§ŲØĒŲØąØ§ØąØ§ØĒ بŲŲ
ŲØ§ØĒ ŲØ¨ŲØąØŠØ ŲŲØ¸Øą ØĨŲŲ:",
|
||||
"default_locale": "اŲŲØēØŠ Ø§ŲØ§ŲØĒØąØ§ØļŲØŠ",
|
||||
"default_locale_description": "ØĒŲØŗŲŲ Ø§ŲØĒŲØ§ØąŲØŽ ŲØ§ŲØŖØąŲØ§Ų
Ø¨ŲØ§ØĄŲ ØšŲŲ ŲØēØŠ اŲŲ
ØĒØĩŲØ Ø§ŲØŽØ§Øĩ بŲ",
|
||||
"delete": "ØØ°Ų",
|
||||
"delete_action_confirmation_message": "ŲŲ Ø§ŲØĒ Ų
ØĒØŖŲØ¯ Ų
Ų ØØ°Ų ŲØ°Ø§ اŲŲ
ŲŲØ ŲØ°Ø§ ØŗØ¤Ø¯Ų Ø§ŲŲ ŲŲŲ Ø§ŲŲ
ŲŲ Ø§ŲŲ ØŗŲØŠ Ų
ŲŲ
ŲØ§ØĒ Ø§ŲØŽØ§Ø¯Ų
ŲØŗŲØĒŲ
Ø§Ø´ØšØ§ØąŲ Ø§Ų ŲŲØĒ ØĒØąŲØ¯ ØØ°ŲŲ ØšŲŲ Ø§ŲØŦŲØ§Ø˛",
|
||||
"delete_action_prompt": "ØĒŲ
ØØ°Ų {count}",
|
||||
@@ -1004,6 +1007,8 @@
|
||||
"editor_edits_applied_success": "ØĒŲ
ØĒØˇØ¨ŲŲ Ø§ŲØĒØšØ¯ŲŲØ§ØĒ Ø¨ŲØŦاØ",
|
||||
"editor_flip_horizontal": "اŲŲØ¨ ØŖŲŲŲŲØ§",
|
||||
"editor_flip_vertical": "اŲŲØ¨ ØšŲ
ŲØ¯ŲŲØ§",
|
||||
"editor_handle_corner": "{corner, select, top_left {ØŖØšŲŲ Ø§ŲŲØŗØ§Øą} top_right {ØŖØšŲŲ Ø§ŲŲŲ
ŲŲ} bottom_left {ØŖØŗŲŲ Ø§ŲŲØŗØ§Øą} bottom_right {ØŖØŗŲŲ Ø§ŲŲŲ
ŲŲ} other {ØŖØŽØąŲ}} corner handle",
|
||||
"editor_handle_edge": "{edge, select, top {ØŖØšŲŲ} bottom {ØŖØŗŲŲ} left {ŲØŗØ§Øą} right {ŲŲ
ŲŲ} other {ØŖØŽØąŲ}} edge handle",
|
||||
"editor_orientation": "اØĒØŦاŲ",
|
||||
"editor_reset_all_changes": "اؚاد؊ Ø¸Ø¨Øˇ Ø§ŲØĒØēŲبਧØĒ",
|
||||
"editor_rotate_left": "ØŖØ¯Øą 90° ØšŲØŗ اØĒØŦØ§Ų ØšŲØ§ØąØ¨ Ø§ŲØŗØ§ØšØŠ",
|
||||
@@ -1069,6 +1074,7 @@
|
||||
"failed_to_update_notification_status": "ŲØ´Ų ŲŲ ØĒØØ¯ŲØĢ ØØ§ŲØŠ Ø§ŲØĨØ´ØšØ§Øą",
|
||||
"incorrect_email_or_password": "Ø¨ØąŲØ¯ ØŖŲ ŲŲŲ
ØŠ Ų
ØąŲØą ØēŲØą ØĩØŲØØŠ",
|
||||
"library_folder_already_exists": "Ų
ØŗØ§Øą Ø§ŲØ§ØŗØĒŲØąØ§Ø¯ Ų
ŲØŦŲØ¯ باŲŲØšŲ.",
|
||||
"page_not_found": "Ø§ŲØĩŲØØŠ ØēŲØą Ų
ŲØŦŲØ¯ØŠ",
|
||||
"paths_validation_failed": "ŲØ´Ų ŲŲ Ø§ŲØĒØŲŲ Ų
Ų {paths, plural, one {# Ų
ØŗØ§Øą} other {# Ų
ØŗØ§ØąØ§ØĒ}}",
|
||||
"profile_picture_transparent_pixels": "ŲØ§ ŲŲ
ŲŲ ØŖŲ ØĒØØĒŲŲ ØĩŲØą Ø§ŲŲ
ŲŲ Ø§ŲØ´ØŽØĩŲ ØšŲŲ ØŖØŦØ˛Ø§ØĄ/Ø¨ŲØŗŲاØĒ Ø´ŲØ§ŲØŠ. ŲØąØŦŲ Ø§ŲØĒŲØ¨ŲØą Ų/ØŖŲ ØĒØØąŲŲ Ø§ŲØĩŲØąØŠ.",
|
||||
"quota_higher_than_disk_size": "ŲŲØ¯ ŲŲ
ØĒ بØĒØšŲŲŲ ØØĩØŠ ŲØŗØ¨ŲØŠ ØŖØšŲŲ Ų
Ų ØØŦŲ
اŲŲØąØĩ",
|
||||
@@ -1168,6 +1174,7 @@
|
||||
"exif_bottom_sheet_people": "اŲŲØ§Øŗ",
|
||||
"exif_bottom_sheet_person_add_person": "اØļŲ Ø§ØŗŲ
ا",
|
||||
"exit_slideshow": "ØŽØąŲØŦ Ų
Ų Ø§ŲØšØąØļ Ø§ŲØĒŲØ¯ŲŲ
Ų",
|
||||
"expand": "ØĒŲØŗØšØŠ",
|
||||
"expand_all": "ØĒŲØŗŲØš اŲŲŲ",
|
||||
"experimental_settings_new_asset_list_subtitle": "ØŖØšŲ
Ø§Ų ØŦØ§ØąŲØŠ",
|
||||
"experimental_settings_new_asset_list_title": "ØĒŲ
ŲŲŲ Ø´Ø¨ŲØŠ Ø§ŲØĩŲØą Ø§ŲØĒØŦØąŲØ¨ŲØŠ",
|
||||
@@ -1212,6 +1219,7 @@
|
||||
"filter_description": "Ø´ØąŲØˇ ØĒØĩŲŲØŠ Ø§ŲØŖØĩŲŲ Ø§ŲŲ
ØŗØĒŲØ¯ŲØŠ",
|
||||
"filter_people": "ØĒØĩŲŲØŠ Ø§ŲØ§Ø´ØŽØ§Øĩ",
|
||||
"filter_places": "ØĒØĩŲŲØŠ Ø§ŲØ§Ų
اŲŲ",
|
||||
"filter_tags": "ØĒØĩŲŲØŠ Ø§ŲØšŲاŲ
اØĒ",
|
||||
"filters": "Ø§ŲØĒØĩŲŲØ§ØĒ",
|
||||
"find_them_fast": "ŲŲ
ŲŲŲ Ø§ŲØšØĢŲØą ØšŲŲŲØ§ Ø¨ØŗØąØšØŠ Ø¨Ø§ŲØ§ØŗŲ
Ų
Ų ØŽŲØ§Ų Ø§ŲØ¨ØØĢ",
|
||||
"first": "Ø§ŲØ§ŲŲ",
|
||||
@@ -1642,6 +1650,7 @@
|
||||
"online": "Ų
ØĒØĩŲ",
|
||||
"only_favorites": "اŲŲ
ŲØļŲØŠ ŲŲØˇ",
|
||||
"open": "ŲØĒØ",
|
||||
"open_calendar": "Ø§ŲØĒØ Ø§ŲØąØ˛ŲØ§Ų
ØŠ",
|
||||
"open_in_map_view": "ŲØĒØ ŲŲ ØšØąØļ Ø§ŲØŽØąŲØˇØŠ",
|
||||
"open_in_openstreetmap": "ŲØĒØ ŲŲ OpenStreetMap",
|
||||
"open_the_search_filters": "Ø§ŲØĒØ Ų
ØąØ´ØØ§ØĒ Ø§ŲØ¨ØØĢ",
|
||||
@@ -1801,9 +1810,8 @@
|
||||
"rate_asset": "ØĒŲŲŲŲ
Ø§ŲØ§ØĩŲ",
|
||||
"rating": "ØĒŲŲŲŲ
ŲØŦŲ
Ų",
|
||||
"rating_clear": "Ų
ØŗØ Ø§ŲØĒŲŲŲŲ
",
|
||||
"rating_count": "{count, plural, one {# ŲØŦŲ
ØŠ} other {# ŲØŦŲŲ
}}",
|
||||
"rating_count": "{count, plural, =0 {Unrated} one {# ŲØŦŲ
ØŠ} other {# ŲØŦŲŲ
}}",
|
||||
"rating_description": "âĢâØ§ØšØąØļ ØĒŲŲŲŲ
EXIF ŲŲ ŲŲØØŠ اŲŲ
ØšŲŲŲ
اØĒ",
|
||||
"rating_set": "ØĒŲ
ØĒØØ¯Ųد Ø§ŲØĒØĩŲŲŲ {rating, plural, one {# ŲØŦŲ
ØŠ} other {# ŲØŦŲŲ
}}",
|
||||
"reaction_options": "ØŽŲØ§ØąØ§ØĒ ØąØ¯ اŲŲØšŲ",
|
||||
"read_changelog": "ŲØąØ§ØĄØŠ ØŗØŦŲ Ø§ŲØĒØēŲŲØą",
|
||||
"readonly_mode_disabled": "ØĒŲ
ØĒØšØˇŲŲ ŲØļØš اŲŲØąØ§ØĄØŠ ŲŲØˇ",
|
||||
@@ -1875,7 +1883,10 @@
|
||||
"reset_pin_code_success": "ØĒŲ
اؚاد؊ ØĒØšŲŲŲ ØąŲ
Ø˛ اŲPIN Ø¨ŲØŦاØ",
|
||||
"reset_pin_code_with_password": "ŲŲ
ŲŲŲ Ø¯Ø§ØĻŲ
ا اؚاد؊ ØĒØšŲŲŲ ØąŲ
Ø˛ اŲPIN Ø§ŲØŽØ§Øĩ Ø¨Ų ØšŲ ØˇØąŲŲ ŲŲŲ
ØŠ اŲŲ
ØąŲØą Ø§Ų؎اØĩØŠ بŲ",
|
||||
"reset_sqlite": "ØĨؚاد؊ ØĒØšŲŲŲ ŲØ§ØšØ¯ØŠ Ø¨ŲØ§ŲاØĒ SQLite",
|
||||
"reset_sqlite_confirmation": "ŲŲ ØŖŲØĒ Ų
ØĒØŖŲØ¯ Ų
Ų ØąØēبØĒŲ ŲŲ ØĨؚاد؊ ØļØ¨Øˇ ŲØ§ØšØ¯ØŠ Ø¨ŲØ§ŲاØĒ SQLiteØ ØŗØĒØØĒØ§ØŦ ØĨŲŲ ØĒØŗØŦŲŲ Ø§ŲØŽØąŲØŦ ØĢŲ
ØĒØŗØŦŲŲ Ø§ŲØ¯ØŽŲŲ Ų
ØąØŠ ØŖØŽØąŲ ŲØĨؚاد؊ Ų
Ø˛Ø§Ų
ŲØŠ Ø§ŲØ¨ŲØ§ŲØ§ØĒ",
|
||||
"reset_sqlite_clear_app_data": "Ų
ØŗØ Ø§ŲØ¨ŲØ§ŲØ§ØĒ",
|
||||
"reset_sqlite_confirmation": "ŲŲ ØŖŲØĒ Ų
ØĒØŖŲØ¯ Ų
Ų ØąØēبØĒŲ ŲŲ ØØ°Ų ØļØ¨Øˇ Ø¨ŲØ§ŲاØĒ Ø§ŲØĒØˇØ¨ŲŲØ ØŗŲØ¤Ø¯Ų ŲØ°Ø§ ØĨŲŲ ØĨØ˛Ø§ŲØŠ ØŦŲ
ب𠨧بĨؚداداØĒ ŲØĒØŗØŦŲŲ ØŽØąŲØŦŲ.",
|
||||
"reset_sqlite_confirmation_note": "Ų
ŲØ§ØØ¸ØŠ: ØŗŲØĒØšŲŲ ØšŲŲŲ ØĨؚاد؊ ØĒØ´ØēŲŲ Ø§ŲØĒØˇØ¨ŲŲ Ø¨ØšØ¯ اŲŲ
ØŗØ.",
|
||||
"reset_sqlite_done": "ØĒŲ
Ų
ØŗØ Ø¨ŲØ§ŲاØĒ Ø§ŲØĒØˇØ¨ŲŲ. ŲØąØŦŲ ØĨؚاد؊ ØĒØ´ØēŲŲ ØĒØˇØ¨ŲŲ Immich ŲØĒØŗØŦŲŲ Ø§ŲØ¯ØŽŲŲ Ų
ØąØŠ ØŖØŽØąŲ.",
|
||||
"reset_sqlite_success": "ØĒŲ
ØĨؚاد؊ ØĒØšŲŲŲ ŲØ§ØšØ¯ØŠ Ø¨ŲØ§ŲاØĒ SQLite Ø¨ŲØŦاØ",
|
||||
"reset_to_default": "ØĨؚاد؊ Ø§ŲØĒØšŲŲŲ ØĨŲŲ Ø§ŲØ§ŲØĒØąØ§ØļŲ",
|
||||
"resolution": "Ø¯ŲØŠ",
|
||||
@@ -1903,6 +1914,7 @@
|
||||
"saved_settings": "ØĒŲ
ØŲظ Ø§ŲØĨؚداداØĒ",
|
||||
"say_something": "ŲŲ Ø´ŲØĻŲØ§",
|
||||
"scaffold_body_error_occurred": "ØØ¯ØĢ ØŽØˇØŖ",
|
||||
"scaffold_body_error_unrecoverable": "ØØ¯ØĢ ØŽØˇØŖ ŲØ§ ŲŲ
ŲŲ ØĨØĩŲØ§ØŲ. ŲØąØŦŲ Ų
Ø´Ø§ØąŲØŠ ØĒŲØ§ØĩŲŲ Ø§ŲØŽØˇØŖ ŲØĒØŗŲØŗŲ Ø§ŲØŖØŽØˇØ§ØĄ ØšŲŲ Discord ØŖŲ GitHub ØØĒŲ ŲØĒŲ
ŲŲ Ų
Ų Ų
ØŗØ§ØšØ¯ØĒŲ. ØĨذا ØˇŲŲØ¨ Ų
ŲŲ Ø°ŲŲØ ŲŲ
ŲŲŲ Ų
ØŗØ Ø¨ŲØ§ŲاØĒ Ø§ŲØĒØˇØ¨ŲŲ ØŖØ¯ŲØ§Ų.",
|
||||
"scan": "Ø¨ØØĢ",
|
||||
"scan_all_libraries": "ŲØØĩ ŲŲ Ø§ŲŲ
ŲØĒØ¨Ø§ØĒ",
|
||||
"scan_library": "Ų
ØŗØ",
|
||||
@@ -1938,6 +1950,7 @@
|
||||
"search_filter_ocr": "Ø§ŲØ¨ØØĢ ØšŲ ØˇØąŲŲ Ø§ŲØĒØšØąŲ Ø§ŲØ¨ØĩØąŲ ØšŲŲ Ø§ŲØØąŲŲ",
|
||||
"search_filter_people_title": "ا؎ØĒØą Ø§ŲØ§Ø´ØŽØ§Øĩ",
|
||||
"search_filter_star_rating": "ØĒŲŲŲŲ
اŲŲØŦŲŲ
",
|
||||
"search_filter_tags_title": "âĒØĒØØ¯ŲØ¯ Ø§ŲØšŲاŲ
اØĒ",
|
||||
"search_for": "Ø§ŲØ¨ØØĢ ØšŲ",
|
||||
"search_for_existing_person": "Ø§ŲØ¨ØØĢ ØšŲ Ø´ØŽØĩ Ų
ŲØŦŲØ¯",
|
||||
"search_no_more_result": "ŲØ§ ØĒŲØŦد ŲØĒØ§ØĻØŦ اØļاŲŲØŠ",
|
||||
@@ -2017,6 +2030,9 @@
|
||||
"set_profile_picture": "ØĒØØ¯Ųد ØĩŲØąØŠ اŲŲ
ŲŲ Ø§ŲØ´ØŽØĩŲ",
|
||||
"set_slideshow_to_fullscreen": "ØĒØØ¯Ųد ØšØąØļ Ø§ŲØ´ØąØ§ØĻØ ØšŲŲ ŲØļØš Ų
ŲØĄ Ø§ŲØ´Ø§Ø´ØŠ",
|
||||
"set_stack_primary_asset": "ØĒØšŲŲŲ ŲØŖØĩŲ Ø§ØŗØ§ØŗŲ",
|
||||
"setting_image_navigation_enable_subtitle": "ŲŲ ØØ§Ų ØĒŲ
Ø§ŲØĒŲØšŲŲØ ŲŲ
ŲŲŲ Ø§ŲØ§ŲØĒŲØ§Ų ØĨŲŲ Ø§ŲØĩŲØąØŠ Ø§ŲØŗØ§Ø¨ŲØŠ ØŖŲ Ø§ŲØĒØ§ŲŲØŠ ØšŲ ØˇØąŲŲ Ø§ŲŲŲØą ØšŲŲ Ø§ŲØąØ¨Øš Ø§ŲØŖŲØŗØą ØŖŲ Ø§ŲØąØ¨Øš Ø§ŲØŖŲŲ
Ų Ų
Ų Ø§ŲØ´Ø§Ø´ØŠ.",
|
||||
"setting_image_navigation_enable_title": "اŲŲŲØą ŲŲØĒŲŲŲ",
|
||||
"setting_image_navigation_title": "Ø§ŲØĒŲŲŲ Ø¨ŲŲ Ø§ŲØĩŲØą",
|
||||
"setting_image_viewer_help": "ŲŲŲŲ
ØšØ§ØąØļ Ø§ŲØĒŲØ§ØĩŲŲ Ø¨ØĒØŲ
ŲŲ Ø§ŲØĩŲØąØŠ اŲŲ
ØĩØēØąØŠ Ø§ŲØĩØēŲØąØŠ ØŖŲŲØ§Ų Ø ØĢŲ
ŲŲŲŲ
بØĒØŲ
ŲŲ Ø§ŲŲ
ؚاŲŲØŠ Ų
ØĒŲØŗØˇØŠ Ø§ŲØØŦŲ
(ØĨذا ØĒŲ
ØĒŲ
ŲŲŲŲØ§) Ø ŲŲŲŲŲ
ØŖØŽŲØąŲا بØĒØŲ
ŲŲ Ø§ŲØŖØĩŲ (ØĨذا ØĒŲ
ØĒŲ
ŲŲŲŲ).",
|
||||
"setting_image_viewer_original_subtitle": "ØĒŲ
ŲŲŲ ØĒØŲ
ŲŲ Ø§ŲØĩŲØąØŠ اŲŲØ§Ų
ŲØŠ Ø§ŲØ¯ŲØŠ Ø§ŲØŖØĩŲŲØŠ (ŲØ¨ŲØąØŠ!).ØĒØšØˇŲŲ ŲØĒŲŲŲŲ Ø§ØŗØĒ؎داŲ
Ø§ŲØ¨ŲØ§ŲØ§ØĒ (ŲŲ Ų
Ų Ø§ŲØ´Ø¨ŲØŠ ŲØšŲŲ Ø°Ø§ŲØąØŠ Ø§ŲØĒØŽØ˛ŲŲ Ø§ŲŲ
Ø¤ŲØĒ ŲŲØŦŲØ§Ø˛).",
|
||||
"setting_image_viewer_original_title": "ØĒØŲ
ŲŲ Ø§ŲØĩŲØąØŠ Ø§ŲØŖØĩŲŲØŠ",
|
||||
@@ -2183,6 +2199,7 @@
|
||||
"support": "Ø§ŲØ¯ØšŲ
",
|
||||
"support_and_feedback": "Ø§ŲØ¯ØšŲ
ŲØ§ŲØĒØšŲŲŲØ§ØĒ",
|
||||
"support_third_party_description": "ØĒŲ
ØØ˛Ų
ØĒØĢØ¨ŲØĒ immich Ø§ŲØŽØ§Øĩ Ø¨Ų Ø¨ŲØ§ØŗØˇØŠ ØŦŲØŠ ØŽØ§ØąØŦŲØŠ. ŲØ¯ ØĒŲŲŲ Ø§ŲŲ
Ø´ŲŲØ§ØĒ Ø§ŲØĒŲ ØĒŲØ§ØŦŲŲØ§ ŲØ§ØŦŲ
ØŠ ØšŲ ŲØ°Ų Ø§ŲØØ˛Ų
ØŠØ ŲØ°Ø§ ŲØąØŦŲ ØˇØąØ Ø§ŲŲ
Ø´ŲŲØ§ØĒ Ų
ØšŲŲ
ŲŲ Ø§ŲŲ
ŲØ§Ų
Ø§ŲØŖŲŲ Ø¨Ø§ØŗØĒ؎داŲ
Ø§ŲØąŲØ§Ø¨Øˇ ØŖØ¯ŲØ§Ų.",
|
||||
"supporter": "داؚŲ
",
|
||||
"swap_merge_direction": "ØĒبدŲŲ Ø§ØĒØŦØ§Ų Ø§ŲØ¯Ų
ØŦ",
|
||||
"sync": "Ų
Ø˛Ø§Ų
ŲØŠ",
|
||||
"sync_albums": "Ų
Ø˛Ø§Ų
ŲØŠ Ø§ŲØ§ŲبŲŲ
اØĒ",
|
||||
@@ -2294,6 +2311,7 @@
|
||||
"unstack_action_prompt": "ØĒŲ
Ø§Ø˛Ø§ŲØŠ ØĒŲØ¯ŲØŗ {count}",
|
||||
"unstacked_assets_count": "ØĒŲ
ØĨØŽØąØ§ØŦ {count, plural, one {# Ø§ŲØŖØĩŲ} other {# Ø§ŲØŖØĩŲŲ}} Ų
Ų Ø§ŲØĒŲØ¯ŲØŗ",
|
||||
"unsupported_field_type": "ŲŲØš ØŲŲ ØēŲØą Ų
دؚŲŲ
",
|
||||
"unsupported_file_type": "ŲØ§ ŲŲ
ŲŲ ØąŲØš Ø§ŲŲ
ŲŲ {file} ŲØŖŲ Ųب𠨧ŲŲ
ŲŲ {type} ØēŲØą Ų
دؚŲŲ
.",
|
||||
"untagged": "ØēŲØą Ų
ŲØšŲŲŲŲŲ
",
|
||||
"untitled_workflow": "ØŽØˇØŠ ØŗŲØą ØšŲ
Ų Ø¨Ø¯ŲŲ ØšŲŲØ§Ų",
|
||||
"up_next": "Ø§ŲØĒØ§ŲŲ",
|
||||
@@ -2320,6 +2338,8 @@
|
||||
"url": "ØšŲŲØ§Ų URL",
|
||||
"usage": "Ø§ŲØ§ØŗØĒ؎داŲ
",
|
||||
"use_biometric": "Ø§ØŗØĒ؎دŲ
Ø§ŲØ¨Ø§ŲŲŲ
ØĒØąŲ",
|
||||
"use_browser_locale": "Ø§ØŗØĒ؎دŲ
ŲØēŲ ŲŲŲ
ØĒØĩŲØ",
|
||||
"use_browser_locale_description": "ØĒŲØŗŲŲ Ø§ŲØĒŲØ§ØąŲØŽ ŲØ§ŲØŖŲŲØ§ØĒ ŲØ§ŲØŖØąŲØ§Ų
ŲŲŲŲØ§ ŲØĨؚداداØĒ اŲŲØēØŠ ŲŲ Ų
ØĒØĩŲØŲ",
|
||||
"use_current_connection": "Ø§ØŗØĒ؎دŲ
Ø§ŲØ§ØĒØĩØ§Ų Ø§ŲØØ§ŲŲ",
|
||||
"use_custom_date_range": "Ø§ØŗØĒ؎دŲ
اŲŲØˇØ§Ų Ø§ŲØ˛Ų
ŲŲ Ø§ŲŲ
ØŽØĩØĩ Ø¨Ø¯ŲØ§Ų Ų
Ų Ø°ŲŲ",
|
||||
"user": "Ų
ØŗØĒ؎دŲ
",
|
||||
|
||||
@@ -104,6 +104,8 @@
|
||||
"image_preview_description": "ĐŅдаŅŅŅ ŅŅŅŅĐ´ĐŊŅĐŗĐ° ĐŋаĐŧĐĩŅŅ Đˇ вŅдаĐģĐĩĐŊŅĐŧŅ ĐŧĐĩŅадаĐŊŅĐŧŅ, вŅĐēаŅŅŅŅĐžŅваĐĩŅŅа ĐŋŅŅ ĐŋŅĐ°ĐŗĐģŅдСĐĩ аŅОйĐŊĐ°ĐŗĐ° ŅŅŅŅŅŅŅ Ņ Đ´ĐģŅ ĐŧаŅŅĐŊĐŊĐ°ĐŗĐ° ĐŊавŅŅаĐŊĐŊŅ",
|
||||
"image_preview_quality_description": "Đ¯ĐēаŅŅŅ ĐŋŅаŅĐ˛Ņ Đ°Đ´ 1 да 100. ЧŅĐŧ вŅŅŅĐš, ŅŅĐŧ ĐģĐĩĐŋŅ, аĐģĐĩ ĐŋŅŅ ĐŗŅŅŅĐŧ ŅŅваŅаŅŅŅа ŅаКĐģŅ ĐąĐžĐģŅŅĐ°ĐŗĐ° ĐŋаĐŧĐĩŅŅ Ņ ĐŧĐžĐļа СĐŊŅСŅŅŅа Ņ
ŅŅĐēаŅŅŅ Đ˛ĐžĐ´ĐŗŅĐēŅ ĐŋŅŅĐēĐģадаĐŊĐŊŅ. ĐŅŅаĐŊĐžŅĐēа ĐŊŅСĐēĐ°ĐŗĐ° СĐŊаŅŅĐŊĐŊŅ ĐŧĐžĐļа ĐŋаŅĐŋĐģŅваŅŅ ĐŊа ŅĐēаŅŅŅ ĐŧаŅŅĐŊĐŊĐ°ĐŗĐ° ĐŊавŅŅаĐŊĐŊŅ.",
|
||||
"image_preview_title": "ĐаĐģĐ°Đ´Ņ ĐŋаĐŋŅŅŅĐ´ĐŊŅĐŗĐ° ĐŋŅĐ°ĐŗĐģŅĐ´Ņ",
|
||||
"image_progressive": "ĐŅĐ°ĐŗŅŅŅŅŅĐŊŅ",
|
||||
"image_progressive_description": "ĐŅŅĐ˛Ņ Đˇ ĐŋŅĐ°ĐŗŅŅŅŅŅĐŊŅĐŧ ĐēОдаваĐŊĐŊĐĩĐŧ ĐˇĐ°ĐŗŅŅĐļаŅŅŅа Ņ
ŅŅŅŅĐš, ĐŋаŅŅŅĐŋОва ĐŋаĐģŅĐŋŅаĐĩŅŅа ŅĐēаŅŅŅ. ĐаĐģада ĐŊĐĩ ŅĐŋĐģŅваĐĩ ĐŊа вŅŅĐ˛Ņ Ņ ŅаŅĐŧаŅĐĩ WebP.",
|
||||
"image_quality": "Đ¯ĐēаŅŅŅ",
|
||||
"image_resolution": "РаСдСŅĐģŅĐģŅĐŊаŅŅŅ",
|
||||
"image_resolution_description": "ĐĐžĐģŅŅ Đ˛ŅŅĐžĐēĐ°Ņ ŅаСдСŅĐģŅĐģŅĐŊаŅŅŅ Đ´Đ°ĐˇĐ˛Đ°ĐģŅĐĩ СаŅ
аваŅŅ ĐąĐžĐģŅŅ Đ´ŅŅаĐģŅŅ, аĐģĐĩ ĐŋаŅŅайŅĐĩ йОĐģŅŅ ŅаŅŅ Đ´ĐģŅ ĐēадаваĐŊĐŊŅ, ĐŋŅŅвОдСŅŅŅ Đ´Đ° ĐŋавŅĐģŅŅваĐŊĐŊŅ ĐŋаĐŧĐĩŅŅ ŅаКĐģĐ°Ņ Ņ ĐŧĐžĐļа СĐŊŅСŅŅŅ Ņ
ŅŅĐēаŅŅŅ Đ˛ĐžĐ´ĐŗŅĐēŅ Đ´Đ°Đ´Đ°ŅĐēŅ.",
|
||||
@@ -120,6 +122,7 @@
|
||||
"job_settings_description": "ĐŅŅаваŅŅ ĐŊаĐģадаĐŧŅ ĐŋаŅаĐģĐĩĐģŅĐŊĐ°ĐŗĐ° вŅĐēаĐŊаĐŊĐŊŅ ĐˇĐ°Đ´Đ°ĐŊĐŊŅŅ",
|
||||
"jobs_delayed": "{jobCount, plural, other {# адĐēĐģадСĐĩĐŊа}}",
|
||||
"jobs_failed": "{jobCount, plural, other {# ĐŊĐĩ вŅĐēаĐŊаĐģаŅŅ}}",
|
||||
"jobs_over_time": "ĐŅаŅŅĐē аĐŋŅаŅĐžŅĐēŅ",
|
||||
"library_created": "ĐĄŅвОŅаĐŊа ĐąŅĐąĐģŅŅŅŅĐēа: {library}",
|
||||
"library_deleted": "ĐŅĐąĐģŅŅŅŅĐēа вŅдаĐģĐĩĐŊа",
|
||||
"library_details": "ĐаŅаĐŧĐĩŅŅŅ ĐąŅĐąĐģŅŅŅŅĐēŅ",
|
||||
@@ -160,8 +163,27 @@
|
||||
"machine_learning_facial_recognition_model_description": "ĐадŅĐģŅ ĐŋĐĩŅаĐģŅŅаĐŊŅ Ņ ĐŋаŅадĐēŅ ŅĐąŅваĐŊĐŊŅ ŅŅ
ĐŋаĐŧĐĩŅŅ. ĐĐžĐģŅŅŅŅ ĐŧадŅĐģŅ ĐŋавОĐģŅĐŊĐĩĐš Ņ Đ˛ŅĐēаŅŅŅŅĐžŅваŅŅŅ ĐąĐžĐģŅŅ ĐŋаĐŧŅŅŅ, аĐģĐĩ даŅŅŅ ĐģĐĩĐŋŅŅŅ Đ˛ŅĐŊŅĐēŅ. ĐвŅŅĐŊŅŅĐĩ ŅĐ˛Đ°ĐŗŅ, ŅŅĐž ĐŋаŅĐģŅ ĐˇĐŧĐĩĐŊŅ ĐŧадŅĐģŅ ŅŅŅйа СĐŊĐžŅ ĐˇĐ°ĐŋŅŅŅŅŅŅ ĐˇĐ°Đ´Đ°ĐŊĐŊĐĩ ŅаŅĐŋаСĐŊаваĐŊĐŊŅ ŅваŅĐ°Ņ Đ´ĐģŅ ŅŅŅŅ
вŅдаŅŅŅаŅ.",
|
||||
"machine_learning_facial_recognition_setting": "ĐŖĐēĐģŅŅŅŅŅ ŅаŅĐŋаСĐŊаваĐŊĐŊĐĩ ŅваŅаŅ",
|
||||
"machine_learning_facial_recognition_setting_description": "ĐаĐģŅ Đ°Đ´ĐēĐģŅŅаĐŊа, вŅдаŅŅŅŅ ĐŊĐĩ ĐąŅĐ´ŅŅŅ ĐēадаваŅŅа Đ´ĐģŅ ŅаŅĐŋаСĐŊаваĐŊĐŊŅ ŅваŅаŅ, Ņ ĐŊĐĩ ĐąŅдСĐĩ СаĐŋаŅĐŊŅŅŅа ŅаСдСĐĩĐģ \"ĐŅдСŅ\" ĐŊа ŅŅаŅĐžĐŊŅŅ \"ĐĐŗĐģŅĐ´\".",
|
||||
"machine_learning_max_detection_distance": "ĐаĐēŅŅĐŧаĐģŅĐŊĐ°Ņ Đ°Đ´ĐģĐĩĐŗĐģаŅŅŅ Đ˛ŅŅŅĐģĐĩĐŊĐŊŅ",
|
||||
"machine_learning_max_detection_distance_description": "ĐаĐēŅŅĐŧаĐģŅĐŊĐ°Ņ ŅОСĐŊŅŅа ĐŋаĐŧŅĐļ двŅĐŧа вŅŅваĐŧŅ, ŅĐēŅŅ ĐģŅŅаŅŅа Đ´ŅĐąĐģŅĐēаŅаĐŧŅ, ŅĐēĐģадаĐĩ ад 0,001 да 0,1. ĐĐžĐģŅŅ Đ˛ŅŅĐžĐēŅŅ ĐˇĐŊаŅŅĐŊĐŊŅ Đ´Đ°ĐˇĐ˛ĐžĐģŅŅŅ Đ˛ŅŅвŅŅŅ ĐąĐžĐģŅŅ Đ´ŅĐąĐģŅĐēаŅаŅ, аĐģĐĩ ĐŧĐžĐŗŅŅŅ ĐŋŅŅвĐĩŅŅŅ Đ´Đ° ĐŊŅĐŋŅавŅĐģŅĐŊŅŅ
вŅŅŅĐģĐĩĐŊĐŊŅŅ.",
|
||||
"machine_learning_max_recognition_distance": "ĐаŅĐžĐŗ ŅаСĐŋаСĐŊаваĐŊĐŊŅ",
|
||||
"machine_learning_max_recognition_distance_description": "ĐаĐēŅŅĐŧаĐģŅĐŊаĐĩ адŅОСĐŊĐĩĐŊĐŊĐĩ ĐŋаĐŧŅĐļ двŅĐŧа аŅОйаĐŧŅ, ŅĐēŅŅ ĐŧĐžĐļĐŊа ĐģŅŅŅŅŅ Đ°Đ´ĐŊŅĐŧ ŅаĐģавĐĩĐēаĐŧ (Ņ Đ´ŅŅĐŋаСОĐŊĐĩ ад 0 да 2).ĐĐŊŅĐļŅĐŊĐŊĐĩ ĐŗŅŅĐ°ĐŗĐ° ĐŋаŅаĐŧĐĩŅŅŅ ĐŧĐžĐļа ĐŋŅадŅŅ
ŅĐģŅŅŅ ŅаŅĐŋаСĐŊаĐŊĐŊĐĩ двŅŅ
ĐģŅдСĐĩĐš ŅĐē адĐŊĐ°ĐŗĐž Ņ ŅĐ°ĐŗĐž Đļ ŅаĐģавĐĩĐēа, а ĐŋавŅŅŅĐŊĐŊĐĩ - ŅĐē двŅŅ
ŅОСĐŊŅŅ
ĐģŅдСĐĩĐš. ĐаКŅĐĩ ĐŊа ŅваСĐĩ, ŅŅĐž ĐŋŅаŅŅĐĩĐš ай'ŅĐ´ĐŊаŅŅ Đ´Đ˛ŅŅ
ĐģŅдСĐĩĐš, ŅŅĐŧ ĐŋадСŅĐģŅŅŅ Đ°Đ´ĐŊĐ°ĐŗĐž ŅаĐģавĐĩĐēа ĐŊа дваŅŅ
, ŅаĐŧŅ Đŋа ĐŧĐ°ĐŗŅŅĐŧаŅŅŅ Đ˛ŅĐąŅŅаКŅĐĩ ĐŧĐĩĐŊŅŅ ĐŋаŅĐžĐŗ.",
|
||||
"machine_learning_min_detection_score": "ĐŅĐŊŅĐŧаĐģŅĐŊŅ ĐŋаŅĐžĐŗ ŅаСĐŋаСĐŊаваĐŊĐŊŅ",
|
||||
"machine_learning_min_detection_score_description": "ĐŅĐŊŅĐŧаĐģŅĐŊŅ ĐŋаŅĐžĐŗ Đ´ĐģŅ Đ˛ŅŅŅĐģĐĩĐŊĐŊŅ Đ°ŅĐžĐąŅ (ад 0 да 1). ĐŅĐļŅĐšŅаĐĩ СĐŊаŅŅĐŊĐŊĐĩ даСвОĐģŅŅŅ ĐˇĐŊаŅ
ОдСŅŅŅ ĐąĐžĐģŅŅ Đ°ŅОй, аĐģĐĩ ĐŧĐžĐļа ĐŋŅŅвĐĩŅŅŅ Đ´Đ° ŅĐģĐļŅвŅŅ
ŅĐŋŅаŅĐžŅваĐŊĐŊŅŅ.",
|
||||
"machine_learning_min_recognized_faces": "ĐŅĐŊŅĐŧŅĐŧ ŅаСĐŋаСĐŊаĐŊŅŅ
ŅваŅаŅ",
|
||||
"machine_learning_min_recognized_faces_description": "ĐŅĐŊŅĐŧаĐģŅĐŊĐ°Ņ ĐēĐžĐģŅĐēаŅŅŅ ŅаŅĐŋаСĐŊаĐŊŅŅ
ŅваŅĐ°Ņ Đ´ĐģŅ ŅŅваŅŅĐŊĐŊŅ Đ°ŅОйŅ. ĐавŅĐģŅŅŅĐŊĐŊĐĩ ĐŗŅŅĐ°ĐŗĐ° ĐŋаŅаĐŧĐĩŅŅа ŅОйŅŅŅ ŅаŅĐŋаСĐŊаĐŊĐŊĐĩ аŅОй йОĐģŅŅ Đ´Đ°ĐēĐģадĐŊŅĐŧ, аĐģĐĩ ĐŋŅŅ ĐŗŅŅŅĐŧ ĐŋавŅĐģŅŅваĐĩŅŅа вĐĩŅĐ°ĐŗĐžĐ´ĐŊаŅŅŅ ŅĐ°ĐŗĐž, ŅŅĐž ŅĐ˛Đ°Ņ ĐŊĐĩ ĐąŅдСĐĩ ĐŋŅŅŅвОĐĩĐŊŅ Đ°ŅОйĐĩ.",
|
||||
"machine_learning_ocr": "РаСĐŋаСĐŊаваĐŊĐŊĐĩ ŅŅĐēŅŅŅ (OCR)",
|
||||
"machine_learning_ocr_description": "ĐŅĐēаŅŅŅŅĐžŅваКŅĐĩ ĐŧаŅŅĐŊĐŊаĐĩ ĐŊавŅŅаĐŊĐŊĐĩ Đ´ĐģŅ ŅаŅĐŋаСĐŊаваĐŊĐŊŅ ŅŅĐēŅŅŅ ĐŊа ĐŧаĐģŅĐŊĐēаŅ
",
|
||||
"machine_learning_ocr_enabled": "ĐадаŅŅ OCR",
|
||||
"machine_learning_ocr_enabled_description": "ĐаĐģŅ Đ°Đ´ĐēĐģŅŅаĐŊа, вŅŅĐ˛Ņ ĐŊĐĩ ĐąŅĐ´ŅŅŅ ŅаŅĐŋаСĐŊаваŅŅа С вŅĐēаŅŅŅŅаĐŊĐŊĐĩĐŧ ŅŅĐēŅŅŅ.",
|
||||
"machine_learning_ocr_max_resolution": "ĐаĐēŅŅĐŧаĐģŅĐŊĐ°Ņ ŅаСдСŅĐģŅĐģŅĐŊаŅŅŅ",
|
||||
"machine_learning_ocr_max_resolution_description": "ĐŅдаŅŅŅŅ Đˇ ŅаСдСŅĐģŅĐģŅĐŊаŅŅŅ ĐąĐžĐģŅŅ ĐŗŅŅаК ĐąŅĐ´ŅŅŅ ĐŋаĐŧĐĩĐŊŅаĐŊŅ Đˇ СаŅ
аваĐŊĐŊĐĩĐŧ ŅŅадĐŊĐžŅŅĐŊŅ ĐąĐ°ĐēĐžŅ. ĐĐžĐģŅŅ Đ˛ŅŅĐžĐēŅŅ ĐˇĐŊаŅŅĐŊĐŊŅ ĐŋавŅŅаŅŅŅ Đ´Đ°ĐēĐģадĐŊаŅŅŅ ŅаŅĐŋаСĐŊаваĐŊĐŊŅ, аĐģĐĩ ĐŋаŅŅайŅŅŅŅ ĐąĐžĐģŅŅ ŅаŅŅ ĐŊа аĐŋŅаŅĐžŅĐēŅ Ņ Đ˛ŅĐēаŅŅŅŅĐžŅваŅŅŅ ĐąĐžĐģŅŅ ĐŋаĐŧŅŅŅ.",
|
||||
"machine_learning_ocr_min_detection_score": "ĐŅĐŊŅĐŧаĐģŅĐŊŅ ĐąĐ°Đģ вŅŅŅĐģĐĩĐŊĐŊŅ",
|
||||
"machine_learning_ocr_min_detection_score_description": "ĐŅĐŊŅĐŧаĐģŅĐŊŅ ĐąĐ°Đģ давĐĩŅŅ Đ´ĐģŅ Đ˛ŅŅŅĐģĐĩĐŊĐŊŅ ŅŅĐēŅŅŅ ŅĐēĐģадаĐĩ ад 0 да 1. ĐĐžĐģŅŅ ĐŊŅСĐēŅŅ ĐˇĐŊаŅŅĐŊĐŊŅ Đ´Đ°ĐˇĐ˛ĐžĐģŅŅŅ Đ˛ŅŅвŅŅŅ ĐąĐžĐģŅŅ ŅŅĐēŅŅŅ, аĐģĐĩ ĐŧĐžĐŗŅŅŅ ĐŋŅŅвĐĩŅŅŅ Đ´Đ° Ņ
ŅĐąĐŊŅŅ
ŅĐŋŅаŅĐžŅваĐŊĐŊŅŅ.",
|
||||
"machine_learning_ocr_min_recognition_score": "ĐŅĐŊŅĐŧаĐģŅĐŊŅ ĐąĐ°Đģ ŅаŅĐŋаСĐŊаваĐŊĐŊŅ",
|
||||
"machine_learning_ocr_min_score_recognition_description": "ĐŅĐŊŅĐŧаĐģŅĐŊŅ ĐąĐ°Đģ давĐĩŅŅ Đ´ĐģŅ ŅаŅĐŋаСĐŊаваĐŊĐŊŅ Đ˛ŅŅŅĐģĐĩĐŊĐ°ĐŗĐ° ŅŅĐēŅŅŅ ŅĐēĐģадаĐĩ ад 0 да 1. ĐĐžĐģŅŅ ĐŊŅСĐēŅŅ ĐˇĐŊаŅŅĐŊĐŊŅ ŅаŅĐŋаСĐŊаŅŅŅ ĐąĐžĐģŅŅ ŅŅĐēŅŅŅ, аĐģĐĩ ĐŧĐžĐŗŅŅŅ ĐŋŅŅвĐĩŅŅŅ Đ´Đ° Ņ
ŅĐąĐŊŅŅ
ŅĐŋŅаŅĐžŅваĐŊĐŊŅŅ.",
|
||||
"machine_learning_ocr_model": "ĐадŅĐģŅ ĐŧаŅŅĐŊĐŊĐ°ĐŗĐ° ĐŊавŅŅаĐŊĐŊŅ (OCR)",
|
||||
"machine_learning_ocr_model_description": "ĐĄĐĩŅвĐĩŅĐŊŅŅ ĐŧадŅĐģŅ ĐąĐžĐģŅŅ Đ´Đ°ĐēĐģадĐŊŅŅ, ŅŅĐŧ ĐŧайŅĐģŅĐŊŅŅ, аĐģĐĩ аĐŋŅаŅĐžŅваŅŅŅ Đ´Đ°Đ´ĐˇĐĩĐŊŅŅ Đ´Đ°ŅĐļŅĐš Ņ Đ˛ŅĐēаŅŅŅŅĐžŅваŅŅŅ ĐąĐžĐģŅŅ ĐŋаĐŧŅŅŅ.",
|
||||
"machine_learning_settings": "ĐаĐģĐ°Đ´Ņ ĐŧаŅŅĐŊĐŊĐ°ĐŗĐ° ĐŊавŅŅаĐŊĐŊŅ",
|
||||
"map_dark_style": "ĐĻŅĐŧĐŊŅ ŅŅŅĐģŅ",
|
||||
"map_enable_description": "ĐŖĐēĐģŅŅŅŅŅ ŅŅĐŊĐēŅŅŅ ĐēаŅŅŅ",
|
||||
"map_gps_settings": "ĐаĐģĐ°Đ´Ņ ĐēаŅŅŅ Ņ GPS",
|
||||
@@ -171,6 +193,7 @@
|
||||
"map_style_description": "URL-адŅĐ°Ņ style.json ŅŅĐŧŅ ĐēаŅŅŅ",
|
||||
"metadata_extraction_job_description": "ĐŅĐŊŅŅŅ ĐŧĐĩŅадаĐŊŅŅ Đˇ ŅаКĐģаŅ, ŅаĐēŅŅ ŅĐē ĐŧĐĩŅŅаСĐŊаŅ
ОдĐļаĐŊĐŊĐĩ, ŅваŅŅ Ņ ŅаСдСŅĐģŅĐģŅĐŊаŅŅŅ",
|
||||
"metadata_settings": "ĐаĐģĐ°Đ´Ņ ĐŧĐĩŅадаĐŊŅŅ
",
|
||||
"notification_email_port_description": "ĐĐžŅŅ ĐŋаŅŅĐžĐ˛Đ°ĐŗĐ° ŅĐĩŅвĐĩŅа (ĐŊаĐŋŅŅĐēĐģад, 25, 465 айО 587)",
|
||||
"oauth_button_text": "ĐĸŅĐēŅŅ ĐēĐŊĐžĐŋĐēŅ",
|
||||
"oauth_settings": "OAuth",
|
||||
"refreshing_all_libraries": "ĐĐąĐŊаŅĐģĐĩĐŊĐŊĐĩ ŅŅŅŅ
ĐąŅĐąĐģŅŅŅŅĐē",
|
||||
|
||||
+34
-14
@@ -61,7 +61,7 @@
|
||||
"backup_onboarding_1_description": "ĐēĐžĐŋиĐĩ ĐŊа ОйĐģаĐēа иĐģи Đ´ŅŅĐŗĐž ŅиСиŅĐĩŅĐēĐž ĐŧŅŅŅĐž.",
|
||||
"backup_onboarding_2_description": "ĐģĐžĐēаĐģĐŊи ĐēĐžĐŋĐ¸Ņ ĐŊа ŅаСĐģиŅĐŊи ŅŅŅŅОКŅŅва. ĐĸОва вĐēĐģŅŅва ĐžŅĐŊОвĐŊиŅĐĩ ŅаКĐģОвĐĩ и ĐģĐžĐēаĐģĐŊи аŅŅ
иви ĐŊа ŅĐĩСи ŅаКĐģОвĐĩ.",
|
||||
"backup_onboarding_3_description": "ОйŅĐž ĐēĐžĐŋĐ¸Ņ ĐŊа ваŅиŅĐĩ даĐŊĐŊи, вĐēĐģŅŅиŅĐĩĐŊĐž ĐžŅĐ¸ĐŗĐ¸ĐŊаĐģĐŊиŅĐĩ ŅаКĐģОвĐĩ. ĐĸОва вĐēĐģŅŅва 1 ĐēĐžĐŋиĐĩ иСвŅĐŊ ŅиŅŅĐĩĐŧаŅа и 2 ĐģĐžĐēаĐģĐŊи ĐēĐžĐŋиŅ.",
|
||||
"backup_onboarding_description": "Đа ĐŊадĐĩĐļĐ´ĐŊа СаŅиŅа ĐŋŅĐĩĐŋĐžŅŅŅваĐŧĐĩ ŅŅŅаŅĐĩĐŗĐ¸ŅŅа <backblaze-link>3-2-1</backblaze-link>. ĐŅавĐĩŅĐĩ аŅŅ
ивĐŊи ĐēĐžĐŋĐ¸Ņ ĐēаĐēŅĐž ĐŊа ĐēаŅĐĩĐŊиŅĐĩ ŅĐŊиĐŧĐēи/видĐĩа, ŅаĐēа и ĐŊа йаСаŅа даĐŊĐŊи ĐŊа Immich.",
|
||||
"backup_onboarding_description": "Đа ĐŊадĐĩĐļĐ´ĐŊа СаŅиŅа ĐŋŅĐĩĐŋĐžŅŅŅваĐŧĐĩ <backblaze-link>ŅŅŅаŅĐĩĐŗĐ¸ŅŅа 3-2-1</backblaze-link>. ĐŅавĐĩŅĐĩ аŅŅ
ивĐŊи ĐēĐžĐŋĐ¸Ņ ĐēаĐēŅĐž ĐŊа ĐēаŅĐĩĐŊиŅĐĩ ŅĐŊиĐŧĐēи/видĐĩа, ŅаĐēа и ĐŊа йаСаŅа даĐŊĐŊи ĐŊа Immich.",
|
||||
"backup_onboarding_footer": "Đа ĐŋОдŅОйĐŊа иĐŊŅĐžŅĐŧаŅĐ¸Ņ ĐžŅĐŊĐžŅĐŊĐž аŅŅ
ивиŅаĐŊĐĩŅĐž в Immich, ĐŧĐžĐģŅ Đ˛Đ¸ĐļŅĐĩ в <link>Đ´ĐžĐēŅĐŧĐĩĐŊŅаŅиŅŅа</link>.",
|
||||
"backup_onboarding_parts_title": "ĐĄŅŅаŅĐĩĐŗĐ¸ŅŅа 3-2-1 вĐēĐģŅŅва:",
|
||||
"backup_onboarding_title": "Đ ĐĩСĐĩŅвĐŊи ĐēĐžĐŋиŅ",
|
||||
@@ -104,7 +104,7 @@
|
||||
"image_preview_description": "ĐĄŅĐĩĐ´ĐĩĐŊ ŅаСĐŧĐĩŅ ĐŊа иСОйŅаĐļĐĩĐŊиĐĩŅĐž Ņ ĐŋŅĐĩĐŧаŅ
ĐŊаŅи ĐŧĐĩŅадаĐŊĐŊи, иСĐŋĐžĐģСваĐŊĐž ĐŋŅи ĐŋŅĐĩĐŗĐģĐĩĐ´ ĐŊа ĐĩдиĐŊ ĐĩĐģĐĩĐŧĐĩĐŊŅ Đ¸ Са ĐŧаŅиĐŊĐŊĐž ОйŅŅĐĩĐŊиĐĩ",
|
||||
"image_preview_quality_description": "ĐаŅĐĩŅŅвО ĐŊа ĐŋŅĐĩдваŅиŅĐĩĐģĐŊĐ¸Ņ ĐŋŅĐĩĐŗĐģĐĩĐ´ ĐžŅ 1 Đ´Đž 100. ĐĐž-виŅĐžĐēаŅа ŅŅОКĐŊĐžŅŅ Đĩ ĐŋĐž-дОйŅа, ĐŊĐž вОди Đ´Đž ĐŋĐž-ĐŗĐžĐģĐĩĐŧи ŅаКĐģОвĐĩ и ĐŧĐžĐļĐĩ да ĐŊаĐŧаĐģи ĐąŅŅСОдĐĩĐšŅŅвиĐĩŅĐž ĐŊа ĐŋŅиĐģĐžĐļĐĩĐŊиĐĩŅĐž. ĐадаваĐŊĐĩŅĐž ĐŊа ĐŊиŅĐēа ŅŅОКĐŊĐžŅŅ ĐŧĐžĐļĐĩ да ĐŋОвĐģиŅĐĩ ĐŊа ĐēаŅĐĩŅŅвОŅĐž ĐŊа ĐŧаŅиĐŊĐŊĐžŅĐž ОйŅŅĐĩĐŊиĐĩ.",
|
||||
"image_preview_title": "ĐаŅŅŅОКĐēи ĐŊа ĐŋŅĐĩĐŗĐģĐĩда",
|
||||
"image_progressive": "ĐŅĐžĐŗŅĐĩŅивĐĩĐŊ JPEG",
|
||||
"image_progressive": "ĐŅĐžĐŗŅĐĩŅивĐŊĐž",
|
||||
"image_progressive_description": "ĐСОйŅаĐļĐĩĐŊиŅŅа, ĐēОдиŅаĐŊи в ĐŋŅĐžĐŗŅĐĩŅивĐĩĐŊ JPEG ŅĐžŅĐŧаŅ, ŅĐĩ СаŅĐĩĐļĐ´Đ°Ņ ĐŋĐž-ĐąŅŅСО, Ņ ĐŋĐžŅŅĐĩĐŋĐĩĐŊĐŊĐž ĐŋОдОйŅŅваŅĐž ŅĐĩ ĐēаŅĐĩŅŅвО. ĐĸОва ĐŊŅĐŧа вĐģиŅĐŊиĐĩ ĐŊа ĐēОдиŅаĐŊиŅĐĩ ĐēаŅĐž WebP иСОйŅаĐļĐĩĐŊиŅ.",
|
||||
"image_quality": "ĐаŅĐĩŅŅвО",
|
||||
"image_resolution": "Đ ĐĩСОĐģŅŅиŅ",
|
||||
@@ -311,7 +311,7 @@
|
||||
"search_jobs": "ĐĸŅŅŅĐĩĐŊĐĩ ĐŊа СадаŅиâĻ",
|
||||
"send_welcome_email": "ĐСĐŋŅаŅаĐŊĐĩ ĐŊа иĐŧĐĩĐšĐģ Са дОйŅĐĩ Đ´ĐžŅĐģи",
|
||||
"server_external_domain_settings": "ĐŅĐŊŅĐĩĐŊ Đ´ĐžĐŧĐĩĐšĐŊ",
|
||||
"server_external_domain_settings_description": "ĐĐžĐŧĐĩĐšĐŊ Са ĐŋŅĐąĐģиŅĐŊи ŅĐŋОдĐĩĐģĐĩĐŊи вŅŅСĐēи, вĐēĐģŅŅиŅĐĩĐģĐŊĐž http(s)://",
|
||||
"server_external_domain_settings_description": "ĐĐžĐŧĐĩĐšĐŊ Са вŅĐŊŅĐŊи вŅŅСĐēи",
|
||||
"server_public_users": "ĐŅĐąĐģиŅĐŊи ĐŋĐžŅŅĐĩйиŅĐĩĐģи",
|
||||
"server_public_users_description": "ĐŅиŅĐēи ĐŋĐžŅŅĐĩйиŅĐĩĐģи (иĐŧĐĩ и иĐŧĐĩĐšĐģ) Ņа иСйŅĐžĐĩĐŊи ĐŋŅи дОйавŅĐŊĐĩ ĐŊа ĐŋĐžŅŅĐĩйиŅĐĩĐģ в ŅĐŋОдĐĩĐģĐĩĐŊи аĐģĐąŅĐŧи. ĐĐžĐŗĐ°ŅĐž Đĩ Đ´ĐĩаĐēŅивиŅаĐŊĐž, ŅĐŋиŅŅĐēŅŅ Ņ ĐŋĐžŅŅĐĩйиŅĐĩĐģи ŅĐĩ ĐąŅĐ´Đĩ Đ´ĐžŅŅŅĐŋĐĩĐŊ ŅаĐŧĐž Са адĐŧиĐŊиŅŅŅаŅĐžŅиŅĐĩ.",
|
||||
"server_settings": "ĐаŅŅŅОКĐēи ĐŊа ŅŅŅвŅŅа",
|
||||
@@ -372,7 +372,7 @@
|
||||
"transcoding_audio_codec": "ĐŅдиО ĐēОдĐĩĐē",
|
||||
"transcoding_audio_codec_description": "Opus Đĩ ĐžĐŋŅиŅŅа Ņ ĐŊаК-виŅĐžĐēĐž ĐēаŅĐĩŅŅвО, ĐŊĐž иĐŧа ĐŋĐž-ĐŊиŅĐēа ŅŅвĐŧĐĩŅŅиĐŧĐžŅŅ ŅŅŅ ŅŅаŅи ŅŅŅŅОКŅŅва иĐģи ŅĐžŅŅŅĐĩŅ.",
|
||||
"transcoding_bitrate_description": "ĐидĐĩĐžĐēĐģиĐŋОвĐĩ Ņ ĐŋĐž-виŅĐžĐē ĐžŅ ĐŧаĐēŅиĐŧаĐģĐŊĐ¸Ņ ĐąĐ¸ŅŅĐĩĐšŅ Đ¸Đģи ĐŊĐĩ в ĐŋŅиĐĩŅ ŅĐžŅĐŧаŅ",
|
||||
"transcoding_codecs_learn_more": "Đа да ĐŊаŅŅиŅĐĩ ĐŋОвĐĩŅĐĩ Са иСĐŋĐžĐģСваĐŊаŅа ŅĐĩŅĐŧиĐŊĐžĐģĐžĐŗĐ¸Ņ, виĐļŅĐĩ Đ´ĐžĐēŅĐŧĐĩĐŊŅаŅиŅŅа ĐŊа FFmpeg Са <h264-link>ĐēОдĐĩĐē H.264</h264-link>, <hevc-link>ĐēОдĐĩĐē HEVC</hevc-link> и <vp9-link>VP9 ĐēОдĐĩĐē</vp9-link>.",
|
||||
"transcoding_codecs_learn_more": "Đа да ĐŊаŅŅиŅĐĩ ĐŋОвĐĩŅĐĩ Са иСĐŋĐžĐģСваĐŊаŅа ŅĐĩŅĐŧиĐŊĐžĐģĐžĐŗĐ¸Ņ, виĐļŅĐĩ Đ´ĐžĐēŅĐŧĐĩĐŊŅаŅиŅŅа ĐŊа FFmpeg Са <h264-link>ĐēОдĐĩĐē H.264</h264-link>, <hevc-link>ĐēОдĐĩĐē HEVC</hevc-link> и <vp9-link>ĐēОдĐĩĐē VP9</vp9-link>.",
|
||||
"transcoding_constant_quality_mode": "Đ ĐĩĐļиĐŧ ĐŊа ĐŋĐžŅŅĐžŅĐŊĐŊĐž ĐēаŅĐĩŅŅвО",
|
||||
"transcoding_constant_quality_mode_description": "ICQ Đĩ ĐŋĐž-дОйŅŅ ĐžŅ CQP, ĐŊĐž ĐŊŅĐēОи ŅŅŅŅОКŅŅва Са Ņ
аŅĐ´ŅĐĩŅĐŊĐž ŅŅĐēĐžŅŅваĐŊĐĩ ĐŊĐĩ ĐŋОддŅŅĐļĐ°Ņ ŅОСи ŅĐĩĐļиĐŧ. ĐĄ СадаваĐŊĐĩŅĐž ĐŊа ŅаСи ĐžĐŋŅĐ¸Ņ ŅĐĩ ĐŋŅĐĩĐ´ĐŋĐžŅĐĩŅĐĩ ĐŋĐžŅĐžŅĐĩĐŊĐ¸Ņ ŅĐĩĐļиĐŧ ĐŋŅи иСĐŋĐžĐģСваĐŊĐĩ ĐŊа йаСиŅаĐŊĐž ĐŊа ĐēаŅĐĩŅŅвО ĐēОдиŅаĐŊĐĩ. ĐĐŗĐŊĐžŅиŅаĐŊĐž ĐžŅ NVENC, ŅŅĐš ĐēаŅĐž ĐŊĐĩ ĐŋОддŅŅĐļа ICQ.",
|
||||
"transcoding_constant_rate_factor": "ĐĐžĐĩŅиŅиĐĩĐŊŅ ĐŊа ĐŋĐžŅŅĐžŅĐŊĐŊа ŅĐēĐžŅĐžŅŅ (-crf)",
|
||||
@@ -411,7 +411,7 @@
|
||||
"transcoding_tone_mapping": "ĐĸĐžĐŊаĐģĐŊĐž ĐēаŅŅĐžĐŗŅаŅиŅаĐŊĐĩ",
|
||||
"transcoding_tone_mapping_description": "ĐĐŋиŅва ŅĐĩ да СаĐŋаСи вŅĐŊŅĐŊĐ¸Ņ Đ˛Đ¸Đ´ ĐŊа HDR видĐĩĐžĐēĐģиĐŋОвĐĩ, ĐēĐžĐŗĐ°ŅĐž ŅĐĩ ĐŋŅĐĩОйŅаСŅва в SDR. ĐŅĐĩĐēи аĐģĐŗĐžŅиŅŅĐŧ ĐŋŅави ŅаСĐģиŅĐŊи ĐēĐžĐŧĐŋŅĐžĐŧиŅи Са ŅвŅŅ, Đ´ĐĩŅаКĐģĐŊĐžŅŅ Đ¸ ŅŅĐēĐžŅŅ. Hable СаĐŋаСва Đ´ĐĩŅаКĐģиŅĐĩ, Mobius СаĐŋаСва ŅвĐĩŅа, а Reinhard СаĐŋаСва ŅŅĐēĐžŅŅŅа.",
|
||||
"transcoding_transcode_policy": "ĐŅавиĐģа Са ŅŅаĐŊŅĐēОдиŅаĐŊĐĩ",
|
||||
"transcoding_transcode_policy_description": "ĐŅавиĐģа Са ŅОва ĐēĐžĐŗĐ° видĐĩĐžĐēĐģиĐŋŅŅ ŅŅŅйва да ĐąŅĐ´Đĩ ŅŅаĐŊŅĐēОдиŅаĐŊ. HDR видĐĩĐžĐēĐģиĐŋОвĐĩŅĐĩ виĐŊĐ°ĐŗĐ¸ ŅĐĩ ĐąŅĐ´Đ°Ņ ŅŅаĐŊŅĐēОдиŅаĐŊи (ĐžŅвĐĩĐŊ аĐēĐž ŅŅаĐŊŅĐēОдиŅаĐŊĐĩŅĐž Đĩ Đ´ĐĩаĐēŅивиŅаĐŊĐž).",
|
||||
"transcoding_transcode_policy_description": "ĐŅавиĐģа Са ŅОва ĐēĐžĐŗĐ° видĐĩĐžĐēĐģиĐŋŅŅ ŅŅŅйва да ĐąŅĐ´Đĩ ŅŅаĐŊŅĐēОдиŅаĐŊ. HDR видĐĩĐžĐēĐģиĐŋОвĐĩŅĐĩ и ŅĐĩСи Ņ ŅĐžŅĐŧаŅ, ŅаСĐģиŅĐĩĐŊ ĐžŅ YUV 4:2:0, ŅĐĩ ĐąŅĐ´Đ°Ņ Đ˛Đ¸ĐŊĐ°ĐŗĐ¸ ŅŅаĐŊŅĐēОдиŅаĐŊи (ĐžŅвĐĩĐŊ аĐēĐž ŅŅаĐŊŅĐēОдиŅаĐŊĐĩŅĐž Đĩ Đ´ĐĩаĐēŅивиŅаĐŊĐž).",
|
||||
"transcoding_two_pass_encoding": "ĐОдиŅаĐŊĐĩ Ņ Đ´Đ˛ĐžĐšĐŊĐž ĐŧиĐŊаваĐŊĐĩ",
|
||||
"transcoding_two_pass_encoding_setting_description": "ĐĸŅаĐŊŅĐēОдиŅаĐŊĐĩŅĐž Ņ Đ´Đ˛Đĩ ĐŧиĐŊаваĐŊĐ¸Ņ ŅŅСдава ĐŋĐž-дОйŅĐĩ ĐēОдиŅаĐŊĐĩ видĐĩа. ĐĐžĐŗĐ°ŅĐž ĐŧаĐēŅиĐŧаĐģĐŊĐ¸Ņ ĐąĐ¸ŅŅĐĩĐšŅ Đĩ вĐēĐģŅŅĐĩĐŊ (СадŅĐģĐļиŅĐĩĐģĐŊĐž Đĩ да ŅĐĩ ŅайОŅи Ņ H.264 и HEVC), ŅаСи ĐžĐŋŅĐ¸Ņ Đ¸ĐˇĐŋĐžĐģСва диаĐŋаСОĐŊ ĐŊа йиŅŅĐĩĐšŅа йаСиŅаĐŊ ĐŊа ĐŧаĐēŅиĐŧаĐģĐŊĐ¸Ņ ĐąĐ¸ŅŅĐĩĐšŅ Đ¸ Đ¸ĐŗĐŊĐžŅиŅа CRF. Đа VP9, CRF ĐŧĐžĐļĐĩ да ŅĐĩ иСĐŋĐžĐģСва аĐēĐž ĐŧаĐēŅиĐŧаĐģĐŊиŅŅ ĐąĐ¸ŅŅĐĩĐšŅ Đĩ иСĐēĐģŅŅĐĩĐŊ.",
|
||||
"transcoding_video_codec": "ĐидĐĩĐžĐēОдĐĩĐē",
|
||||
@@ -794,6 +794,11 @@
|
||||
"color": "ĐĻвŅŅ",
|
||||
"color_theme": "ĐĻвĐĩŅОва ŅĐĩĐŧа",
|
||||
"command": "ĐĐžĐŧаĐŊда",
|
||||
"command_palette_prompt": "ĐŅŅСО ĐŊаĐŧиŅаĐŊĐĩ ĐŊа ŅŅŅаĐŊиŅи, Đ´ĐĩĐšŅŅĐ˛Đ¸Ņ Đ¸Đģи ĐēĐžĐŧаĐŊди",
|
||||
"command_palette_to_close": "СаŅвОŅи",
|
||||
"command_palette_to_navigate": "вĐģĐĩС",
|
||||
"command_palette_to_select": "иСйĐĩŅи",
|
||||
"command_palette_to_show_all": "ĐŋĐžĐēаĐļи вŅиŅĐēĐž",
|
||||
"comment_deleted": "ĐĐžĐŧĐĩĐŊŅаŅŅŅ Đĩ иСŅŅиŅ",
|
||||
"comment_options": "ĐĐŋŅии Са ĐēĐžĐŧĐĩĐŊŅаŅ",
|
||||
"comments_and_likes": "ĐĐžĐŧĐĩĐŊŅаŅи и Ņ
аŅĐĩŅваĐŊиŅ",
|
||||
@@ -866,8 +871,8 @@
|
||||
"current_pin_code": "ĐĄĐĩĐŗĐ°ŅĐĩĐŊ PIN ĐēОд",
|
||||
"current_server_address": "ĐаŅŅĐžŅŅ Đ°Đ´ŅĐĩŅ ĐŊа ŅŅŅвŅŅа",
|
||||
"custom_date": "ĐĐĩŅŅĐžĐŊаĐģиСиŅаĐŊа даŅа",
|
||||
"custom_locale": "ĐĐĩŅŅĐžĐŊаĐģиСиŅаĐŊ ĐģĐžĐēаĐģ",
|
||||
"custom_locale_description": "ФОŅĐŧаŅиŅаĐŊĐĩ ĐŊа даŅи и ŅиŅĐģа в СавиŅиĐŧĐžŅŅ ĐžŅ ĐĩСиĐēа и ŅĐĩĐŗĐ¸ĐžĐŊа",
|
||||
"custom_locale": "ĐĐĩŅŅĐžĐŊаĐģиСиŅаĐŊи ĐĩСиĐēОви ĐŊаŅŅŅОКĐēи",
|
||||
"custom_locale_description": "ФОŅĐŧаŅиŅаĐŊĐĩ ĐŊа даŅа, вŅĐĩĐŧĐĩ и ŅиŅĐģа в СавиŅиĐŧĐžŅŅ ĐžŅ Đ¸ĐˇĐąŅаĐŊĐ¸Ņ ĐĩСиĐē и ŅĐĩĐŗĐ¸ĐžĐŊ",
|
||||
"custom_url": "ĐĐĩŅŅĐžĐŊаĐģиСиŅаĐŊ URL адŅĐĩŅ",
|
||||
"cutoff_date_description": "ĐаĐŋаСваĐŊĐĩ ĐŊа ŅĐŊиĐŧĐēи ĐžŅ ĐŋĐžŅĐģĐĩĐ´ĐŊиŅĐĩâĻ",
|
||||
"cutoff_day": "{count, plural, one {Đ´ĐĩĐŊ} other {Đ´ĐŊи}}",
|
||||
@@ -890,8 +895,6 @@
|
||||
"deduplication_criteria_2": "ĐŅОК EXIF даĐŊĐŊи",
|
||||
"deduplication_info": "ĐĐŊŅĐžŅĐŧаŅĐ¸Ņ ĐˇĐ° Đ´ĐĩĐ´ŅĐŋĐģиĐēаŅиŅŅа",
|
||||
"deduplication_info_description": "Đа авŅĐžĐŧаŅиŅĐŊĐž ĐŋŅĐĩдваŅиŅĐĩĐģĐŊĐž иСйиŅаĐŊĐĩ ĐŊа ŅĐĩŅŅŅŅи и ĐŋŅĐĩĐŧаŅ
ваĐŊĐĩ ĐŊа Đ´ŅĐąĐģиĐēаŅи ĐŊа ĐĩĐ´ŅĐž, ŅĐ°ĐˇĐŗĐģĐĩĐļдаĐŧĐĩ:",
|
||||
"default_locale": "ĐĐžĐēаĐģиСаŅĐ¸Ņ ĐŋĐž ĐŋОдŅаСйиŅаĐŊĐĩ",
|
||||
"default_locale_description": "ФОŅĐŧаŅиŅаĐŊĐĩ ĐŊа даŅи и ŅиŅĐģа в СавиŅиĐŧĐžŅŅ ĐžŅ ĐĩСиĐēОваŅа ĐŊаŅŅŅОКĐēа ĐŊа ĐąŅаŅСŅŅа",
|
||||
"delete": "ĐСŅŅиК",
|
||||
"delete_action_confirmation_message": "ĐĄĐ¸ĐŗŅŅĐŊи Đģи ŅŅĐĩ, ŅĐĩ иŅĐēаŅĐĩ да иСŅŅиĐĩŅĐĩ ŅОСи ОйĐĩĐēŅ? ĐĄĐģĐĩдва ĐŋŅĐĩĐŧĐĩŅŅваĐŊĐĩ ĐŊа ОйĐĩĐēŅа в ĐēĐžŅа Са ĐžŅĐŋадŅŅи ĐŊа ŅŅŅвŅŅа и ŅĐĩ ĐŋĐžĐģŅŅиŅĐĩ ĐŋŅĐĩĐ´ĐģĐžĐļĐĩĐŊиĐĩ ОйĐĩĐēŅа да ĐąŅĐ´Đĩ иСŅŅĐ¸Ņ ĐģĐžĐēаĐģĐŊĐž",
|
||||
"delete_action_prompt": "{count} Ņа иСŅŅиŅи",
|
||||
@@ -1004,6 +1007,8 @@
|
||||
"editor_edits_applied_success": "ĐŖŅĐŋĐĩŅĐŊĐž ĐŋŅиĐģĐ°ĐŗĐ°ĐŊĐĩ ĐŊа ĐŋŅĐžĐŧĐĩĐŊиŅĐĩ",
|
||||
"editor_flip_horizontal": "ĐĐąŅŅĐŊи Ņ
ĐžŅиСОĐŊŅаĐģĐŊĐž",
|
||||
"editor_flip_vertical": "ĐĐąŅŅĐŊи вĐĩŅŅиĐēаĐģĐŊĐž",
|
||||
"editor_handle_corner": "ĐаĐŊиĐŋŅĐģаŅĐžŅ {corner, select, top_left {ĐŗĐžŅĐĩĐŊ ĐģŅв} top_right {ĐŗĐžŅĐĩĐŊ Đ´ĐĩŅĐĩĐŊ} bottom_left {Đ´ĐžĐģĐĩĐŊ ĐģŅв} bottom_right {Đ´ĐžĐģĐĩĐŊ Đ´ĐĩŅĐĩĐŊ} other {в}} ŅĐŗŅĐģ",
|
||||
"editor_handle_edge": "ĐаĐŊиĐŋŅĐģаŅĐžŅ {edge, select, top {ĐŗĐžŅĐĩĐŊ} bottom {Đ´ĐžĐģĐĩĐŊ} left {ĐģŅв} right {Đ´ĐĩŅĐĩĐŊ} other {ĐŋĐž}} ŅŅĐą",
|
||||
"editor_orientation": "ĐŅиĐĩĐŊŅаŅиŅ",
|
||||
"editor_reset_all_changes": "ĐŅСŅŅаĐŊОви вŅиŅĐēи ĐŋŅĐžĐŧĐĩĐŊи",
|
||||
"editor_rotate_left": "ĐавŅŅŅи 90° ОйŅаŅĐŊĐž ĐŊа ŅаŅОвĐŊиĐēОваŅа ŅŅŅĐĩĐģĐēа",
|
||||
@@ -1069,6 +1074,7 @@
|
||||
"failed_to_update_notification_status": "ĐĐĩŅŅĐŋĐĩŅĐŊĐž ОйĐŊОвŅваĐŊĐĩ ĐŊа ŅŅŅŅĐžŅĐŊиĐĩŅĐž ĐŊа иСвĐĩŅŅиŅŅа",
|
||||
"incorrect_email_or_password": "ĐĐĩĐŋŅавиĐģĐĩĐŊ иĐŧĐĩĐšĐģ иĐģи ĐŋаŅĐžĐģа",
|
||||
"library_folder_already_exists": "ĐĸаСи ĐŋаĐŋĐēа вĐĩŅĐĩ ŅŅŅĐĩŅŅвŅва.",
|
||||
"page_not_found": "ĐĄŅŅаĐŊиŅаŅа ĐŊĐĩ Đĩ ĐŊаĐŧĐĩŅĐĩĐŊа",
|
||||
"paths_validation_failed": "{paths, plural, one {# ĐŋŅŅ} other {# ĐŋŅŅиŅа}} ĐŊĐĩ ĐŋŅĐĩĐŧиĐŊаŅ
а ваĐģидаŅиŅ",
|
||||
"profile_picture_transparent_pixels": "ĐŅĐžŅиĐģĐŊиŅĐĩ ŅĐŊиĐŧĐēи ĐŊĐĩ ĐŧĐžĐŗĐ°Ņ Đ´Đ° иĐŧĐ°Ņ ĐŋŅОСŅаŅĐŊи ĐŋиĐēŅĐĩĐģи. ĐĐžĐģŅ, ŅвĐĩĐģиŅĐĩŅĐĩ и/иĐģи ĐŋŅĐĩĐŧĐĩŅŅĐĩŅĐĩ иСОйŅаĐļĐĩĐŊиĐĩŅĐž.",
|
||||
"quota_higher_than_disk_size": "ĐададĐĩĐŊа Đĩ ĐēвОŅа, ĐŋĐž-ĐŗĐžĐģŅĐŧа ĐžŅ ŅаСĐŧĐĩŅа ĐŊа диŅĐēа",
|
||||
@@ -1168,6 +1174,7 @@
|
||||
"exif_bottom_sheet_people": "ĐĨĐĐ Đ",
|
||||
"exif_bottom_sheet_person_add_person": "ĐОйави иĐŧĐĩ",
|
||||
"exit_slideshow": "ĐСŅ
Од ĐžŅ ŅĐģаКдŅĐžŅŅĐž",
|
||||
"expand": "Đ Đ°ĐˇĐŗŅĐŊи",
|
||||
"expand_all": "РаСŅиŅи вŅиŅĐēи",
|
||||
"experimental_settings_new_asset_list_subtitle": "Đ ŅаСвиŅиĐĩ",
|
||||
"experimental_settings_new_asset_list_title": "ĐĐēĐģŅŅи ĐĩĐēŅĐŋĐĩŅиĐŧĐĩĐŊŅаĐģĐŊа ĐŋОдŅĐĩдйа ĐŊа ŅĐŊиĐŧĐēи",
|
||||
@@ -1212,6 +1219,7 @@
|
||||
"filter_description": "ĐŖŅĐģĐžĐ˛Đ¸Ņ ĐˇĐ° ŅиĐģŅŅиŅаĐŊĐĩ ĐŊа ОйĐĩĐēŅи",
|
||||
"filter_people": "ФиĐģŅŅиŅаĐŊĐĩ ĐŊа Ņ
ĐžŅа",
|
||||
"filter_places": "ФиĐģŅŅŅ ĐŋĐž ĐŧŅŅŅĐž",
|
||||
"filter_tags": "ФиĐģŅŅиŅаĐŊĐĩ ĐŋĐž ĐĩŅиĐēĐĩŅи",
|
||||
"filters": "ФиĐģŅŅи",
|
||||
"find_them_fast": "ĐаĐŧĐĩŅĐĩŅĐĩ ĐŗĐ¸ ĐąŅŅСО ĐŋĐž иĐŧĐĩ Ņ ŅŅŅŅĐĩĐŊĐĩ",
|
||||
"first": "ĐŅŅви",
|
||||
@@ -1311,7 +1319,7 @@
|
||||
"import_path": "ĐŅŅ ĐˇĐ° иĐŧĐŋĐžŅŅиŅаĐŊĐĩ",
|
||||
"in_albums": "Đ {count, plural, one {# аĐģĐąŅĐŧ} other {# аĐģĐąŅĐŧа}}",
|
||||
"in_archive": "РаŅŅ
ив",
|
||||
"in_year": "{year} Đŗ.",
|
||||
"in_year": "ĐŅĐĩС {year}",
|
||||
"in_year_selector": "ĐŅĐĩС",
|
||||
"include_archived": "ĐĐēĐģŅŅваĐŊĐĩ ĐŊа аŅŅ
ивиŅаĐŊи",
|
||||
"include_shared_albums": "ĐĐēĐģŅŅваĐŊĐĩ ĐŊа ŅĐŋОдĐĩĐģĐĩĐŊи аĐģĐąŅĐŧи",
|
||||
@@ -1642,6 +1650,7 @@
|
||||
"online": "ĐĐŊĐģаКĐŊ",
|
||||
"only_favorites": "ХаĐŧĐž ĐģŅйиĐŧи",
|
||||
"open": "ĐŅвОŅи",
|
||||
"open_calendar": "ĐŅвОŅи ĐēаĐģĐĩĐŊдаŅ",
|
||||
"open_in_map_view": "ĐŅвОŅи Đ¸ĐˇĐŗĐģĐĩĐ´ ĐŊа ĐēаŅŅа",
|
||||
"open_in_openstreetmap": "ĐŅвОŅи в OpenStreetMap",
|
||||
"open_the_search_filters": "ĐŅваŅи ŅиĐģŅŅиŅĐĩ Са ŅŅŅŅĐĩĐŊĐĩ",
|
||||
@@ -1801,9 +1810,8 @@
|
||||
"rate_asset": "ĐадаваĐŊĐĩ ĐŊа ŅĐĩĐšŅиĐŊĐŗ",
|
||||
"rating": "ĐŅĐĩĐŊĐēа ŅŅŅ ĐˇĐ˛ĐĩСди",
|
||||
"rating_clear": "ĐСŅиŅŅи ĐžŅĐĩĐŊĐēаŅа",
|
||||
"rating_count": "{count, plural, one {# СвĐĩСда} other {# СвĐĩСди}}",
|
||||
"rating_count": "{count, plural, =0 {ĐĐĩС ŅĐĩĐšŅиĐŊĐŗ} one {# СвĐĩСда} other {# СвĐĩСди}}",
|
||||
"rating_description": "ĐĐžĐēаĐļи EXIF ĐžŅĐĩĐŊĐēаŅа в ĐŋаĐŊĐĩĐģа Ņ Đ¸ĐŊŅĐžŅĐŧаŅиŅ",
|
||||
"rating_set": "ĐададĐĩĐŊ Đĩ ŅĐĩĐšŅиĐŊĐŗ {rating, plural, one {# СвĐĩСда} other {# СвĐĩСди}}",
|
||||
"reaction_options": "ĐĐˇĐąĐžŅ ĐŊа ŅĐĩаĐēŅиŅ",
|
||||
"read_changelog": "ĐŅĐžŅĐĩŅи ĐŋŅĐžĐŧĐĩĐŊиŅĐĩ",
|
||||
"readonly_mode_disabled": "Đ ĐĩĐļиĐŧа ŅаĐŧĐž Са ŅĐĩŅĐĩĐŊĐĩ Đĩ Đ´ĐĩаĐēŅивиŅаĐŊ",
|
||||
@@ -1875,7 +1883,10 @@
|
||||
"reset_pin_code_success": "ĐŖŅĐŋĐĩŅĐŊĐž ĐŊŅĐģиŅаĐŊ ĐĐĐ ĐēОд",
|
||||
"reset_pin_code_with_password": "ĐĄ ваŅаŅа ĐŋаŅĐžĐģа ĐŧĐžĐļĐĩŅĐĩ виĐŊĐ°ĐŗĐ¸ да ĐŊŅĐģиŅаŅĐĩ ŅĐ˛ĐžŅ ĐĐĐ ĐēОд",
|
||||
"reset_sqlite": "ĐŅĐģиŅаĐŊĐĩ ĐŊа йаСаŅа даĐŊĐŊи SQLite",
|
||||
"reset_sqlite_confirmation": "ĐаиŅŅиĐŊа Đģи иŅĐēаŅĐĩ да ĐŊŅĐģиŅаŅĐĩ йаСаŅа даĐŊĐŊи SQLite? ĐŠĐĩ ŅŅŅйва да иСĐģĐĩСĐĩŅĐĩ ĐžŅ ŅиŅŅĐĩĐŧаŅа и да ŅĐĩ вĐŋиŅĐĩŅĐĩ ĐžŅĐŊОвО Са ĐŊОва ŅиĐŊŅ
ŅĐžĐŊиСаŅĐ¸Ņ ĐŊа даĐŊĐŊиŅĐĩ",
|
||||
"reset_sqlite_clear_app_data": "ĐŅĐĩĐŧаŅ
ĐŊи даĐŊĐŊиŅĐĩ",
|
||||
"reset_sqlite_confirmation": "ĐаиŅŅиĐŊа Đģи иŅĐēаŅĐĩ да ĐŊŅĐģиŅаŅĐĩ даĐŊĐŊиŅĐĩ ĐŊа ĐŋŅиĐģĐžĐļĐĩĐŊиĐĩŅĐž? ĐĸОва ŅĐĩ ĐŋŅĐĩĐŧаŅ
ĐŊи вŅиŅĐēи ĐŊаŅŅŅОКĐēи и ŅĐĩ Đи ĐžŅĐŋиŅĐĩ ĐžŅ ŅиŅŅĐĩĐŧаŅа.",
|
||||
"reset_sqlite_confirmation_note": "ĐĐĩĐģĐĩĐļĐēа: ĐĄĐģĐĩĐ´ ĐŋŅĐĩĐŧаŅ
ваĐŊĐĩ ĐŊа даĐŊĐŊиŅĐĩ ŅĐĩ ŅŅŅйва да ŅĐĩŅŅаŅŅиŅаŅĐĩ ĐŋŅиĐģĐžĐļĐĩĐŊиĐĩŅĐž.",
|
||||
"reset_sqlite_done": "ĐаĐŊĐŊиŅĐĩ ĐŊа ĐŋŅиĐģĐžĐļĐĩĐŊиĐĩŅĐž Ņа ĐŋŅĐĩĐŧаŅ
ĐŊаŅи. ĐĐžĐģŅ, ŅĐĩŅŅаŅŅиŅаКŅĐĩ Immich и ŅĐĩ вĐŋиŅĐĩŅĐĩ ĐžŅĐŊОвО.",
|
||||
"reset_sqlite_success": "ĐŖŅĐŋĐĩŅĐŊĐž ĐŊŅĐģиŅаĐŊĐĩ ĐŊа йаСаŅа даĐŊĐŊи SQLite",
|
||||
"reset_to_default": "ĐŅŅŅаĐŊĐĩ ĐŊа ŅайŅиŅĐŊи ĐŊаŅŅŅОКĐēи",
|
||||
"resolution": "Đ ĐĩСОĐģŅŅиŅ",
|
||||
@@ -1903,6 +1914,7 @@
|
||||
"saved_settings": "ĐаĐŋаСĐĩĐŊи ĐŊаŅŅŅОКĐēи",
|
||||
"say_something": "ĐаĐļи ĐŊĐĩŅĐž",
|
||||
"scaffold_body_error_occurred": "ĐŅСĐŊиĐēĐŊа ĐŗŅĐĩŅĐēа",
|
||||
"scaffold_body_error_unrecoverable": "ĐŅСĐŊиĐēĐŊа ĐŊĐĩĐŋĐžĐŋŅавиĐŧа ĐŗŅĐĩŅĐēа. ĐĐžĐģŅ, ŅĐŋОдĐĩĐģĐĩŅĐĩ ĐŗŅĐĩŅĐēаŅа и ŅŅаŅиŅаĐŊĐĩŅĐž ĐŊа ŅŅĐĩĐēа в Discord иĐģи GitHub, Са да ĐŧĐžĐļĐĩĐŧ да Đи ĐŋĐžĐŧĐžĐŗĐŊĐĩĐŧ. ĐĐēĐž ĐąŅĐ´ĐĩŅĐĩ ĐŋĐžŅŅвĐĩŅваĐŊи, ĐŧĐžĐļĐĩ да иСŅиŅŅиŅĐĩ даĐŊĐŊиŅĐĩ ĐŊа ĐŋŅиĐģĐžĐļĐĩĐŊиĐĩŅĐž.",
|
||||
"scan": "ĐĄĐēаĐŊиŅаĐŊe",
|
||||
"scan_all_libraries": "ĐĄĐēаĐŊиŅаК вŅиŅĐēи йийĐģиОŅĐĩĐēи",
|
||||
"scan_library": "ĐĄĐēаĐŊиŅаК",
|
||||
@@ -1938,6 +1950,7 @@
|
||||
"search_filter_ocr": "ĐĸŅŅŅĐĩĐŊĐĩ ĐŊa ŅĐĩĐēŅŅ",
|
||||
"search_filter_people_title": "ĐСйĐĩŅи Ņ
ĐžŅа",
|
||||
"search_filter_star_rating": "ĐĐģаŅаŅĐ¸Ņ ŅŅŅ ĐˇĐ˛ĐĩСди",
|
||||
"search_filter_tags_title": "ĐСйĐĩŅĐĩŅĐĩ ĐĩŅиĐēĐĩŅи",
|
||||
"search_for": "ĐĸŅŅŅи Са",
|
||||
"search_for_existing_person": "ĐĸŅŅŅи ŅŅŅĐĩŅŅвŅĐ˛Đ°Ņ ŅОвĐĩĐē",
|
||||
"search_no_more_result": "ĐŅĐŧа Đ´ŅŅĐŗĐ¸ ŅĐĩСŅĐģŅаŅи",
|
||||
@@ -2017,6 +2030,9 @@
|
||||
"set_profile_picture": "ĐадаКŅĐĩ ĐŋŅĐžŅиĐģĐŊа ŅĐŊиĐŧĐēа",
|
||||
"set_slideshow_to_fullscreen": "ĐадаКŅĐĩ ĐĄĐģаКдŅĐžŅ ĐŊа ŅŅĐģ ĐĩĐēŅаĐŊ",
|
||||
"set_stack_primary_asset": "ĐадаК ĐēаŅĐž ĐžŅĐŊОвĐŊи ОйĐĩĐēŅи",
|
||||
"setting_image_navigation_enable_subtitle": "ĐĐēĐž Đĩ иСйŅаĐŊĐž, ĐŧĐžĐļĐĩŅĐĩ да ĐŊĐ°Đ˛Đ¸ĐŗĐ¸ŅаŅĐĩ ĐēŅĐŧ ĐŋŅĐĩдиŅĐŊа/ŅĐģĐĩдваŅа ŅĐŊиĐŧĐēа ĐēаŅĐž ĐŊаŅиŅĐŊĐĩŅĐĩ вŅŅŅ
Ņ ĐģŅваŅа/Đ´ŅŅĐŊаŅа ŅŅŅаĐŊа ĐŊа ĐĩĐēŅаĐŊа.",
|
||||
"setting_image_navigation_enable_title": "ĐаŅиŅĐŊи Са ĐŊĐ°Đ˛Đ¸ĐŗĐ¸ŅаĐŊĐĩ",
|
||||
"setting_image_navigation_title": "ĐĐ°Đ˛Đ¸ĐŗĐ¸ŅаĐŊĐĩ ĐŊа ŅĐŊиĐŧĐēа",
|
||||
"setting_image_viewer_help": "ĐŅи ĐŋĐžĐēаСваĐŊĐĩ ĐŊа ОйĐĩĐēŅ ĐŋŅŅвО ŅĐĩ СаŅĐĩĐļда ĐŧиĐŊиаŅŅŅа, ĐŋĐžŅĐģĐĩ иСОйŅаĐļĐĩĐŊиĐĩ ŅŅŅ ŅŅĐĩĐ´ĐŊĐž ĐēаŅĐĩŅŅвО (аĐēĐž Đĩ ŅаСŅĐĩŅĐĩĐŊĐž) и ĐŊаĐēŅĐ°Ņ ĐžŅĐ¸ĐŗĐ¸ĐŊаĐģа (аĐēĐž Đĩ ŅаСŅĐĩŅĐĩĐŊĐž).",
|
||||
"setting_image_viewer_original_subtitle": "РаСŅĐĩŅи Са да ŅĐĩ СаŅĐĩĐļда ĐžŅĐ¸ĐŗĐ¸ĐŊаĐģĐŊĐžŅĐž иСОйŅаĐļĐĩĐŊиĐĩ в ĐŋŅĐģĐĩĐŊ ŅаСĐŧĐĩŅ (ĐŗĐžĐģŅĐŧ!). ĐайŅаĐŊи Са да ŅĐĩ ĐŊаĐŧаĐģи ОйĐĩĐŧа ĐŊа даĐŊĐŊиŅĐĩ (ĐŋĐž ĐŧŅĐĩĐļаŅа и в ĐēĐĩŅа ĐŊа ŅŅŅŅОКŅŅвОŅĐž).",
|
||||
"setting_image_viewer_original_title": "ĐаŅĐĩĐļдаĐŊĐĩ ĐŊа ĐžŅĐ¸ĐŗĐ¸ĐŊаĐģĐŊĐž иСОйŅаĐļĐĩĐŊиĐĩ",
|
||||
@@ -2183,6 +2199,7 @@
|
||||
"support": "ĐОддŅŅĐļĐēа",
|
||||
"support_and_feedback": "ĐОддŅŅĐļĐēа и ОйŅаŅĐŊа вŅŅСĐēа",
|
||||
"support_third_party_description": "ĐаŅаŅа иĐŊŅŅаĐģаŅĐ¸Ņ ĐŊа Immich Đĩ ĐŋаĐēĐĩŅиŅаĐŊа ĐžŅ ŅŅĐĩŅа ŅŅŅаĐŊа. ĐŅОйĐģĐĩĐŧиŅĐĩ, ĐēОиŅĐž иСĐŋиŅваŅĐĩ, ĐŧĐžĐļĐĩ да Ņа ĐŋŅиŅиĐŊĐĩĐŊи ĐžŅ ŅОСи ĐŋаĐēĐĩŅ, СаŅОва ĐŧĐžĐģŅ, ĐŋŅŅвО ĐŋОдаваКŅĐĩ ĐŋŅОйĐģĐĩĐŧиŅĐĩ Ņи ĐēŅĐŧ ŅŅŅ
ŅŅĐĩС ĐģиĐŊĐēОвĐĩŅĐĩ ĐŋĐž-Đ´ĐžĐģŅ.",
|
||||
"supporter": "ĐОддŅŅĐļĐŊиĐē",
|
||||
"swap_merge_direction": "РаСĐŧŅĐŊа ĐŋĐžŅĐžĐēаŅа ĐŊа ŅĐģиваĐŊĐĩ",
|
||||
"sync": "ХиĐŊŅ
ŅĐžĐŊиСиŅаĐŊĐĩ",
|
||||
"sync_albums": "ХиĐŊŅ
ŅĐžĐŊиСиŅаĐŊĐĩ ĐŊа аĐģĐąŅĐŧи",
|
||||
@@ -2196,7 +2213,7 @@
|
||||
"tag_assets": "ĐĸĐ°ĐŗĐŊи ĐĩĐģĐĩĐŧĐĩĐŊŅи",
|
||||
"tag_created": "ĐĄŅСдадĐĩĐŊ ĐĩŅиĐēĐĩŅ: {tag}",
|
||||
"tag_feature_description": "Đ Đ°ĐˇĐŗĐģĐĩĐļдаĐŊĐĩ ĐŊа ŅĐŊиĐŧĐēи и видĐĩĐžĐēĐģиĐŋОвĐĩ, ĐŗŅŅĐŋиŅаĐŊи ĐŋĐž ŅĐĩĐŧи Ņ ĐģĐžĐŗĐ¸ŅĐĩŅĐēи ŅĐ°ĐŗĐžĐ˛Đĩ",
|
||||
"tag_not_found_question": "ĐĐĩ ĐŧĐžĐļĐĩŅĐĩ да ĐŊаĐŧĐĩŅиŅĐĩ ĐĩŅиĐēĐĩŅ? ĐĄŅСдаКŅĐĩ ŅаĐēŅв <link>ŅŅĐē</link>",
|
||||
"tag_not_found_question": "ĐĐĩ ĐŧĐžĐļĐĩŅĐĩ да ĐŊаĐŧĐĩŅиŅĐĩ ĐĩŅиĐēĐĩŅ? <link>ĐĄŅСдаКŅĐĩ ĐŊОв ĐĩŅиĐēĐĩŅ.</link>",
|
||||
"tag_people": "ĐŅĐąĐĩĐģĐĩĐļи ĐĨĐžŅа",
|
||||
"tag_updated": "ĐĐąĐŊОвĐĩĐŊ ĐĩŅиĐēĐĩŅ: {tag}",
|
||||
"tagged_assets": "ĐĸĐ°ĐŗĐŊаŅи {count, plural, one {# ĐĩĐģĐĩĐŧĐĩĐŊŅ} other {# ĐĩĐģĐĩĐŧĐĩĐŊŅи}}",
|
||||
@@ -2294,6 +2311,7 @@
|
||||
"unstack_action_prompt": "{count} Ņа ŅĐ°ĐˇĐŗŅŅĐŋиŅаĐŊи",
|
||||
"unstacked_assets_count": "РаСĐēаŅĐĩĐŊи {count, plural, one {# ĐĩĐģĐĩĐŧĐĩĐŊŅ} other {# ĐĩĐģĐĩĐŧĐĩĐŊŅи}}",
|
||||
"unsupported_field_type": "ĐĸиĐŋа ĐŊа ĐŋĐžĐģĐĩŅĐž ĐŊĐĩ ŅĐĩ ĐŋОддŅŅĐļа",
|
||||
"unsupported_file_type": "ФаКĐģŅŅ {file} ĐŊĐĩ ĐŧĐžĐļĐĩ да ĐąŅĐ´Đĩ СаŅĐĩĐ´ĐĩĐŊ, СаŅĐžŅĐž ĐŊĐĩĐŗĐžĐ˛Đ¸ŅŅ ŅиĐŋ {type} ĐŊĐĩ ŅĐĩ ĐŋОддŅŅĐļа.",
|
||||
"untagged": "ĐĐĩĐŧаŅĐēиŅаĐŊи",
|
||||
"untitled_workflow": "РайОŅĐĩĐŊ ĐŋŅĐžŅĐĩŅ ĐąĐĩС иĐŧĐĩ",
|
||||
"up_next": "ĐĄĐģĐĩдваŅ",
|
||||
@@ -2320,6 +2338,8 @@
|
||||
"url": "URL",
|
||||
"usage": "ĐĐžŅŅĐĩĐąĐģĐĩĐŊиĐĩ",
|
||||
"use_biometric": "ĐСĐŋĐžĐģСваК йиОĐŧĐĩŅŅиŅ",
|
||||
"use_browser_locale": "ĐСĐŋĐžĐģСваК ĐĩСиĐēОвиŅĐĩ ĐŊаŅŅŅОКĐēи ĐŊа ĐąŅаŅСŅŅа",
|
||||
"use_browser_locale_description": "ФОŅĐŧĐ°Ņ ĐŊа даŅа, вŅĐĩĐŧĐĩ и ŅиŅĐģа ŅĐŋĐžŅĐĩĐ´ ĐĩСиĐēОваŅа ĐŊаŅŅŅОКĐēа ĐŊа ĐąŅаŅСŅŅа",
|
||||
"use_current_connection": "ĐСĐŋĐžĐģСваК ŅĐĩĐēŅŅаŅа вŅŅСĐēа",
|
||||
"use_custom_date_range": "ĐСĐŋĐžĐģСваКŅĐĩ ŅОйŅŅвĐĩĐŊ диаĐŋаСОĐŊ ĐžŅ Đ´Đ°Ņи вĐŧĐĩŅŅĐž ŅОва",
|
||||
"user": "ĐĐžŅŅĐĩйиŅĐĩĐģ",
|
||||
|
||||
+29
-27
@@ -70,23 +70,23 @@
|
||||
"cleared_jobs": "{job} āĻāϰ āĻāύā§āϝ jobs āĻāĻžāϞāĻŋ āĻāϰāĻž āĻšāϝāĻŧā§āĻā§",
|
||||
"config_set_by_file": "āĻāύāĻĢāĻŋāĻ āĻŦāϰā§āϤāĻŽāĻžāύ⧠āĻāĻāĻāĻŋ āĻāύāĻĢāĻŋāĻ āĻĢāĻžāĻāϞ āĻĻā§āĻŦāĻžāϰāĻž āϏā§āĻ āĻāϰāĻž āĻāĻā§",
|
||||
"confirm_delete_library": "āĻāĻĒāύāĻŋ āĻāĻŋ āύāĻŋāĻļā§āĻāĻŋāϤ āϝ⧠āĻāĻĒāύāĻŋ {library} āϞāĻžāĻāĻŦā§āϰā§āϰāĻŋ āĻŽā§āĻā§ āĻĢā§āϞāϤ⧠āĻāĻžāύ?",
|
||||
"confirm_delete_library_assets": "āĻāĻĒāύāĻŋ āĻāĻŋ āύāĻŋāĻļā§āĻāĻŋāϤ āϝ⧠āĻāĻĒāύāĻŋ āĻāĻ āϞāĻžāĻāĻŦā§āϰā§āϰāĻŋāĻāĻŋ āĻŽā§āĻā§ āĻĢā§āϞāϤ⧠āĻāĻžāύ? āĻāĻāĻŋ Immich āĻĨā§āĻā§ {count, plural, one {# contained asset} other {all # contained asset}} āĻŽā§āĻā§ āĻĢā§āϞāĻŦā§ āĻāĻŦāĻ āĻĒā§āϰā§āĻŦāĻžāĻŦāϏā§āĻĨāĻžāϝāĻŧ āĻĢā§āϰāĻžāύ⧠āϝāĻžāĻŦā§ āύāĻžāĨ¤ āĻĢāĻžāĻāϞāĻā§āϞāĻŋ āĻĄāĻŋāϏā§āĻā§ āĻĨāĻžāĻāĻŦā§āĨ¤",
|
||||
"confirm_email_below": "āύāĻŋāĻļā§āĻāĻŋāϤ āĻāϰāϤā§, āύāĻŋāĻā§ \"{email}\" āĻāĻžāĻāĻĒ āĻāϰā§āύ",
|
||||
"confirm_reprocess_all_faces": "āĻāĻĒāύāĻŋ āĻāĻŋ āύāĻŋāĻļā§āĻāĻŋāϤ āϝ⧠āĻāĻĒāύāĻŋ āϏāĻŽāϏā§āϤ āĻŽā§āĻ āĻĒā§āύāϰāĻžāϝāĻŧ āĻĒā§āϰāĻā§āϰāĻŋāϝāĻŧāĻž āĻāϰāϤ⧠āĻāĻžāύ? āĻāĻāĻŋ āύāĻžāĻŽāϝā§āĻā§āϤ āĻŦā§āϝāĻā§āϤāĻŋāĻĻā§āϰāĻ āĻŽā§āĻā§ āĻĢā§āϞāĻŦā§āĨ¤",
|
||||
"confirm_user_password_reset": "āĻāĻĒāύāĻŋ āĻāĻŋ āύāĻŋāĻļā§āĻāĻŋāϤ āϝ⧠āĻāĻĒāύāĻŋ {user} āĻāϰ āĻĒāĻžāϏāĻāϝāĻŧāĻžāϰā§āĻĄ āϰāĻŋāϏā§āĻ āĻāϰāϤ⧠āĻāĻžāύ?",
|
||||
"confirm_user_pin_code_reset": "āĻāĻĒāύāĻŋ āĻāĻŋ āύāĻŋāĻļā§āĻāĻŋāϤ āϝ⧠āĻāĻĒāύāĻŋ {user} āĻāϰ āĻĒāĻŋāύ āĻā§āĻĄ āϰāĻŋāϏā§āĻ āĻāϰāϤ⧠āĻāĻžāύ?",
|
||||
"copy_config_to_clipboard_description": "āĻŦāϰā§āϤāĻŽāĻžāύ āϏāĻŋāϏā§āĻā§āĻŽ āĻāύāĻĢāĻŋāĻāĻžāϰā§āĻļāύ āĻāĻāĻāĻŋ JSON āĻ
āĻŦāĻā§āĻā§āĻ āĻšāĻŋāϏā§āĻŦā§ āĻā§āϞāĻŋāĻĒāĻŦā§āϰā§āĻĄā§ āĻāĻĒāĻŋ āĻāϰā§āύ",
|
||||
"create_job": "job āϤā§āϰāĻŋ āĻāϰā§āύ",
|
||||
"cron_expression": "āĻā§āϰā§āύ āĻāĻā§āϏāĻĒā§āϰā§āĻļāύ",
|
||||
"cron_expression_description": "āĻā§āϰā§āύ āĻĢāϰā§āĻŽā§āϝāĻžāĻ āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰ⧠āϏā§āĻā§āϝāĻžāύāĻŋāĻ āĻŦā§āϝāĻŦāϧāĻžāύ āϏā§āĻ āĻāϰā§āύāĨ¤ āĻāϰāĻ āϤāĻĨā§āϝā§āϰ āĻāύā§āϝ āĻĻāϝāĻŧāĻž āĻāϰ⧠āĻĻā§āĻā§āύ āϝā§āĻŽāύ <link>Crontab Guru</link>",
|
||||
"cron_expression_presets": "āĻā§āϰā§āύ āĻāĻā§āϏāĻĒā§āϰā§āĻļāύ āĻĒā§āϰāĻŋāϏā§āĻ",
|
||||
"confirm_delete_library_assets": "āĻāĻĒāύāĻŋ āĻāĻŋ āύāĻŋāĻļā§āĻāĻŋāϤāĻāĻžāĻŦā§ āĻāĻ āϞāĻžāĻāĻŦā§āϰā§āϰāĻŋāĻāĻŋ āĻŽā§āĻā§ āĻĢā§āϞāϤ⧠āĻāĻžāύ? āĻāϤ⧠Immich āĻĨā§āĻā§ {count, plural, one {#āĻāĻŋ āĻ
ā§āϝāĻžāϏā§āĻ} other {#āĻāĻŋ āĻ
ā§āϝāĻžāϏā§āĻ}} āĻŽā§āĻā§ āϝāĻžāĻŦā§ āĻāĻŦāĻ āĻāĻ āĻāĻžāĻāĻāĻŋ āĻĒāϰ⧠āĻāϰ āĻĒā§āϰā§āĻŦāĻžāĻŦāϏā§āĻĨāĻžāϝāĻŧ āĻĢā§āϰāĻžāύ⧠āϝāĻžāĻŦā§ āύāĻžāĨ¤ āϤāĻŦā§ āĻĢāĻžāĻāϞāĻā§āϞ⧠āĻĄāĻŋāϏā§āĻā§ āĻĨā§āĻā§ āϝāĻžāĻŦā§āĨ¤",
|
||||
"confirm_email_below": "āύāĻŋāĻļā§āĻāĻŋāϤ āĻāϰāĻžāϰ āĻāύā§āϝ āύāĻŋāĻā§ \"{email}\" āĻāĻžāĻāĻĒ āĻāϰā§āύ",
|
||||
"confirm_reprocess_all_faces": "āĻāĻĒāύāĻŋ āĻāĻŋ āύāĻŋāĻļā§āĻāĻŋāϤ āϝ⧠āĻāĻĒāύāĻŋ āϏāĻŽāϏā§āϤ āĻŽā§āĻ āĻĒā§āύāϰāĻžāϝāĻŧ āĻĒāϰāĻŋāĻļā§āϧāύ āĻāϰāϤ⧠āĻāĻžāύ? āĻāϤ⧠āύāĻžāĻŽ āĻĻā§āĻāϝāĻŧāĻž āĻŦā§āϝāĻā§āϤāĻŋāĻĻā§āϰ āϤāĻĨā§āϝāĻ āĻŽā§āĻā§ āϝāĻžāĻŦā§āĨ¤",
|
||||
"confirm_user_password_reset": "āĻāĻĒāύāĻŋ āĻāĻŋ āύāĻŋāĻļā§āĻāĻŋāϤ āϝ⧠āĻāĻĒāύāĻŋ {user}-āĻāϰ āĻĒāĻžāϏāĻāϝāĻŧāĻžāϰā§āĻĄ āϰāĻŋāϏā§āĻ āĻāϰāϤ⧠āĻāĻžāύ?",
|
||||
"confirm_user_pin_code_reset": "āĻāĻĒāύāĻŋ āĻāĻŋ āύāĻŋāĻļā§āĻāĻŋāϤ āϝ⧠āĻāĻĒāύāĻŋ {user}-āĻāϰ āĻĒāĻŋāύ āĻā§āĻĄ āϰāĻŋāϏā§āĻ āĻāϰāϤ⧠āĻāĻžāύ?",
|
||||
"copy_config_to_clipboard_description": "āĻŦāϰā§āϤāĻŽāĻžāύ āϏāĻŋāϏā§āĻā§āĻŽ āĻāύāĻĢāĻŋāĻāĻžāϰā§āĻļāύāĻāĻŋāĻā§ āĻāĻāĻāĻŋ JSON āĻ
āĻŦāĻā§āĻā§āĻ āĻšāĻŋāϏā§āĻŦā§ āĻā§āϞāĻŋāĻĒāĻŦā§āϰā§āĻĄā§ āĻāĻĒāĻŋ āĻāϰā§āύ",
|
||||
"create_job": "Job āϤā§āϰāĻŋ āĻāϰā§āύ",
|
||||
"cron_expression": "Cron āĻāĻā§āϏāĻĒā§āϰā§āĻļāύ",
|
||||
"cron_expression_description": "Cron āĻĢāϰāĻŽā§āϝāĻžāĻ āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰ⧠āϏā§āĻā§āϝāĻžāύāĻŋāĻ āĻāύā§āĻāĻžāϰāĻā§āϝāĻžāϞ āύāĻŋāϰā§āϧāĻžāϰāĻŖ āĻāϰā§āύāĨ¤ āĻāϰāĻ āϤāĻĨā§āϝā§āϰ āĻāύā§āϝ āĻĻā§āĻž āĻāϰ⧠<link>Crontab Guru</link> āĻĻā§āĻā§āύ",
|
||||
"cron_expression_presets": "Cron āĻāĻā§āϏāĻĒā§āϰā§āĻļāύ āĻĒā§āϰāĻŋāϏā§āĻ",
|
||||
"disable_login": "āϞāĻāĻāύ āĻ
āĻā§āώāĻŽ āĻāϰā§āύ",
|
||||
"duplicate_detection_job_description": "āĻ
āύā§āϰā§āĻĒ āĻāĻŦāĻŋ āϏāύāĻžāĻā§āϤ āĻāϰāϤ⧠āϏāĻŽā§āĻĒāĻĻāĻā§āϞāĻŋāϤ⧠āĻŽā§āĻļāĻŋāύ āϞāĻžāϰā§āύāĻŋāĻ āĻāĻžāϞāĻžāύāĨ¤ āϏā§āĻŽāĻžāϰā§āĻ āĻ
āύā§āϏāύā§āϧāĻžāύā§āϰ āĻāĻĒāϰ āύāĻŋāϰā§āĻāϰ āĻāϰā§",
|
||||
"exclusion_pattern_description": "āĻāĻā§āϏāĻā§āϞā§āĻļāύ āĻĒā§āϝāĻžāĻāĻžāϰā§āύ āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰ⧠āĻāĻĒāύāĻŋ āĻāĻĒāύāĻžāϰ āϞāĻžāĻāĻŦā§āϰā§āϰāĻŋ āϏā§āĻā§āϝāĻžāύ āĻāϰāĻžāϰ āϏāĻŽāϝāĻŧ āĻĢāĻžāĻāϞ āĻāĻŦāĻ āĻĢā§āϞā§āĻĄāĻžāϰāĻā§āϞāĻŋāĻā§ āĻāĻĒā§āĻā§āώāĻž āĻāϰāϤ⧠āĻĒāĻžāϰāĻŦā§āύāĨ¤ āϝāĻĻāĻŋ āĻāĻĒāύāĻžāϰ āĻāĻŽāύ āĻĢā§āϞā§āĻĄāĻžāϰ āĻĨāĻžāĻā§ āϝā§āĻāĻžāύ⧠āĻāĻŽāύ āĻĢāĻžāĻāϞ āĻĨāĻžāĻā§ āϝāĻž āĻāĻĒāύāĻŋ āĻāĻŽāĻĻāĻžāύāĻŋ āĻāϰāϤ⧠āĻāĻžāύ āύāĻž, āϝā§āĻŽāύ RAW āĻĢāĻžāĻāϞāĨ¤",
|
||||
"export_config_as_json_description": "āĻŦāϰā§āϤāĻŽāĻžāύ āϏāĻŋāϏā§āĻā§āĻŽ āĻāύāĻĢāĻŋāĻāĻžāϰā§āĻļāύ āĻāĻāĻāĻŋ JSON āĻĢāĻžāĻāϞ āĻšāĻŋāϏā§āĻŦā§ āĻĄāĻžāĻāύāϞā§āĻĄ āĻāϰā§āύ",
|
||||
"external_libraries_page_description": "āĻ
ā§āϝāĻžāĻĄāĻŽāĻŋāύ external āϞāĻžāĻāĻŦā§āϰā§āϰāĻŋ āĻĒā§āĻ",
|
||||
"face_detection": "āĻŽā§āĻ āϏāύāĻžāĻā§āϤāĻāϰāĻŖ",
|
||||
"face_detection_description": "āĻŽā§āĻļāĻŋāύ āϞāĻžāϰā§āύāĻŋāĻ āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰ⧠āĻ
ā§āϝāĻžāϏā§āĻā§ āĻĨāĻžāĻāĻž āĻŽā§āĻ/āĻā§āĻšāĻžāϰāĻž āĻā§āϞāĻŋ āϏāύāĻžāĻā§āϤ āĻāϰā§āύāĨ¤ āĻāĻŋāĻĄāĻŋāĻ āĻā§āϞāĻŋāϰ āĻāύā§āϝ, āĻļā§āϧā§āĻŽāĻžāϤā§āϰ āĻĨāĻžāĻŽā§āĻŦāύā§āĻāϞ āĻŦāĻŋāĻŦā§āĻāύāĻž āĻāϰāĻž āĻšāϝāĻŧāĨ¤ \"āϰāĻŋāĻĢā§āϰā§āĻļ\" (āĻĒā§āύāϰāĻžāϝāĻŧ) āϏāĻŽāϏā§āϤ āĻ
ā§āϝāĻžāϏā§āĻ āĻĒā§āϰāĻā§āϰāĻŋāϝāĻŧāĻž āĻāϰā§āĨ¤ \"āϰāĻŋāϏā§āĻ\" āĻāϰāĻžāϰ āĻŽāĻžāϧā§āϝāĻŽā§ āĻ
āϤāĻŋāϰāĻŋāĻā§āϤāĻāĻžāĻŦā§ āϏāĻŽāϏā§āϤ āĻŦāϰā§āϤāĻŽāĻžāύ āĻŽā§āĻā§āϰ āĻĄā§āĻāĻž āϏāĻžāĻĢ āĻāϰā§āĨ¤ \"āĻ
āύā§āĻĒāϏā§āĻĨāĻŋāϤ\" āĻ
ā§āϝāĻžāϏā§āĻāĻā§āϞāĻŋāĻā§ āϏāĻžāϰāĻŋāĻŦāĻĻā§āϧ āĻāϰ⧠āϝāĻž āĻāĻāύāĻ āĻĒā§āϰāĻā§āϰāĻŋāϝāĻŧāĻž āĻāϰāĻž āĻšāϝāĻŧāύāĻŋāĨ¤ āϏāύāĻžāĻā§āϤ āĻāϰāĻž āĻŽā§āĻāĻā§āϞāĻŋāĻā§ āĻĢā§āϏāĻŋāϝāĻŧāĻžāϞ āϰāĻŋāĻāĻāύāĻŋāĻļāύā§āϰ āĻāύā§āϝ āϏāĻžāϰāĻŋāĻŦāĻĻā§āϧ āĻāϰāĻž āĻšāĻŦā§, āĻĢā§āϏāĻŋāϝāĻŧāĻžāϞ āĻĄāĻŋāĻā§āĻāĻļāύ āϏāĻŽā§āĻĒā§āϰā§āĻŖ āĻšāĻāϝāĻŧāĻžāϰ āĻĒāϰā§, āĻŦāĻŋāĻĻā§āϝāĻŽāĻžāύ āĻŦāĻž āύāϤā§āύ āĻŦā§āϝāĻā§āϤāĻŋāĻĻā§āϰ āĻŽāϧā§āϝ⧠āĻā§āώā§āĻ ā§āĻŦāĻĻā§āϧ āĻāϰā§āĨ¤",
|
||||
"duplicate_detection_job_description": "āϏāĻĻā§āĻļ āĻāĻŦāĻŋ āĻļāύāĻžāĻā§āϤ āĻāϰāϤ⧠āĻ
ā§āϝāĻžāϏā§āĻāĻā§āϞā§āϰ āĻāĻĒāϰ āĻŽā§āĻļāĻŋāύ āϞāĻžāϰā§āύāĻŋāĻ āĻāĻžāϞāĻžāύāĨ¤ āĻāĻāĻŋ Smart Search-āĻāϰ āĻāĻĒāϰ āύāĻŋāϰā§āĻāϰ āĻāϰā§",
|
||||
"exclusion_pattern_description": "āĻāĻā§āϏāĻā§āϞā§āĻļāύ āĻĒā§āϝāĻžāĻāĻžāϰā§āύ āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰ⧠āϞāĻžāĻāĻŦā§āϰā§āϰāĻŋ āϏā§āĻā§āϝāĻžāύ āĻāϰāĻžāϰ āϏāĻŽāϝāĻŧ āύāĻŋāϰā§āĻĻāĻŋāώā§āĻ āĻĢāĻžāĻāϞ āĻ āĻĢā§āϞā§āĻĄāĻžāϰ āĻāĻĒā§āĻā§āώāĻž āĻāϰāĻž āϝāĻžāϝāĻŧāĨ¤ āĻāĻāĻŋ āϤāĻāύāĻ āĻāĻĒāĻāĻžāϰ⧠āϝāĻāύ āĻāĻŋāĻā§ āĻĢā§āϞā§āĻĄāĻžāϰ⧠āĻāĻŽāύ āĻĢāĻžāĻāϞ āĻĨāĻžāĻā§ āϝāĻž āĻāĻĒāύāĻŋ āĻāĻŽāĻĒā§āϰā§āĻ āĻāϰāϤ⧠āĻāĻžāύ āύāĻž, āϝā§āĻŽāύ RAW āĻĢāĻžāĻāϞāĨ¤",
|
||||
"export_config_as_json_description": "āĻŦāϰā§āϤāĻŽāĻžāύ āϏāĻŋāϏā§āĻā§āĻŽ āĻāύāĻĢāĻŋāĻāĻžāϰā§āĻļāύāĻāĻŋāĻā§ āĻāĻāĻāĻŋ JSON āĻĢāĻžāĻāϞ āĻšāĻŋāϏā§āĻŦā§ āĻĄāĻžāĻāύāϞā§āĻĄ āĻāϰā§āύ",
|
||||
"external_libraries_page_description": "āĻ
ā§āϝāĻžāĻĄāĻŽāĻŋāύ āĻāĻā§āϏāĻāĻžāϰā§āύāĻžāϞ āϞāĻžāĻāĻŦā§āϰā§āϰāĻŋ āĻĒā§āĻ",
|
||||
"face_detection": "āĻŽā§āĻ āĻļāύāĻžāĻā§āϤāĻāϰāĻŖ",
|
||||
"face_detection_description": "āĻŽā§āĻļāĻŋāύ āϞāĻžāϰā§āύāĻŋāĻ āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰ⧠āĻ
ā§āϝāĻžāϏā§āĻā§ āĻĨāĻžāĻāĻž āĻŽā§āĻ/āĻā§āĻšāĻžāϰāĻž āĻļāύāĻžāĻā§āϤ āĻāϰā§āύāĨ¤ āĻāĻŋāĻĄāĻŋāĻāϰ āĻā§āώā§āϤā§āϰ⧠āĻļā§āϧā§āĻŽāĻžāϤā§āϰ āĻĨāĻžāĻŽā§āĻŦāύā§āĻāϞ āĻŦāĻŋāĻŦā§āĻāύāĻž āĻāϰāĻž āĻšāϝāĻŧāĨ¤ \"āϰāĻŋāĻĢā§āϰā§āĻļ\" āϏāĻŦ āĻ
ā§āϝāĻžāϏā§āĻ āĻĒā§āύāϰāĻžāϝāĻŧ āĻĒā§āϰāĻā§āϰāĻŋāϝāĻŧāĻž āĻāϰā§āĨ¤ \"āϰāĻŋāϏā§āĻ\" āĻāϰāϞ⧠āĻŦāĻŋāĻĻā§āϝāĻŽāĻžāύ āϏāĻŦ āĻŽā§āĻā§āϰ āĻĄā§āĻāĻž āĻŽā§āĻā§ āϝāĻžāϝāĻŧāĨ¤ \"āĻŽāĻŋāϏāĻŋāĻ\" āĻāĻ āĻ
ā§āϝāĻžāϏā§āĻāĻā§āϞā§āĻā§ āϏāĻžāϰāĻŋāϤ⧠āϝā§āĻ āĻāϰ⧠āϝāĻžāĻĻā§āϰāĻā§ āĻāĻāύ⧠āĻĒā§āϰāĻā§āϰāĻŋāϝāĻŧāĻž āĻāϰāĻž āĻšāϝāĻŧāύāĻŋāĨ¤ āĻĢā§āϏ āĻĄāĻŋāĻā§āĻāĻļāύ āϏāĻŽā§āĻĒāύā§āύ āĻšāϞ⧠āĻļāύāĻžāĻā§āϤ āĻšāĻāϝāĻŧāĻž āĻŽā§āĻāĻā§āϞ⧠āĻĢā§āϏāĻŋāϝāĻŧāĻžāϞ āϰāĻŋāĻāĻāύāĻŋāĻļāύā§āϰ āĻāύā§āϝ āϏāĻžāϰāĻŋāϤ⧠āϝā§āĻ āĻāϰāĻž āĻšāĻŦā§ āĻāĻŦāĻ āϏā§āĻā§āϞā§āĻā§ āĻŦāĻŋāĻĻā§āϝāĻŽāĻžāύ āĻŦāĻž āύāϤā§āύ āĻŦā§āϝāĻā§āϤāĻŋāĻĻā§āϰ āϏāĻžāĻĨā§ āĻā§āϰā§āĻĒ āĻāϰāĻž āĻšāĻŦā§āĨ¤",
|
||||
"facial_recognition_job_description": "āĻļāύāĻžāĻā§āϤ āĻāϰāĻž āĻŽā§āĻāĻā§āϞāĻŋāĻā§ āĻŽāĻžāύā§āώā§āϰ āĻŽāϧā§āϝ⧠āĻā§āώā§āĻ ā§āĻā§āĻā§āϤ/āĻā§āϰā§āĻĒ āĻāϰā§āύāĨ¤ āĻŽā§āĻ āϏāύāĻžāĻā§āϤāĻāϰāĻŖ āϏāĻŽā§āĻĒā§āϰā§āĻŖ āĻšāĻāϝāĻŧāĻžāϰ āĻĒāϰ⧠āĻāĻ āϧāĻžāĻĒāĻāĻŋ āĻāϞā§āĨ¤ \"āϰāĻŋāϏā§āĻ\" (āĻĒā§āύāϰāĻžāϝāĻŧ) āϏāĻŽāϏā§āϤ āĻŽā§āĻāĻā§ āĻā§āϞāĻžāϏā§āĻāĻžāϰ āĻāϰā§āĨ¤ \"āĻ
āύā§āĻĒāϏā§āĻĨāĻŋāϤ/āĻŽāĻŋāϏāĻŋāĻ\" āĻŽā§āĻāĻā§āϞāĻŋāĻā§ āϏāĻžāϰāĻŋāϤ⧠āϰāĻžāĻā§ āϝā§āĻā§āϞ⧠āĻā§āύāĻ āĻŦā§āϝāĻā§āϤāĻŋāĻā§ āĻāϏāĻžāĻāύ/āĻŦāϰāĻžāĻĻā§āĻĻ āĻāϰāĻž āĻšāϝāĻŧāύāĻŋāĨ¤",
|
||||
"failed_job_command": "āĻāĻŽāĻžāύā§āĻĄ {command} āĻāĻžāĻā§āϰ āĻāύā§āϝ āĻŦā§āϝāϰā§āĻĨ āĻšāϝāĻŧā§āĻā§: {job}",
|
||||
"force_delete_user_warning": "āϏāϤāϰā§āĻāϤāĻž: āĻāĻāĻŋ āĻŦā§āϝāĻŦāĻšāĻžāϰāĻāĻžāϰ⧠āĻāĻŦāĻ āϏāĻŽāϏā§āϤ āϏāĻŽā§āĻĒāĻĻ āĻ
āĻŦāĻŋāϞāĻŽā§āĻŦā§ āϏāϰāĻŋāϝāĻŧā§ āĻĢā§āϞāĻŦā§āĨ¤ āĻāĻāĻŋ āĻĒā§āϰā§āĻŦāĻžāĻŦāϏā§āĻĨāĻžāϝāĻŧ āĻĢā§āϰāĻžāύ⧠āϝāĻžāĻŦā§ āύāĻž āĻāĻŦāĻ āĻĢāĻžāĻāϞāĻā§āϞāĻŋ āĻĒā§āύāϰā§āĻĻā§āϧāĻžāϰ āĻāϰāĻž āϝāĻžāĻŦā§ āύāĻžāĨ¤",
|
||||
@@ -98,9 +98,9 @@
|
||||
"image_fullsize_quality_description": "āĻĒā§āϰā§āĻŖ-āĻāĻāĻžāϰā§āϰ āĻāĻŦāĻŋāϰ āĻŽāĻžāύ ā§§-ā§§ā§Ļā§ĻāĨ¤ āĻāĻā§āĻāϤāϰ āĻšāϞ⧠āĻāĻžāϞā§, āĻāĻŋāύā§āϤ⧠āĻāϰāĻ āĻŦāĻĄāĻŧ āĻĢāĻžāĻāϞ āϤā§āϰāĻŋ āĻšāϝāĻŧāĨ¤",
|
||||
"image_fullsize_title": "āĻĒā§āϰā§āĻŖ-āĻāĻāĻžāϰā§āϰ āĻāĻŋāϤā§āϰ āϏā§āĻāĻŋāĻāϏ",
|
||||
"image_prefer_embedded_preview": "āĻāĻŽā§āĻŦā§āĻĄ āĻāϰāĻž āĻĒā§āϰāĻŋāĻāĻŋāĻ āĻĒāĻāύā§āĻĻ āĻāϰā§āύ",
|
||||
"image_prefer_embedded_preview_setting_description": "āϝāĻĻāĻŋ āĻĒāĻžāĻā§āĻž āϝāĻžā§, RAW āĻāĻŦāĻŋāϰ āĻā§āϤāϰ⧠āĻĨāĻžāĻāĻž āĻĒā§āϰāĻŋāĻāĻŋāĻ āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰā§āύāĨ¤ āĻāϤ⧠āĻāĻŋāĻā§ āĻāĻŦāĻŋāϰ āϰāĻ āĻāϰāĻ āϏāĻ āĻŋāĻ āĻĻā§āĻāĻž āϝā§āϤ⧠āĻĒāĻžāϰā§, āϤāĻŦā§ āĻŽāĻžāύ āĻā§āϝāĻžāĻŽā§āϰāĻžāϰ āĻāĻĒāϰ āύāĻŋāϰā§āĻāϰ āĻāϰ⧠āĻāĻŦāĻ āĻāĻŦāĻŋāϤ⧠āĻŦāĻžā§āϤāĻŋ āĻāĻŽāĻĒā§āϰā§āĻļāύ āĻāϰā§āĻāĻŋāĻĢā§āϝāĻžāĻā§āĻ āĻĻā§āĻāĻž āϝā§āϤ⧠āĻĒāĻžāϰā§āĨ¤",
|
||||
"image_prefer_embedded_preview_setting_description": "RAW āĻāĻŦāĻŋāϤ⧠āĻĨāĻžāĻāĻž āĻāĻŽāĻŦā§āĻĄā§āĻĄ āĻĒā§āϰāĻŋāĻāĻŋāĻāĻā§āϞā§āĻā§ āĻāĻŽā§āĻ āĻĒā§āϰāϏā§āϏāĻŋāĻāϝāĻŧā§āϰ āĻāύāĻĒā§āĻ āĻšāĻŋāϏā§āĻŦā§ āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰā§āύ āϝāĻĻāĻŋ āϤāĻž āĻāĻĒāϞāĻā§āϝ āĻĨāĻžāĻā§āĨ¤ āĻāϤ⧠āĻāĻŋāĻā§ āĻāĻŦāĻŋāϰ āϰāĻ āĻāϰāĻ āϏāĻ āĻŋāĻāĻāĻžāĻŦā§ āĻĒāĻžāĻāϝāĻŧāĻž āϝā§āϤ⧠āĻĒāĻžāϰā§, āϤāĻŦā§ āĻĒā§āϰāĻŋāĻāĻŋāĻāϝāĻŧā§āϰ āĻŽāĻžāύ āĻā§āϝāĻžāĻŽā§āϰāĻžāϰ āĻāĻĒāϰ āύāĻŋāϰā§āĻāϰ āĻāϰ⧠āĻāĻŦāĻ āĻāĻŦāĻŋāϤ⧠āĻŦā§āĻļāĻŋ āĻāĻŽāĻĒā§āϰā§āĻļāύ āĻāϰā§āĻāĻŋāĻĢā§āϝāĻžāĻā§āĻ āĻĨāĻžāĻāϤ⧠āĻĒāĻžāϰā§āĨ¤",
|
||||
"image_prefer_wide_gamut": "āĻĒā§āϰāĻļāϏā§āϤ āĻĒāϰāĻŋāϏāϰ āĻĒāĻāύā§āĻĻ āĻāϰā§āύ",
|
||||
"image_prefer_wide_gamut_setting_description": "āĻĨāĻžāĻŽā§āĻŦāύā§āĻāϞā§āϰ āĻāύā§āϝ Display P3 āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰā§āύāĨ¤ āĻāĻāĻŋ āĻā§āĻžāĻāĻĄ āĻāĻžāϞāĻžāϰāϏā§āĻĒā§āϏ āĻāĻŦāĻŋāϰ āĻāĻā§āĻā§āĻŦāϞāϤāĻž āĻ āĻĒā§āϰāĻžāĻŖāĻŦāύā§āϤ āϰāĻ āĻāĻžāϞā§āĻāĻžāĻŦā§ āϧāϰ⧠āϰāĻžāĻā§, āϤāĻŦā§ āĻĒā§āϰāύ⧠āĻĄāĻŋāĻāĻžāĻāϏ āĻŦāĻž āĻŦā§āϰāĻžāĻāĻāĻžāϰ⧠āĻāĻŦāĻŋāĻā§āϞ⧠āĻāĻŋāύā§āύāĻāĻžāĻŦā§ āĻĻā§āĻāĻž āϝā§āϤ⧠āĻĒāĻžāϰā§āĨ¤ sRGB āĻāĻŦāĻŋāĻā§āϞ⧠āϰāĻā§āϰ āĻĒāϰāĻŋāĻŦāϰā§āϤāύ āĻā§āĻžāϤ⧠sRGB āĻšāĻŋāϏā§āĻŦā§āĻ āϰāĻžāĻāĻž āĻšāĻŦā§āĨ¤",
|
||||
"image_prefer_wide_gamut_setting_description": "āĻĨāĻžāĻŽā§āĻŦāύā§āĻāϞā§āϰ āĻāύā§āϝ Display P3 āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰā§āύāĨ¤ āĻāϤ⧠āĻŦāĻŋāϏā§āϤā§āϰā§āĻŖ āĻāĻžāϞāĻžāϰāϏā§āĻĒā§āϏā§āϰ āĻāĻŦāĻŋāϤ⧠āĻāĻŦāĻŋāϰ āĻāĻā§āĻā§āĻŦāϞāϤāĻž āĻ āĻĒā§āϰāĻžāĻŖāĻŦāύā§āϤāϤāĻž āĻāϰāĻ āĻāĻžāϞā§āĻāĻžāĻŦā§ āĻŦāĻāĻžāϝāĻŧ āĻĨāĻžāĻā§āĨ¤ āϤāĻŦā§ āĻĒā§āϰā§āύ⧠āĻĄāĻŋāĻāĻžāĻāϏ āĻŦāĻž āĻĒā§āϰā§āύ⧠āĻŦā§āϰāĻžāĻāĻāĻžāϰā§āϰ āĻĻā§āώ⧠āĻāĻŦāĻŋāĻā§āϞ⧠āĻāĻŋāĻā§āĻāĻž āĻāĻŋāύā§āύāĻāĻžāĻŦā§ āĻĻā§āĻāĻž āĻĻāĻŋāϤ⧠āĻĒāĻžāϰā§āĨ¤ sRGB āĻāĻŦāĻŋāĻā§āϞā§āϤ⧠āϰāĻā§āϰ āĻĒāϰāĻŋāĻŦāϰā§āϤāύ āĻāĻĄāĻŧāĻžāϤ⧠sRGB āĻšāĻŋāϏā§āĻŦā§āĻ āϰāĻžāĻāĻž āĻšāϝāĻŧāĨ¤",
|
||||
"image_preview_description": "āϏā§āĻā§āϰāĻŋāĻĒāĻĄ āĻŽā§āĻāĻžāĻĄā§āĻāĻž āϏāĻš āĻŽāĻžāĻāĻžāϰāĻŋ āĻāĻāĻžāϰā§āϰ āĻāĻŦāĻŋ, āĻāĻāĻāĻŋ āĻāĻāĻ āϏāĻŽā§āĻĒāĻĻ āĻĻā§āĻāĻžāϰ āϏāĻŽāϝāĻŧ āĻāĻŦāĻ āĻŽā§āĻļāĻŋāύ āϞāĻžāϰā§āύāĻŋāĻāϝāĻŧā§āϰ āĻāύā§āϝ āĻŦā§āϝāĻŦāĻšā§āϤ āĻšāϝāĻŧ",
|
||||
"image_preview_quality_description": "ā§§-ā§§ā§Ļā§Ļ āĻāϰ āĻŽāϧā§āϝ⧠āĻĒā§āϰāĻŋāĻāĻŋāĻ āĻā§āϝāĻŧāĻžāϞāĻŋāĻāĻŋāĨ¤ āĻŦā§āĻļāĻŋ āĻšāϞ⧠āĻāĻžāϞā§, āĻāĻŋāύā§āϤ⧠āĻŦāĻĄāĻŧ āĻĢāĻžāĻāϞ āϤā§āϰāĻŋ āĻšāϝāĻŧ āĻāĻŦāĻ āĻ
ā§āϝāĻžāĻĒā§āϰ āĻĒā§āϰāϤāĻŋāĻā§āϰāĻŋāϝāĻŧāĻžāĻļā§āϞāϤāĻž āĻāĻŽāĻžāϤ⧠āĻĒāĻžāϰā§āĨ¤ āĻāĻŽ āĻŽāĻžāύ āϏā§āĻ āĻāϰāϞ⧠āĻŽā§āĻļāĻŋāύ āϞāĻžāϰā§āύāĻŋāĻ āĻā§āϝāĻŧāĻžāϞāĻŋāĻāĻŋāϰ āĻāĻĒāϰ āĻĒā§āϰāĻāĻžāĻŦ āĻĒāĻĄāĻŧāϤ⧠āĻĒāĻžāϰā§āĨ¤",
|
||||
"image_preview_title": "āĻĒā§āϰāĻŋāĻāĻŋāĻ āϏā§āĻāĻŋāĻāϏ",
|
||||
@@ -117,7 +117,7 @@
|
||||
"import_config_from_json_description": "āĻāĻāĻāĻŋ JSON āĻāύāĻĢāĻŋāĻ āĻĢāĻžāĻāϞ āĻāĻĒāϞā§āĻĄ āĻāϰ⧠āϏāĻŋāϏā§āĻā§āĻŽ āĻāύāĻĢāĻŋāĻāĻžāϰā§āĻļāύ āĻāĻŽāĻĒā§āϰā§āĻ āĻāϰā§āύāĨ¤",
|
||||
"job_concurrency": "{job} āĻāύāĻāĻžāϰā§āύā§āϏāĻŋ",
|
||||
"job_created": "Job āϤā§āϰāĻŋ āĻšāϝāĻŧā§āĻā§",
|
||||
"job_not_concurrency_safe": "āĻāĻ āĻāĻžāĻāĻāĻŋ āϏāĻŽāĻžāύā§āϤāϰāĻžāϞāĻāĻžāĻŦā§ āĻāĻžāϞāĻžāύ⧠āύāĻŋāϰāĻžāĻĒāĻĻ āύā§",
|
||||
"job_not_concurrency_safe": "āĻāĻ āĻāĻžāĻāĻāĻŋ āϏāĻŽāĻžāύā§āϤāϰāĻžāϞāĻāĻžāĻŦā§ āĻāĻžāϞāĻžāύ⧠āύāĻŋāϰāĻžāĻĒāĻĻ āύā§āĨ¤",
|
||||
"job_settings": "āĻāĻžāĻā§āϰ āϏā§āĻāĻŋāĻāϏ",
|
||||
"job_settings_description": "āĻāĻžāĻā§āϰ āϏāĻŽāĻžāύā§āϤāϰāĻžāϞāϤāĻž āĻĒāϰāĻŋāĻāĻžāϞāύāĻž āĻāϰā§āύ",
|
||||
"jobs_delayed": "{jobCount, plural, other {# āĻŦāĻŋāϞāĻŽā§āĻŦāĻŋāϤ}}",
|
||||
@@ -137,20 +137,20 @@
|
||||
"library_tasks_description": "āύāϤā§āύ āĻāĻŦāĻ/āĻ
āĻĨāĻŦāĻž āĻĒāϰāĻŋāĻŦāϰā§āϤāĻŋāϤ āϏāĻŽā§āĻĒāĻĻā§āϰ āĻāύā§āϝ āĻŦāĻšāĻŋāϰāĻžāĻāϤ āϞāĻžāĻāĻŦā§āϰā§āϰāĻŋ āϏā§āĻā§āϝāĻžāύ āĻāϰā§āύ",
|
||||
"library_updated": "āĻāĻĒāĻĄā§āĻāĻā§āϤ āϞāĻžāĻāĻŦā§āϰā§āϰāĻŋāĨ¤",
|
||||
"library_watching_enable_description": "āĻĢāĻžāĻāϞ āĻĒāϰāĻŋāĻŦāϰā§āϤāύā§āϰ āĻāύā§āϝ āĻŦāĻšāĻŋāϰāĻžāĻāϤ āϞāĻžāĻāĻŦā§āϰā§āϰāĻŋāĻā§āϞāĻŋ āĻĻā§āĻā§āύ",
|
||||
"library_watching_settings": "āϞāĻžāĻāĻŦā§āϰā§āϰāĻŋ āĻĻā§āĻāĻž (āĻĒāϰā§āĻā§āώāĻžāĻŽā§āϞāĻ)",
|
||||
"library_watching_settings": "āϞāĻžāĻāĻŦā§āϰā§āϰāĻŋ āĻĒāϰā§āϝāĻŦā§āĻā§āώāĻŖ [āĻĒāϰā§āĻā§āώāĻžāĻŽā§āϞāĻ]",
|
||||
"library_watching_settings_description": "āĻĒāϰāĻŋāĻŦāϰā§āϤāĻŋāϤ āĻĢāĻžāĻāϞāĻā§āϞāĻŋāϰ āĻāύā§āϝ āϏā§āĻŦāϝāĻŧāĻāĻā§āϰāĻŋāϝāĻŧāĻāĻžāĻŦā§ āύāĻāϰ āϰāĻžāĻā§āύ",
|
||||
"logging_enable_description": "āϞāĻāĻŋāĻ āĻāύāĻžāĻŦāϞ/āϏāĻā§āώāĻŽ āĻāϰā§āύ",
|
||||
"logging_level_description": "āϏāĻā§āϰāĻŋāϝāĻŧ āĻĨāĻžāĻāĻžāĻāĻžāϞā§āύ, āĻā§āύ āϞāĻ āϏā§āϤāϰ āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰāϤ⧠āĻšāĻŦā§āĨ¤",
|
||||
"logging_settings": "āϞāĻāĻŋāĻ",
|
||||
"machine_learning_availability_checks": "āĻĒā§āϰāĻžāĻĒā§āϝāϤāĻž āĻĒāϰā§āĻā§āώāĻž",
|
||||
"machine_learning_availability_checks_description": "āϏā§āĻŦāϝāĻŧāĻāĻā§āϰāĻŋāϝāĻŧāĻāĻžāĻŦā§ āĻāĻĒāϞāĻŦā§āϧ āĻŽā§āĻļāĻŋāύ āϞāĻžāϰā§āύāĻŋāĻ āϏāĻžāϰā§āĻāĻžāϰāĻā§āϞāĻŋ āϏāύāĻžāĻā§āϤ āĻāϰā§āύ āĻāĻŦāĻ āĻĒāĻāύā§āĻĻ āĻāϰā§āύ",
|
||||
"machine_learning_availability_checks_description": "āĻāĻĒāϞāĻā§āϝ āĻŽā§āĻļāĻŋāύ āϞāĻžāϰā§āύāĻŋāĻ āϏāĻžāϰā§āĻāĻžāϰāĻā§āϞ⧠āϏā§āĻŦāϝāĻŧāĻāĻā§āϰāĻŋāϝāĻŧāĻāĻžāĻŦā§ āĻļāύāĻžāĻā§āϤ āĻāϰ⧠āϏā§āĻā§āϞā§āĻā§ āĻ
āĻā§āϰāĻžāϧāĻŋāĻāĻžāϰ āĻĻāĻŋāύ",
|
||||
"machine_learning_availability_checks_enabled": "āĻĒā§āϰāĻžāĻĒā§āϝāϤāĻž āĻĒāϰā§āĻā§āώāĻž āϏāĻā§āώāĻŽ āĻāϰā§āύ",
|
||||
"machine_learning_availability_checks_interval": "āĻā§āĻ āĻŦā§āϝāĻŦāϧāĻžāύ",
|
||||
"machine_learning_availability_checks_interval_description": "āĻĒā§āϰāĻžāĻĒā§āϝāϤāĻž āĻĒāϰā§āĻā§āώāĻžāĻā§āϞāĻŋāϰ āĻŽāϧā§āϝ⧠āĻŦā§āϝāĻŦāϧāĻžāύ āĻŽāĻŋāϞāĻŋāϏā§āĻā§āύā§āĻĄā§",
|
||||
"machine_learning_availability_checks_timeout": "āĻ
āύā§āϰā§āϧā§āϰ āϏāĻŽā§āϏā§āĻŽāĻž āĻļā§āώ",
|
||||
"machine_learning_availability_checks_timeout_description": "āĻĒā§āϰāĻžāĻĒā§āϝāϤāĻžāϰ āĻĒāϰā§āĻā§āώāĻžāϰ āĻāύā§āϝ āĻŽāĻŋāϞāĻŋāϏā§āĻā§āύā§āĻĄā§ āϏāĻŽā§āϏā§āĻŽāĻžāĨ¤",
|
||||
"machine_learning_clip_model": "CLIP āĻŽāĻĄā§āϞ",
|
||||
"machine_learning_clip_model_description": "<link>āĻāĻāĻžāύā§</link> āϤāĻžāϞāĻŋāĻāĻžāĻā§āĻā§āϤ āĻāĻāĻāĻŋ CLIP āĻŽāĻĄā§āϞā§āϰ āύāĻžāĻŽāĨ¤ āĻŽāύ⧠āϰāĻžāĻāĻŦā§āύ, āĻŽāĻĄā§āϞ āĻĒāϰāĻŋāĻŦāϰā§āϤāύā§āϰ āĻĒāϰ āϏāĻŦ āĻāĻŦāĻŋāϰ āĻāύā§āϝ āĻ
āĻŦāĻļā§āϝāĻ âSmart Searchâ āĻāĻžāĻāĻāĻŋ āĻāĻŦāĻžāϰ āĻāĻžāϞāĻžāϤ⧠āĻšāĻŦā§āĨ¤",
|
||||
"machine_learning_clip_model_description": "<link>āĻāĻāĻžāύā§</link> āϤāĻžāϞāĻŋāĻāĻžāĻā§āĻā§āϤ āĻāĻāĻāĻŋ CLIP āĻŽāĻĄā§āϞā§āϰ āύāĻžāĻŽāĨ¤ āĻŽāύ⧠āϰāĻžāĻāĻŦā§āύ, āĻŽāĻĄā§āϞ āĻĒāϰāĻŋāĻŦāϰā§āϤāύ āĻāϰāϞ⧠āϏāĻŦ āĻāĻŦāĻŋāϰ āĻāύā§āϝ 'Smart Search' āĻāĻŦāĻāĻŋ āĻĒā§āύāϰāĻžāϝāĻŧ āĻāĻžāϞāĻžāϤ⧠āĻšāĻŦā§āĨ¤",
|
||||
"machine_learning_duplicate_detection": "āĻĒā§āύāϰāĻžāĻŦā§āϤā§āϤāĻŋ āϏāύāĻžāĻā§āϤāĻāϰāĻŖ",
|
||||
"machine_learning_duplicate_detection_enabled": "āĻĒā§āύāϰāĻžāĻŦā§āϤā§āϤāĻŋ āĻļāύāĻžāĻā§āϤāĻāϰāĻŖ āĻāĻžāϞ⧠āĻāϰā§āύ",
|
||||
"machine_learning_duplicate_detection_enabled_description": "āύāĻŋāώā§āĻā§āϰāĻŋā§ āĻĨāĻžāĻāϞā§āĻ āĻšā§āĻŦāĻšā§ āĻāĻāĻ āϏāĻŽā§āĻĒāĻĻāĻā§āϞā§āϰ āĻĄā§āĻĒā§āϞāĻŋāĻā§āĻ āϏāϰāĻŋā§ā§ āĻĢā§āϞāĻž āĻšāĻŦā§āĨ¤",
|
||||
@@ -192,7 +192,7 @@
|
||||
"machine_learning_url_description": "āĻŽā§āĻļāĻŋāύ āϞāĻžāϰā§āύāĻŋāĻ āϏāĻžāϰā§āĻāĻžāϰā§āϰ URLāĨ¤ āϝāĻĻāĻŋ āĻāĻā§āϰ āĻŦā§āĻļāĻŋ URL āĻĒā§āϰāĻĻāĻžāύ āĻāϰāĻž āĻšā§, āϤāĻŦā§ āĻāĻāĻāĻŋ āϏāĻĢāϞāĻāĻžāĻŦā§ āϏāĻžā§āĻž āύāĻž āĻĻā§āĻā§āĻž āĻĒāϰā§āϝāύā§āϤ āĻĒā§āϰāϤāĻŋāĻāĻŋ āϏāĻžāϰā§āĻāĻžāϰ⧠āĻāĻ āĻāĻ āĻāϰ⧠āĻā§āώā§āĻāĻž āĻāϰāĻž āĻšāĻŦā§ (āĻĒā§āϰāĻĨāĻŽ āĻĨā§āĻā§ āĻļā§āώ āĻā§āϰāĻŽāĻžāύā§āϏāĻžāϰā§)āĨ¤ āϝ⧠āϏāĻžāϰā§āĻāĻžāϰāĻā§āϞ⧠āϏāĻžā§āĻž āĻĻā§āĻŦā§ āύāĻž, āϏā§āĻā§āϞ⧠āĻĒā§āύāϰāĻžā§ āϏāĻāϞ āĻšāĻā§āĻž āĻĒāϰā§āϝāύā§āϤ āϏāĻžāĻŽā§āĻŋāĻāĻāĻžāĻŦā§ āĻāĻĒā§āĻā§āώāĻž āĻāϰāĻž āĻšāĻŦā§āĨ¤",
|
||||
"maintenance_delete_backup": "āĻŦā§āϝāĻžāĻāĻāĻĒ (Backup)āĻŽā§āĻā§āύ",
|
||||
"maintenance_delete_backup_description": "āĻāĻ āĻĢāĻžāĻāϞāĻāĻŋ āĻāĻŋāϰāϤāϰ⧠āĻŽā§āĻā§ āĻĢā§āϞāĻž āĻšāĻŦā§āĨ¤",
|
||||
"maintenance_delete_error": "āĻŦā§āϝāĻžāĻāĻāĻĒ āĻŽā§āĻāϤ⧠āĻŦā§āϝāϰā§āĻĨ āĻšā§ā§āĻā§āĨ¤",
|
||||
"maintenance_delete_error": "āĻŦā§āϝāĻžāĻāĻāĻĒ āĻŽā§āĻā§ āĻĢā§āϞāϤ⧠āĻŦā§āϝāϰā§āĻĨ āĻšā§ā§āĻā§āĨ¤",
|
||||
"maintenance_restore_backup": "āĻŦā§āϝāĻžāĻāĻāĻĒ āĻĒā§āύāϰā§āĻĻā§āϧāĻžāϰ(Restore) āĻāϰā§āύ",
|
||||
"maintenance_restore_backup_description": "Immich āĻŽā§āĻā§ āĻĢā§āϞāĻž āĻšāĻŦā§ āĻāĻŦāĻ āύāĻŋāϰā§āĻŦāĻžāĻāĻŋāϤ āĻŦā§āϝāĻžāĻāĻāĻĒ āĻĨā§āĻā§ āĻĒā§āύāϰā§āĻĻā§āϧāĻžāϰ āĻāϰāĻž āĻšāĻŦā§āĨ¤ āĻāĻžāϰā§āϝāĻā§āϰāĻŽ āĻāĻžāϞāĻŋā§ā§ āϝāĻžāĻā§āĻžāϰ āĻāĻā§ āĻāĻāĻāĻŋ āĻŦā§āϝāĻžāĻāĻāĻĒ āϤā§āϰāĻŋ āĻāϰāĻž āĻšāĻŦā§āĨ¤",
|
||||
"maintenance_restore_backup_different_version": "āĻāĻ āĻŦā§āϝāĻžāĻāĻāĻĒāĻāĻŋ Immich-āĻāϰ āĻāĻāĻāĻŋ āĻāĻŋāύā§āύ āϏāĻāϏā§āĻāϰāĻŖā§āϰ āĻŽāĻžāϧā§āϝāĻŽā§ āϤā§āϰāĻŋ āĻāϰāĻž āĻšā§ā§āĻāĻŋāϞ!",
|
||||
@@ -220,7 +220,7 @@
|
||||
"map_reverse_geocoding_settings": "āϰāĻŋāĻāĻžāϰā§āϏ āĻāĻŋāĻāĻā§āĻĄāĻŋāĻ āϏā§āĻāĻŋāĻāϏ (Reverse Geocoding Settings)",
|
||||
"map_settings": "āĻŽāĻžāύāĻāĻŋāϤā§āϰ (Map)",
|
||||
"map_settings_description": "āĻŽāĻžāύāĻāĻŋāϤā§āϰā§āϰ āϏā§āĻāĻŋāĻāϏ āĻĒāϰāĻŋāĻāĻžāϞāύāĻž āĻāϰā§āύ (Manage map settings)",
|
||||
"map_style_description": "āĻāĻāĻāĻŋ style.json āĻŽā§āϝāĻžāĻĒ āĻĨāĻŋāĻŽā§āϰ URL (URL to a style.json map theme)",
|
||||
"map_style_description": "style.json āĻŽā§āϝāĻžāĻĒ āĻĨāĻŋāĻŽā§āϰ URL āĻ āĻŋāĻāĻžāύāĻž",
|
||||
"memory_cleanup_job": "āĻŽā§āĻŽāϰāĻŋ āĻā§āϞāĻŋāύāĻāĻĒ (Memory cleanup)",
|
||||
"memory_generate_job": "āϏā§āĻŽā§āϤāĻŋ āϤā§āϰāĻŋ āĻāϰāĻž(Memory generation)",
|
||||
"metadata_extraction_job": "āĻŽā§āĻāĻžāĻĄā§āĻāĻž āĻāĻā§āϏāĻā§āϰā§āϝāĻžāĻā§āĻ āĻāϰā§āύ (Extract metadata)",
|
||||
@@ -295,7 +295,7 @@
|
||||
"search_jobs": "āĻāĻŦ āϏāĻžāϰā§āĻ āĻāϰā§āύâĻ",
|
||||
"send_welcome_email": "āϏā§āĻŦāĻžāĻāϤ āĻāĻŽā§āϞ āĻĒāĻžāĻ āĻžāύ",
|
||||
"server_external_domain_settings": "āĻāĻā§āϏāĻāĻžāϰā§āύāĻžāϞ āĻĄā§āĻŽā§āĻāύ (External Domain)",
|
||||
"server_external_domain_settings_description": "āĻĒāĻžāĻŦāϞāĻŋāĻ āĻļā§āϝāĻŧāĻžāϰāĻŋāĻ āϞāĻŋāĻā§āĻā§āϰ āĻāύā§āϝ āĻĄā§āĻŽā§āĻāύ (http(s):// āϏāĻš)",
|
||||
"server_external_domain_settings_description": "āĻŦāĻžāĻāϰā§āϰ āϞāĻŋāĻā§āĻā§āϰ āĻāύā§āϝ āĻŦā§āϝāĻŦāĻšā§āϤ āĻĄā§āĻŽā§āĻāύ",
|
||||
"server_public_users": "āĻĒāĻžāĻŦāϞāĻŋāĻ āĻāĻāĻāĻžāϰ (Public Users)",
|
||||
"server_public_users_description": "āĻļā§āϝāĻŧāĻžāϰ āĻāϰāĻž āĻ
ā§āϝāĻžāϞāĻŦāĻžāĻŽā§ āĻā§āύ⧠āĻŦā§āϝāĻŦāĻšāĻžāϰāĻāĻžāϰā§āĻā§ āϝā§āĻ āĻāϰāĻžāϰ āϏāĻŽāϝāĻŧ āϏāĻŽāϏā§āϤ āĻŦā§āϝāĻŦāĻšāĻžāϰāĻāĻžāϰā§āϰ (āύāĻžāĻŽ āĻāĻŦāĻ āĻāĻŽā§āϞ) āϤāĻžāϞāĻŋāĻāĻž āĻĻā§āĻāĻžāύ⧠āĻšāϝāĻŧāĨ¤ āĻāĻāĻŋ āύāĻŋāώā§āĻā§āϰāĻŋāϝāĻŧ (Disabled) āĻāϰāĻž āĻšāϞā§, āĻŦā§āϝāĻŦāĻšāĻžāϰāĻāĻžāϰā§āϰ āϤāĻžāϞāĻŋāĻāĻž āĻļā§āϧā§āĻŽāĻžāϤā§āϰ āĻ
ā§āϝāĻžāĻĄāĻŽāĻŋāύāĻĻā§āϰ āĻāύā§āϝ āĻāĻĒāϞāĻŦā§āϧ āĻšāĻŦā§āĨ¤",
|
||||
"server_settings": "āϏāĻžāϰā§āĻāĻžāϰ āϏā§āĻāĻŋāĻāϏ (Server Settings)",
|
||||
@@ -317,9 +317,9 @@
|
||||
"storage_template_migration_description": "āĻĒā§āϰā§āĻŦā§ āĻāĻĒāϞā§āĻĄ āĻāϰāĻž āĻ
ā§āϝāĻžāϏā§āĻāĻā§āϞā§āϤ⧠āĻŦāϰā§āϤāĻŽāĻžāύ <link>{template}</link> āĻĒā§āϰāϝāĻŧā§āĻ āĻāϰā§āύ",
|
||||
"storage_template_migration_info": "āϏā§āĻā§āϰā§āĻ āĻā§āĻŽāĻĒā§āϞā§āĻāĻāĻŋ āϏāĻŽāϏā§āϤ āĻāĻā§āϏāĻā§āύāĻļāύāĻā§ āĻā§āĻ āĻšāĻžāϤā§āϰ āĻ
āĻā§āώāϰ⧠(lowercase) āϰā§āĻĒāĻžāύā§āϤāϰ āĻāϰāĻŦā§āĨ¤ āĻā§āĻŽāĻĒā§āϞā§āĻā§āϰ āĻĒāϰāĻŋāĻŦāϰā§āϤāύāĻā§āϞ⧠āĻā§āĻŦāϞ āύāϤā§āύ āĻ
ā§āϝāĻžāϏā§āĻāĻā§āϞā§āϰ āĻā§āώā§āϤā§āϰ⧠āĻĒā§āϰāϝā§āĻā§āϝ āĻšāĻŦā§āĨ¤ āĻĒā§āϰā§āĻŦā§ āĻāĻĒāϞā§āĻĄ āĻāϰāĻž āĻ
ā§āϝāĻžāϏā§āĻāĻā§āϞā§āϤ⧠āĻāĻ āĻā§āĻŽāĻĒā§āϞā§āĻāĻāĻŋ āĻā§āϤāĻžāĻĒā§āĻā§āώāĻāĻžāĻŦā§ (retroactively) āĻĒā§āϰāϝāĻŧā§āĻ āĻāϰāϤ⧠<link>{job}</link> āϰāĻžāύ āĻāϰā§āύāĨ¤",
|
||||
"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-link> āĻĻā§āĻā§āύ",
|
||||
"storage_template_onboarding_description_v2": "āĻāĻāĻŋ āϏāĻā§āϰāĻŋā§ āĻĨāĻžāĻāϞā§, āĻĢāĻŋāĻāĻžāϰāĻāĻŋ āĻŦā§āϝāĻŦāĻšāĻžāϰāĻāĻžāϰā§āϰ āύāĻŋāϰā§āϧāĻžāϰāĻŋāϤ āĻā§āĻŽāĻĒā§āϞā§āĻ āĻ
āύā§āϝāĻžā§ā§ āĻĢāĻžāĻāϞāĻā§āϞā§āĻā§ āϏā§āĻŦāϝāĻŧāĻāĻā§āϰāĻŋāϝāĻŧāĻāĻžāĻŦā§ āĻ
āϰā§āĻāĻžāύāĻžāĻāĻ (Auto-organize) āĻāϰāĻŦā§āĨ¤ āĻāϰāĻ āϤāĻĨā§āϝā§āϰ āĻāύā§āϝ āĻ
āύā§āĻā§āϰāĻš āĻāϰ⧠<link>āĻĄāĻā§āĻŽā§āύā§āĻā§āĻļāύ</link> āĻĻā§āĻā§āύāĨ¤",
|
||||
"storage_template_path_length": "āĻāύā§āĻŽāĻžāύāĻŋāĻ āĻĒāĻžāĻĨ āϞā§āύā§āĻĨ āϞāĻŋāĻŽāĻŋāĻ (Path length limit): <b>{length, number}</b>/{limit, number}",
|
||||
"storage_template_path_length": "āĻāύā§āĻŽāĻžāύāĻŋāĻāĻāĻžāĻŦā§ āĻĒāĻĨā§āϰ āĻĻā§āϰā§āĻā§āϝā§āϰ āϏā§āĻŽāĻž: <b>{length, number}</b>/{limit, number}",
|
||||
"storage_template_settings": "āϏā§āĻā§āϰā§āĻ āĻā§āĻŽāĻĒā§āϞā§āĻ (Storage Template)",
|
||||
"storage_template_settings_description": "āĻāĻĒāϞā§āĻĄ āĻāϰāĻž āĻ
ā§āϝāĻžāϏā§āĻā§āϰ āĻĢā§āϞā§āĻĄāĻžāϰ āϏā§āĻā§āϰāĻžāĻāĻāĻžāϰ āĻāĻŦāĻ āĻĢāĻžāĻāϞ āύā§āĻŽ āĻŽā§āϝāĻžāύā§āĻ āĻāϰā§āύ",
|
||||
"storage_template_user_label": "<code>{label}</code> āĻšāϞ⧠āĻŦā§āϝāĻŦāĻšāĻžāϰāĻāĻžāϰā§āϰ āϏā§āĻā§āϰā§āĻ āϞā§āĻŦā§āϞ (Storage Label)",
|
||||
@@ -336,6 +336,8 @@
|
||||
"transcoding_accepted_audio_codecs_description": "āĻā§āύ āĻ
āĻĄāĻŋāĻ āĻā§āĻĄā§āĻāĻā§āϞ⧠āĻā§āϰāĻžāύāϏāĻā§āĻĄ āĻāϰāĻžāϰ āĻĒā§āϰā§ā§āĻāύ āύā§āĻ āϤāĻž āύāĻŋāϰā§āĻŦāĻžāĻāύ āĻāϰā§āύāĨ¤ āĻāĻāĻŋ āĻļā§āϧā§āĻŽāĻžāϤā§āϰ āύāĻŋāϰā§āĻĻāĻŋāώā§āĻ āĻā§āϰāĻžāύāϏāĻā§āĻĄ āĻĒāϞāĻŋāϏāĻŋāϰ (transcode policies) āĻāύā§āϝ āĻŦā§āϝāĻŦāĻšā§āϤ āĻšā§āĨ¤",
|
||||
"transcoding_accepted_containers": "āĻā§āϰāĻšāĻŖāϝā§āĻā§āϝ āĻāύā§āĻā§āĻāύāĻžāϰāϏāĻŽā§āĻš (Accepted containers)"
|
||||
},
|
||||
"user_usage_stats": "āĻ
ā§āϝāĻžāĻāĻžāĻāύā§āĻ āĻŦā§āϝāĻŦāĻšāĻžāϰā§āϰ āĻĒāϰāĻŋāϏāĻāĻā§āϝāĻžāύ",
|
||||
"user_usage_stats_description": "āĻ
ā§āϝāĻžāĻāĻžāĻāύā§āĻ āĻŦā§āϝāĻŦāĻšāĻžāϰā§āϰ āĻĒāϰāĻŋāϏāĻāĻā§āϝāĻžāύ āĻĻā§āĻā§āύ",
|
||||
"yes": "āĻšā§āϝāĻžāĻ",
|
||||
"you_dont_have_any_shared_links": "āĻāĻĒāύāĻžāϰ āĻā§āύ⧠āĻļā§ā§āĻžāϰ āĻāϰāĻž āϞāĻŋāĻā§āĻ āύā§āĻ (You don't have any shared links)",
|
||||
"your_wifi_name": "āĻāĻĒāύāĻžāϰ āĻāϝāĻŧāĻžāĻ-āĻĢāĻžāĻ āĻāϰ āύāĻžāĻŽ (Your Wi-Fi name)",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user