mirror of
https://github.com/immich-app/immich.git
synced 2026-06-04 13:15:22 -04:00
Compare commits
325 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d5fe5f1a4 | |||
| af39384efb | |||
| 01712cf0a7 | |||
| 2015f95ff5 | |||
| d4f29ab6ac | |||
| 3decc864b5 | |||
| eca0e60db8 | |||
| 8cff5883b5 | |||
| 3d320d9751 | |||
| b9e0e65bdb | |||
| 88e5e8d6ea | |||
| ee107c98d5 | |||
| affe0ac5ee | |||
| f1d8ab8aae | |||
| c0898b96ca | |||
| 5e9bda7fab | |||
| b60e9c6771 | |||
| b554664791 | |||
| 97c62136b7 | |||
| c1051c7ed2 | |||
| 65bd0a9320 | |||
| bf32864644 | |||
| 7ef7ecec5b | |||
| bc4abd18e4 | |||
| b74cfd4424 | |||
| 7dc84f56c0 | |||
| 92634f923b | |||
| 96b6165bd3 | |||
| 2624f3884f | |||
| f9b7ce9407 | |||
| 013ea37a0d | |||
| b2b4385271 | |||
| 081c75bb21 | |||
| da337578fb | |||
| acf4109171 | |||
| 66601a1fdc | |||
| 02ff077367 | |||
| 94bb6c1a5e | |||
| fe9e5afcf4 | |||
| 5e89efba64 | |||
| 5a457d72c9 | |||
| 45ccdb37fb | |||
| 9263e2f2e1 | |||
| a3ee615c5b | |||
| 39cfad7136 | |||
| 350056dd1a | |||
| f0835d06f8 | |||
| 03b70cf029 | |||
| 4bfb8b36c2 | |||
| dfacde5af8 | |||
| 317afe9e3b | |||
| 1fb5f13237 | |||
| 793a7054fb | |||
| 3a874dd441 | |||
| 3dc7dc93d8 | |||
| 70397dc5a6 | |||
| a16d233a0c | |||
| bb0872afef | |||
| b9ca68f6e4 | |||
| 837305da7e | |||
| e20fb44142 | |||
| c2786978cd | |||
| 312bb91a4f | |||
| c1934b904c | |||
| 47752d158a | |||
| 6267322b9c | |||
| 93c3cd49f3 | |||
| f52825ab08 | |||
| d74dc74f92 | |||
| 539a39ae49 | |||
| f68cd424a7 | |||
| 20c0cc7e73 | |||
| be1b9a5f67 | |||
| d9011c0829 | |||
| 2c7a24d81f | |||
| f909648bce | |||
| c78b1d8ab4 | |||
| 94a34436a3 | |||
| 0eef15a3ab | |||
| 6982896549 | |||
| 2c812a2561 | |||
| 0b1188e42e | |||
| be20cd2bf9 | |||
| b8591cb591 | |||
| 384d3a0984 | |||
| 03af669856 | |||
| b0e4850d76 | |||
| 36ebcaf00c | |||
| 7a86f2b7b9 | |||
| 55f2b3b6a0 | |||
| fd5e8d6521 | |||
| 6798d5df32 | |||
| 9d33853544 | |||
| a46e46452c | |||
| dbf30b77bf | |||
| 8afca348ff | |||
| 2070f775d6 | |||
| a456a05052 | |||
| b7eff33f90 | |||
| 18c0228f1b | |||
| 2f8be45fe0 | |||
| 41968fdcac | |||
| 79c392ceba | |||
| 8fbeb64c59 | |||
| 7d181f0686 | |||
| 2172dde7dc | |||
| fce220b1d7 | |||
| 2a47c35eb7 | |||
| 6aadb7b5bd | |||
| 88bce52042 | |||
| d046f16860 | |||
| 88815a0345 | |||
| 57212f29bf | |||
| 95fa8fbdab | |||
| 687b7cad6f | |||
| ac2ebcee37 | |||
| 3356e81c85 | |||
| 9c642bd6fc | |||
| 9da0cb3cf4 | |||
| 4ff6cca4da | |||
| 2b7ae4981f | |||
| e63df4121a | |||
| 03b4ab2935 | |||
| facd3bd331 | |||
| 20ddf2e7d2 | |||
| 7f0025b3fc | |||
| 60f4dedb29 | |||
| d5d2ebd9bf | |||
| 37abbeba52 | |||
| 50557002b7 | |||
| 4aa31d38bf | |||
| 3d8df74b43 | |||
| 2ff9f95527 | |||
| a69eecf3bc | |||
| 4ffa26c969 | |||
| ac06514db5 | |||
| 792cb9148b | |||
| 8ee5d3039a | |||
| d410131312 | |||
| 5334a6254a | |||
| 79fccdbee0 | |||
| 6dd6053222 | |||
| 8454cb2631 | |||
| 603fc7401f | |||
| ed70e0febf | |||
| 5f5e3344d5 | |||
| 6da2d3d587 | |||
| 41d2d84b21 | |||
| 6ba17bb86f | |||
| e1a84d3ab6 | |||
| 7d8f843be6 | |||
| 3753b7a4d1 | |||
| 84a1fb27ca | |||
| 81780b0cc0 | |||
| 5e81a5a054 | |||
| e4e2f586b5 | |||
| a001adf14a | |||
| 136814540a | |||
| fed5cc1ae1 | |||
| 641ab51b80 | |||
| 3b47ca1c37 | |||
| 8fb2c7755d | |||
| 1ba0989e15 | |||
| daed3f0966 | |||
| 46d612ad8c | |||
| 513dead2c2 | |||
| ca006c1569 | |||
| 4e8e8304fd | |||
| d377d2e145 | |||
| 9c9feddf7d | |||
| bfcf34d8b5 | |||
| 95e57a24cb | |||
| eada662981 | |||
| 352f6ecc28 | |||
| bee49cef02 | |||
| 6d0c6a4008 | |||
| 8a975e5ea9 | |||
| d39e7da10d | |||
| bc400d68ac | |||
| d7f038ec60 | |||
| 26957f37ce | |||
| 3254d31cd2 | |||
| 7b269d1638 | |||
| b5bed02300 | |||
| 5553910236 | |||
| 8d67c1f820 | |||
| ed0ec30917 | |||
| 2b0f6c9202 | |||
| 55ab8c65b6 | |||
| 781d568f29 | |||
| 6a361dae72 | |||
| 64766c8c06 | |||
| 6a63e814a5 | |||
| 6441c3b77c | |||
| b03a649e74 | |||
| 2903b2653b | |||
| 9ba9a22c40 | |||
| f1882c2926 | |||
| 4278789083 | |||
| 921c8a8de3 | |||
| afec61addc | |||
| a1a03efbcd | |||
| 1d0e5cf18d | |||
| de9ec95db1 | |||
| 7f784952eb | |||
| 3d6c7ba353 | |||
| 3be97db118 | |||
| 8f3a99ffbc | |||
| e6d114af10 | |||
| 4e28811f09 | |||
| 4987032e62 | |||
| 572bad8ede | |||
| 95c1f0efeb | |||
| fbe631fe91 | |||
| 2143a0c935 | |||
| 136bd1e2eb | |||
| 564065a3ed | |||
| 9bcce59719 | |||
| cd86a83c33 | |||
| f29c06799f | |||
| 6fcf651d76 | |||
| 196307bca5 | |||
| 776b9cbad5 | |||
| 960be0c27a | |||
| 123119ca0d | |||
| 1772f720bf | |||
| bcc29903de | |||
| 767caf9bfe | |||
| 649d14822a | |||
| 207672c481 | |||
| 4fcd9c2e0d | |||
| a2687d674e | |||
| fb1bc7f9e2 | |||
| 18e8d30b1c | |||
| 95ef60628c | |||
| a19b7148e5 | |||
| 8e414e42f3 | |||
| db0f86c749 | |||
| adb6b39eec | |||
| c8ae99e7d7 | |||
| 37823bcd51 | |||
| b465f2b58f | |||
| 2166f07b1f | |||
| c9e251c78c | |||
| da4b88fc14 | |||
| d1e2e8ab4e | |||
| 2a619d3c10 | |||
| c29493e3a0 | |||
| 4ef777d145 | |||
| 0b40f4fd76 | |||
| ecba4e2a62 | |||
| 4eb531197e | |||
| 505a07a825 | |||
| 548dbe8ad6 | |||
| 0c184940f4 | |||
| be180fd9da | |||
| 859f58174e | |||
| a6c7e76008 | |||
| 0ff94213e6 | |||
| 6b1dd6f680 | |||
| 7d4286bbc5 | |||
| 18201a26d9 | |||
| a2e3635ac9 | |||
| ce346bf956 | |||
| a1a2939868 | |||
| e8309585d6 | |||
| 17d4941089 | |||
| b09ebb11e9 | |||
| 181b028b09 | |||
| eb20b715e4 | |||
| 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 | |||
| 8e9bec75ac |
@@ -6,6 +6,12 @@ mobile/openapi/**/*.dart linguist-generated=true
|
|||||||
mobile/lib/**/*.g.dart -diff -merge
|
mobile/lib/**/*.g.dart -diff -merge
|
||||||
mobile/lib/**/*.g.dart linguist-generated=true
|
mobile/lib/**/*.g.dart linguist-generated=true
|
||||||
|
|
||||||
|
mobile/android/**/*.g.kt -diff -merge
|
||||||
|
mobile/android/**/*.g.kt linguist-generated=true
|
||||||
|
|
||||||
|
mobile/ios/**/*.g.swift -diff -merge
|
||||||
|
mobile/ios/**/*.g.swift linguist-generated=true
|
||||||
|
|
||||||
mobile/lib/**/*.drift.dart -diff -merge
|
mobile/lib/**/*.drift.dart -diff -merge
|
||||||
mobile/lib/**/*.drift.dart linguist-generated=true
|
mobile/lib/**/*.drift.dart linguist-generated=true
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
24.13.1
|
24.15.0
|
||||||
|
|||||||
@@ -30,12 +30,17 @@ jobs:
|
|||||||
while IFS= read -r header; do
|
while IFS= read -r header; do
|
||||||
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
|
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
|
||||||
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
|
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
|
||||||
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
|
echo "uses_template=$OK" | tee --append "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
close_template:
|
close_template:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: parse_template
|
needs: parse_template
|
||||||
if: ${{ needs.parse_template.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
|
if: >-
|
||||||
|
${{
|
||||||
|
needs.parse_template.outputs.uses_template == 'false'
|
||||||
|
&& github.event.pull_request.state != 'closed'
|
||||||
|
&& !contains(github.event.pull_request.labels.*.name, 'auto-closed:template')
|
||||||
|
}}
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
@@ -46,7 +51,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
gh api graphql \
|
gh api graphql \
|
||||||
-f prId="$NODE_ID" \
|
-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 body="This PR has been automatically closed as the description doesn't follow [our template](https://github.com/immich-app/immich/blob/main/.github/pull_request_template.md). After you edit it to match the template, the PR will automatically be reopened." \
|
||||||
-f query='
|
-f query='
|
||||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||||
addComment(input: {
|
addComment(input: {
|
||||||
@@ -66,7 +71,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
run: gh pr edit "$PR_NUMBER" --add-label "auto-closed:template"
|
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "auto-closed:template"
|
||||||
|
|
||||||
close_llm:
|
close_llm:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -113,7 +118,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
run: gh pr edit "$PR_NUMBER" --remove-label "auto-closed:template" || true
|
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --remove-label "auto-closed:template" || true
|
||||||
|
|
||||||
- name: Check for remaining auto-closed labels
|
- name: Check for remaining auto-closed labels
|
||||||
id: check_labels
|
id: check_labels
|
||||||
@@ -121,9 +126,9 @@ jobs:
|
|||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
run: |
|
run: |
|
||||||
REMAINING=$(gh pr view "$PR_NUMBER" --json labels \
|
REMAINING=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels \
|
||||||
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
|
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
|
||||||
echo "remaining=$REMAINING" >> "$GITHUB_OUTPUT"
|
echo "remaining=$REMAINING" | tee --append "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Reopen PR
|
- name: Reopen PR
|
||||||
if: ${{ steps.check_labels.outputs.remaining == '0' }}
|
if: ${{ steps.check_labels.outputs.remaining == '0' }}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Restore Gradle Cache
|
- name: Restore Gradle Cache
|
||||||
id: cache-gradle-restore
|
id: cache-gradle-restore
|
||||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -114,14 +114,14 @@ jobs:
|
|||||||
key: build-mobile-gradle-${{ runner.os }}-main
|
key: build-mobile-gradle-${{ runner.os }}-main
|
||||||
|
|
||||||
- name: Setup Flutter SDK
|
- name: Setup Flutter SDK
|
||||||
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
flutter-version-file: ./mobile/pubspec.yaml
|
flutter-version-file: ./mobile/pubspec.yaml
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3.2.2
|
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
||||||
with:
|
with:
|
||||||
packages: ''
|
packages: ''
|
||||||
|
|
||||||
@@ -153,14 +153,14 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Publish Android Artifact
|
- name: Publish Android Artifact
|
||||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: release-apk-signed
|
name: release-apk-signed
|
||||||
path: mobile/build/app/outputs/flutter-apk/*.apk
|
path: mobile/build/app/outputs/flutter-apk/*.apk
|
||||||
|
|
||||||
- name: Save Gradle Cache
|
- name: Save Gradle Cache
|
||||||
id: cache-gradle-save
|
id: cache-gradle-save
|
||||||
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -191,7 +191,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Flutter SDK
|
- name: Setup Flutter SDK
|
||||||
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
flutter-version-file: ./mobile/pubspec.yaml
|
flutter-version-file: ./mobile/pubspec.yaml
|
||||||
@@ -210,7 +210,7 @@ jobs:
|
|||||||
working-directory: ./mobile
|
working-directory: ./mobile
|
||||||
|
|
||||||
- name: Setup Ruby
|
- name: Setup Ruby
|
||||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
uses: ruby/setup-ruby@7372622e62b60b3cb750dcd2b9e32c247ffec26a # v1.302.0
|
||||||
with:
|
with:
|
||||||
ruby-version: '3.3'
|
ruby-version: '3.3'
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
@@ -291,7 +291,7 @@ jobs:
|
|||||||
security delete-keychain build.keychain || true
|
security delete-keychain build.keychain || true
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
- name: Upload IPA artifact
|
||||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: ios-release-ipa
|
name: ios-release-ipa
|
||||||
path: mobile/ios/Runner.ipa
|
path: mobile/ios/Runner.ipa
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Check for breaking API changes
|
- name: Check for breaking API changes
|
||||||
uses: oasdiff/oasdiff-action/breaking@2a37bc82462349c03a533b8b608bebbaf57b3e60 # v0.0.33
|
uses: oasdiff/oasdiff-action/breaking@f8cb9308b42121e793f835bd14c0b8090420430c # v0.0.39
|
||||||
with:
|
with:
|
||||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||||
revision: open-api/immich-openapi-specs.json
|
revision: open-api/immich-openapi-specs.json
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
@@ -115,7 +115,7 @@ jobs:
|
|||||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||||
with:
|
with:
|
||||||
file: cli/Dockerfile
|
file: cli/Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
needs: [get_body, should_run]
|
needs: [get_body, should_run]
|
||||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||||
container:
|
container:
|
||||||
image: ghcr.io/immich-app/mdq:main@sha256:df7188ba88abb0800d73cc97d3633280f0c0c3d4c441d678225067bf154150fb
|
image: ghcr.io/immich-app/mdq:main@sha256:557cca601891b8b7d78b940071d35aaf7aaeb9b327d19b22cf282118edbc5272
|
||||||
outputs:
|
outputs:
|
||||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
|
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
|
uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
@@ -83,6 +83,6 @@ jobs:
|
|||||||
# ./location_of_script_within_repo/buildscript.sh
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
|
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
|
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
|
||||||
steps:
|
steps:
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -90,7 +90,7 @@ jobs:
|
|||||||
suffix: ['']
|
suffix: ['']
|
||||||
steps:
|
steps:
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -178,7 +178,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: always()
|
if: always()
|
||||||
steps:
|
steps:
|
||||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
||||||
with:
|
with:
|
||||||
needs: ${{ toJSON(needs) }}
|
needs: ${{ toJSON(needs) }}
|
||||||
|
|
||||||
@@ -189,6 +189,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: always()
|
if: always()
|
||||||
steps:
|
steps:
|
||||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
||||||
with:
|
with:
|
||||||
needs: ${{ toJSON(needs) }}
|
needs: ${{ toJSON(needs) }}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ jobs:
|
|||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Upload build output
|
- name: Upload build output
|
||||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: docs-build-output
|
name: docs-build-output
|
||||||
path: docs/build/
|
path: docs/build/
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
run: echo 'The triggering workflow did not succeed' && exit 1
|
run: echo 'The triggering workflow did not succeed' && exit 1
|
||||||
- name: Get artifact
|
- name: Get artifact
|
||||||
id: get-artifact
|
id: get-artifact
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
script: |
|
script: |
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
return { found: true, id: matchArtifact.id };
|
return { found: true, id: matchArtifact.id };
|
||||||
- name: Determine deploy parameters
|
- name: Determine deploy parameters
|
||||||
id: parameters
|
id: parameters
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
env:
|
env:
|
||||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
with:
|
with:
|
||||||
@@ -135,7 +135,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Load parameters
|
- name: Load parameters
|
||||||
id: parameters
|
id: parameters
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
env:
|
env:
|
||||||
PARAM_JSON: ${{ needs.checks.outputs.parameters }}
|
PARAM_JSON: ${{ needs.checks.outputs.parameters }}
|
||||||
with:
|
with:
|
||||||
@@ -147,7 +147,7 @@ jobs:
|
|||||||
core.setOutput("shouldDeploy", parameters.shouldDeploy);
|
core.setOutput("shouldDeploy", parameters.shouldDeploy);
|
||||||
|
|
||||||
- name: Download artifact
|
- name: Download artifact
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
env:
|
env:
|
||||||
ARTIFACT_JSON: ${{ needs.checks.outputs.artifact }}
|
ARTIFACT_JSON: ${{ needs.checks.outputs.artifact }}
|
||||||
with:
|
with:
|
||||||
@@ -211,7 +211,7 @@ jobs:
|
|||||||
run: 'mise run //deployment:tf apply'
|
run: 'mise run //deployment:tf apply'
|
||||||
|
|
||||||
- name: Comment
|
- name: Comment
|
||||||
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
|
uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
|
||||||
if: ${{ steps.parameters.outputs.event == 'pr' }}
|
if: ${{ steps.parameters.outputs.event == 'pr' }}
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
run: 'mise run //deployment:tf destroy -- -refresh=false'
|
run: 'mise run //deployment:tf destroy -- -refresh=false'
|
||||||
|
|
||||||
- name: Comment
|
- name: Comment
|
||||||
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
|
uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
number: ${{ github.event.number }}
|
number: ${{ github.event.number }}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate-token
|
id: generate-token
|
||||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
persist-credentials: true
|
persist-credentials: true
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb # v6.0.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
@@ -42,13 +42,13 @@ jobs:
|
|||||||
run: pnpm --recursive install && pnpm run --recursive --if-present --parallel format:fix
|
run: pnpm --recursive install && pnpm run --recursive --if-present --parallel format:fix
|
||||||
|
|
||||||
- name: Commit and push
|
- name: Commit and push
|
||||||
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0
|
||||||
with:
|
with:
|
||||||
default_author: github_actions
|
default_author: github_actions
|
||||||
message: 'chore: fix formatting'
|
message: 'chore: fix formatting'
|
||||||
|
|
||||||
- name: Remove label
|
- name: Remove label
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.generate-token.outputs.token }}
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate_token
|
id: generate_token
|
||||||
if: ${{ inputs.skip != true }}
|
if: ${{ inputs.skip != true }}
|
||||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate-token
|
id: generate-token
|
||||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
@@ -63,10 +63,10 @@ jobs:
|
|||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb # v6.0.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
@@ -86,7 +86,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Commit and tag
|
- name: Commit and tag
|
||||||
id: push-tag
|
id: push-tag
|
||||||
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0
|
||||||
with:
|
with:
|
||||||
default_author: github_actions
|
default_author: github_actions
|
||||||
message: 'chore: version ${{ steps.output.outputs.version }}'
|
message: 'chore: version ${{ steps.output.outputs.version }}'
|
||||||
@@ -124,7 +124,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate-token
|
id: generate-token
|
||||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
@@ -142,7 +142,7 @@ jobs:
|
|||||||
github-token: ${{ steps.generate-token.outputs.token }}
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|
||||||
- name: Create draft release
|
- name: Create draft release
|
||||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
|
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
tag_name: ${{ needs.bump_version.outputs.version }}
|
tag_name: ${{ needs.bump_version.outputs.version }}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
|
- uses: mshick/add-pr-comment@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
message-id: 'preview-status'
|
message-id: 'preview-status'
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
script: |
|
script: |
|
||||||
@@ -48,14 +48,14 @@ jobs:
|
|||||||
name: 'preview'
|
name: 'preview'
|
||||||
})
|
})
|
||||||
|
|
||||||
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
|
- uses: mshick/add-pr-comment@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
|
||||||
if: ${{ github.event.pull_request.head.repo.fork }}
|
if: ${{ github.event.pull_request.head.repo.fork }}
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
message-id: 'preview-status'
|
message-id: 'preview-status'
|
||||||
message: 'PRs from forks cannot have preview environments.'
|
message: 'PRs from forks cannot have preview environments.'
|
||||||
|
|
||||||
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
|
- uses: mshick/add-pr-comment@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
|
||||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Flutter SDK
|
- name: Setup Flutter SDK
|
||||||
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
flutter-version-file: ./mobile/pubspec.yaml
|
flutter-version-file: ./mobile/pubspec.yaml
|
||||||
|
|||||||
@@ -392,6 +392,8 @@ jobs:
|
|||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
- name: Setup Mise
|
||||||
|
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
|
||||||
- name: Run pnpm install
|
- name: Run pnpm install
|
||||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
||||||
- name: Run medium tests
|
- name: Run medium tests
|
||||||
@@ -464,7 +466,7 @@ jobs:
|
|||||||
run: docker compose logs --no-color > docker-compose-logs.txt
|
run: docker compose logs --no-color > docker-compose-logs.txt
|
||||||
working-directory: ./e2e
|
working-directory: ./e2e
|
||||||
- name: Archive Docker logs
|
- name: Archive Docker logs
|
||||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: e2e-server-docker-logs-${{ matrix.runner }}
|
name: e2e-server-docker-logs-${{ matrix.runner }}
|
||||||
@@ -522,7 +524,7 @@ jobs:
|
|||||||
run: pnpm test:web
|
run: pnpm test:web
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
- name: Archive e2e test (web) results
|
- name: Archive e2e test (web) results
|
||||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
if: success() || failure()
|
if: success() || failure()
|
||||||
with:
|
with:
|
||||||
name: e2e-web-test-results-${{ matrix.runner }}
|
name: e2e-web-test-results-${{ matrix.runner }}
|
||||||
@@ -533,7 +535,7 @@ jobs:
|
|||||||
run: pnpm test:web:ui
|
run: pnpm test:web:ui
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
- name: Archive ui test (web) results
|
- name: Archive ui test (web) results
|
||||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
if: success() || failure()
|
if: success() || failure()
|
||||||
with:
|
with:
|
||||||
name: e2e-ui-test-results-${{ matrix.runner }}
|
name: e2e-ui-test-results-${{ matrix.runner }}
|
||||||
@@ -544,7 +546,7 @@ jobs:
|
|||||||
run: pnpm test:web:maintenance
|
run: pnpm test:web:maintenance
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
- name: Archive maintenance tests (web) results
|
- name: Archive maintenance tests (web) results
|
||||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
if: success() || failure()
|
if: success() || failure()
|
||||||
with:
|
with:
|
||||||
name: e2e-maintenance-isolated-test-results-${{ matrix.runner }}
|
name: e2e-maintenance-isolated-test-results-${{ matrix.runner }}
|
||||||
@@ -554,7 +556,7 @@ jobs:
|
|||||||
run: docker compose logs --no-color > docker-compose-logs.txt
|
run: docker compose logs --no-color > docker-compose-logs.txt
|
||||||
working-directory: ./e2e
|
working-directory: ./e2e
|
||||||
- name: Archive Docker logs
|
- name: Archive Docker logs
|
||||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: e2e-web-docker-logs-${{ matrix.runner }}
|
name: e2e-web-docker-logs-${{ matrix.runner }}
|
||||||
@@ -566,7 +568,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: always()
|
if: always()
|
||||||
steps:
|
steps:
|
||||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
||||||
with:
|
with:
|
||||||
needs: ${{ toJSON(needs) }}
|
needs: ${{ toJSON(needs) }}
|
||||||
mobile-unit-tests:
|
mobile-unit-tests:
|
||||||
@@ -588,7 +590,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup Flutter SDK
|
- name: Setup Flutter SDK
|
||||||
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
flutter-version-file: ./mobile/pubspec.yaml
|
flutter-version-file: ./mobile/pubspec.yaml
|
||||||
@@ -620,7 +622,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.11
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -68,6 +68,6 @@ jobs:
|
|||||||
permissions: {}
|
permissions: {}
|
||||||
if: always()
|
if: always()
|
||||||
steps:
|
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:
|
with:
|
||||||
needs: ${{ toJSON(needs) }}
|
needs: ${{ toJSON(needs) }}
|
||||||
|
|||||||
@@ -28,3 +28,5 @@ vite.config.js.timestamp-*
|
|||||||
.pnpm-store
|
.pnpm-store
|
||||||
.devcontainer/library
|
.devcontainer/library
|
||||||
.devcontainer/.env*
|
.devcontainer/.env*
|
||||||
|
*.tsbuildinfo
|
||||||
|
*.tsbuildInfo
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
[submodule "mobile/.isar"]
|
|
||||||
path = mobile/.isar
|
|
||||||
url = https://github.com/isar/isar
|
|
||||||
[submodule "e2e/test-assets"]
|
[submodule "e2e/test-assets"]
|
||||||
path = e2e/test-assets
|
path = e2e/test-assets
|
||||||
url = https://github.com/immich-app/test-assets
|
url = https://github.com/immich-app/test-assets
|
||||||
|
|||||||
Vendored
+2
-13
@@ -13,10 +13,6 @@
|
|||||||
"editor.wordBasedSuggestions": "off"
|
"editor.wordBasedSuggestions": "off"
|
||||||
},
|
},
|
||||||
"[javascript]": {
|
"[javascript]": {
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.organizeImports": "explicit",
|
|
||||||
"source.removeUnusedImports": "explicit"
|
|
||||||
},
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
},
|
||||||
@@ -29,18 +25,11 @@
|
|||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
},
|
||||||
"[svelte]": {
|
"[svelte]": {
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.organizeImports": "explicit",
|
|
||||||
"source.removeUnusedImports": "explicit"
|
|
||||||
},
|
|
||||||
"editor.defaultFormatter": "svelte.svelte-vscode",
|
"editor.defaultFormatter": "svelte.svelte-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"tailwindCSS.lint.suggestCanonicalClasses": "ignore"
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.organizeImports": "explicit",
|
|
||||||
"source.removeUnusedImports": "explicit"
|
|
||||||
},
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
24.13.1
|
24.15.0
|
||||||
|
|||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.6.2",
|
"version": "2.7.5",
|
||||||
"description": "Command Line Interface (CLI) for Immich",
|
"description": "Command Line Interface (CLI) for Immich",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./dist/index.js",
|
"exports": "./dist/index.js",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/micromatch": "^4.0.9",
|
"@types/micromatch": "^4.0.9",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/node": "^24.11.0",
|
"@types/node": "^24.12.2",
|
||||||
"@vitest/coverage-v8": "^4.0.0",
|
"@vitest/coverage-v8": "^4.0.0",
|
||||||
"byte-size": "^9.0.0",
|
"byte-size": "^9.0.0",
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
@@ -28,13 +28,13 @@
|
|||||||
"eslint": "^10.0.0",
|
"eslint": "^10.0.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"eslint-plugin-unicorn": "^63.0.0",
|
"eslint-plugin-unicorn": "^64.0.0",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
"mock-fs": "^5.2.0",
|
"mock-fs": "^5.2.0",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-organize-imports": "^4.0.0",
|
"prettier-plugin-organize-imports": "^4.0.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^6.0.0",
|
||||||
"typescript-eslint": "^8.28.0",
|
"typescript-eslint": "^8.58.0",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
"vitest": "^4.0.0",
|
"vitest": "^4.0.0",
|
||||||
"vitest-fetch-mock": "^0.4.0",
|
"vitest-fetch-mock": "^0.4.0",
|
||||||
@@ -68,6 +68,6 @@
|
|||||||
"micromatch": "^4.0.8"
|
"micromatch": "^4.0.8"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.13.1"
|
"node": "24.15.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|||||||
import { setTimeout as sleep } from 'node:timers/promises';
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
import { describe, expect, it, MockedFunction, vi } from 'vitest';
|
import { describe, expect, it, MockedFunction, vi } from 'vitest';
|
||||||
|
|
||||||
import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk';
|
import { AssetRejectReason, AssetUploadAction, checkBulkUpload, defaults, getSupportedMediaTypes } from '@immich/sdk';
|
||||||
import createFetchMock from 'vitest-fetch-mock';
|
import createFetchMock from 'vitest-fetch-mock';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -120,7 +120,7 @@ describe('checkForDuplicates', () => {
|
|||||||
vi.mocked(checkBulkUpload).mockResolvedValue({
|
vi.mocked(checkBulkUpload).mockResolvedValue({
|
||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
action: Action.Accept,
|
action: AssetUploadAction.Accept,
|
||||||
id: testFilePath,
|
id: testFilePath,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -144,10 +144,10 @@ describe('checkForDuplicates', () => {
|
|||||||
vi.mocked(checkBulkUpload).mockResolvedValue({
|
vi.mocked(checkBulkUpload).mockResolvedValue({
|
||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
action: Action.Reject,
|
action: AssetUploadAction.Reject,
|
||||||
id: testFilePath,
|
id: testFilePath,
|
||||||
assetId: 'fc5621b1-86f6-44a1-9905-403e607df9f5',
|
assetId: 'fc5621b1-86f6-44a1-9905-403e607df9f5',
|
||||||
reason: Reason.Duplicate,
|
reason: AssetRejectReason.Duplicate,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -167,7 +167,7 @@ describe('checkForDuplicates', () => {
|
|||||||
vi.mocked(checkBulkUpload).mockResolvedValue({
|
vi.mocked(checkBulkUpload).mockResolvedValue({
|
||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
action: Action.Accept,
|
action: AssetUploadAction.Accept,
|
||||||
id: testFilePath,
|
id: testFilePath,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -187,7 +187,7 @@ describe('checkForDuplicates', () => {
|
|||||||
mocked.mockResolvedValue({
|
mocked.mockResolvedValue({
|
||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
action: Action.Accept,
|
action: AssetUploadAction.Accept,
|
||||||
id: testFilePath,
|
id: testFilePath,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
Action,
|
|
||||||
AssetBulkUploadCheckItem,
|
AssetBulkUploadCheckItem,
|
||||||
AssetBulkUploadCheckResult,
|
AssetBulkUploadCheckResult,
|
||||||
AssetMediaResponseDto,
|
AssetMediaResponseDto,
|
||||||
AssetMediaStatus,
|
AssetMediaStatus,
|
||||||
|
AssetUploadAction,
|
||||||
Permission,
|
Permission,
|
||||||
addAssetsToAlbum,
|
addAssetsToAlbum,
|
||||||
checkBulkUpload,
|
checkBulkUpload,
|
||||||
@@ -234,7 +234,7 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
|
|||||||
const results = response.results as AssetBulkUploadCheckResults;
|
const results = response.results as AssetBulkUploadCheckResults;
|
||||||
|
|
||||||
for (const { id: filepath, assetId, action } of results) {
|
for (const { id: filepath, assetId, action } of results) {
|
||||||
if (action === Action.Accept) {
|
if (action === AssetUploadAction.Accept) {
|
||||||
newFiles.push(filepath);
|
newFiles.push(filepath);
|
||||||
} else {
|
} else {
|
||||||
// rejects are always duplicates
|
// rejects are always duplicates
|
||||||
@@ -404,8 +404,6 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
|
|||||||
const { baseUrl, headers } = defaults;
|
const { baseUrl, headers } = defaults;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, ''));
|
|
||||||
formData.append('deviceId', 'CLI');
|
|
||||||
formData.append('fileCreatedAt', stats.mtime.toISOString());
|
formData.append('fileCreatedAt', stats.mtime.toISOString());
|
||||||
formData.append('fileModifiedAt', stats.mtime.toISOString());
|
formData.append('fileModifiedAt', stats.mtime.toISOString());
|
||||||
formData.append('fileSize', String(stats.size));
|
formData.append('fileSize', String(stats.size));
|
||||||
|
|||||||
+6
-2
@@ -15,8 +15,12 @@
|
|||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"baseUrl": "./",
|
"rootDir": "./src",
|
||||||
|
"paths": {
|
||||||
|
"src/*": ["./src/*"],
|
||||||
|
},
|
||||||
|
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
|
||||||
"types": ["vitest/globals"]
|
"types": ["vitest/globals"]
|
||||||
},
|
},
|
||||||
"exclude": ["dist", "node_modules"]
|
"exclude": ["dist", "node_modules", "vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tools]
|
[tools]
|
||||||
terragrunt = "0.99.4"
|
terragrunt = "1.0.2"
|
||||||
opentofu = "1.11.5"
|
opentofu = "1.11.6"
|
||||||
|
|
||||||
[tasks."tg:fmt"]
|
[tasks."tg:fmt"]
|
||||||
run = "terragrunt hclfmt"
|
run = "terragrunt hclfmt"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ services:
|
|||||||
- /tmp
|
- /tmp
|
||||||
volumes:
|
volumes:
|
||||||
- ..:/usr/src/app
|
- ..:/usr/src/app
|
||||||
|
# - ../../ui:/usr/src/ui
|
||||||
- pnpm_cache:/buildcache/pnpm_cache
|
- pnpm_cache:/buildcache/pnpm_cache
|
||||||
- server_node_modules:/usr/src/app/server/node_modules
|
- server_node_modules:/usr/src/app/server/node_modules
|
||||||
- web_node_modules:/usr/src/app/web/node_modules
|
- web_node_modules:/usr/src/app/web/node_modules
|
||||||
@@ -90,6 +91,7 @@ services:
|
|||||||
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
|
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
|
||||||
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
|
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
|
||||||
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
|
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
|
||||||
|
IMMICH_HELMET_FILE: 'true'
|
||||||
ports:
|
ports:
|
||||||
- 9230:9230
|
- 9230:9230
|
||||||
- 9231:9231
|
- 9231:9231
|
||||||
@@ -155,7 +157,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
@@ -85,7 +85,7 @@ services:
|
|||||||
container_name: immich_prometheus
|
container_name: immich_prometheus
|
||||||
ports:
|
ports:
|
||||||
- 9090:9090
|
- 9090:9090
|
||||||
image: prom/prometheus@sha256:4a61322ac1103a0e3aea2a61ef1718422a48fa046441f299d71e660a3bc71ae9
|
image: prom/prometheus@sha256:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
- prometheus-data:/prometheus
|
- prometheus-data:/prometheus
|
||||||
@@ -97,7 +97,7 @@ services:
|
|||||||
command: ['./run.sh', '-disable-reporting']
|
command: ['./run.sh', '-disable-reporting']
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- 3000:3000
|
||||||
image: grafana/grafana:12.3.2-ubuntu@sha256:6cca4b429a1dc0d37d401dee54825c12d40056c3c6f3f56e3f0d6318ce77749b
|
image: grafana/grafana:12.4.2-ubuntu@sha256:78839fe49e1425c02416fa8072591533a72bd9598e563b54a07d78f9e27fb5d3
|
||||||
volumes:
|
volumes:
|
||||||
- grafana-data:/var/lib/grafana
|
- grafana-data:/var/lib/grafana
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
|
||||||
user: '1000:1000'
|
user: '1000:1000'
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
24.13.1
|
24.15.0
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ The provided restore process ensures your database is never in a broken state by
|
|||||||
|
|
||||||
## Filesystem
|
## Filesystem
|
||||||
|
|
||||||
Immich stores two types of content in the filesystem: (a) original, unmodified assets (photos and videos), and (b) generated content. We recommend backing up the entire contents of `UPLOAD_LOCATION`, but only the original content is critical, which is stored in the following folders:
|
Immich does not handle filesystem backups for you. You have to arrange these yourself! Immich stores two types of content in the filesystem: (a) original, unmodified assets (photos and videos), and (b) generated content. We recommend backing up the entire contents of `UPLOAD_LOCATION`, but only the original content is critical, which is stored in the following folders:
|
||||||
|
|
||||||
1. `UPLOAD_LOCATION/library`
|
1. `UPLOAD_LOCATION/library`
|
||||||
2. `UPLOAD_LOCATION/upload`
|
2. `UPLOAD_LOCATION/upload`
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -14,6 +14,7 @@ Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an i
|
|||||||
- [Authelia](https://www.authelia.com/integration/openid-connect/immich/)
|
- [Authelia](https://www.authelia.com/integration/openid-connect/immich/)
|
||||||
- [Okta](https://www.okta.com/openid-connect/)
|
- [Okta](https://www.okta.com/openid-connect/)
|
||||||
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
|
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
|
||||||
|
- [Keycloak](https://www.keycloak.org)
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -49,6 +50,10 @@ Before enabling OAuth in Immich, a new client application needs to be configured
|
|||||||
- `https://immich.example.com/auth/login`
|
- `https://immich.example.com/auth/login`
|
||||||
- `https://immich.example.com/user-settings`
|
- `https://immich.example.com/user-settings`
|
||||||
|
|
||||||
|
3. Configure Backchannel logout URL
|
||||||
|
|
||||||
|
If the authentication server supports it, the **Backchannel logout URL** can be specified, and it is of the form: `http://DOMAIN:PORT/api/oauth/backchannel-logout`.
|
||||||
|
|
||||||
## Enable OAuth
|
## Enable OAuth
|
||||||
|
|
||||||
Once you have a new OAuth client application configured, Immich can be configured using the Administration Settings page, available on the web (Administration -> Settings).
|
Once you have a new OAuth client application configured, Immich can be configured using the Administration Settings page, available on the web (Administration -> Settings).
|
||||||
@@ -62,6 +67,8 @@ Once you have a new OAuth client application configured, Immich can be configure
|
|||||||
| `scope` | string | openid email profile | Full list of scopes to send with the request (space delimited) |
|
| `scope` | string | openid email profile | Full list of scopes to send with the request (space delimited) |
|
||||||
| `id_token_signed_response_alg` | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
|
| `id_token_signed_response_alg` | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
|
||||||
| `userinfo_signed_response_alg` | string | none | The algorithm used to sign the userinfo response (examples: RS256, HS256) |
|
| `userinfo_signed_response_alg` | string | none | The algorithm used to sign the userinfo response (examples: RS256, HS256) |
|
||||||
|
| `prompt` | string | (empty) | Prompt parameter for authorization url (examples: select_account, login, consent) |
|
||||||
|
| `end_session_endpoint` | URL | (empty) | Http(s) alternative end session endpoint (logout URI) |
|
||||||
| Request timeout | string | 30,000 (30 seconds) | Number of milliseconds to wait for http requests to complete before giving up |
|
| Request timeout | string | 30,000 (30 seconds) | Number of milliseconds to wait for http requests to complete before giving up |
|
||||||
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label**¹** |
|
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label**¹** |
|
||||||
| Role Claim | string | immich_role | Claim mapping for the user's role. (should return "user" or "admin")**¹** |
|
| Role Claim | string | immich_role | Claim mapping for the user's role. (should return "user" or "admin")**¹** |
|
||||||
@@ -180,6 +187,7 @@ Configuration of OAuth in Immich System Settings
|
|||||||
| Scope | openid email profile immich_scope |
|
| Scope | openid email profile immich_scope |
|
||||||
| ID Token Signed Response Algorithm | RS256 |
|
| ID Token Signed Response Algorithm | RS256 |
|
||||||
| Userinfo Signed Response Algorithm | RS256 |
|
| Userinfo Signed Response Algorithm | RS256 |
|
||||||
|
| End Session Endpoint | https://auth.example.com/logout?rd=https://immich.example.com/ |
|
||||||
| Storage Label Claim | uid |
|
| Storage Label Claim | uid |
|
||||||
| Storage Quota Claim | immich_quota |
|
| Storage Quota Claim | immich_quota |
|
||||||
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
|
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
|
||||||
@@ -253,4 +261,40 @@ Configuration of OAuth in Immich System Settings
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Keycloak Example</summary>
|
||||||
|
|
||||||
|
### Keycloak Example
|
||||||
|
|
||||||
|
Here's an example of OAuth configured for Keycloak:
|
||||||
|
|
||||||
|
Create your immich client on your Keycloak Realm.
|
||||||
|
|
||||||
|
<img src={require('./img/keycloak-general-settings.webp').default} width='100%' title="Keycloak Client general Settings" />
|
||||||
|
<img src={require('./img/keycloak-access-settings.webp').default} width='100%' title="Keycloak Client Access Settings" />
|
||||||
|
<img src={require('./img/keycloak-capability-config.webp').default} width='100%' title="Keycloak Client Capability Configuration" />
|
||||||
|
|
||||||
|
Configuration of OAuth in Immich System Settings
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
| ---------------------------- | ----------------------------------------------------- |
|
||||||
|
| Issuer URL | `https://<KEYCLOAK_DOMAIN>/realms/<YOUR_REALM>` |
|
||||||
|
| Client ID | immich |
|
||||||
|
| Client Secret | can be optained from Clients -> immich -> Credentials |
|
||||||
|
| Scope | openid email profile |
|
||||||
|
| Signing Algorithm | RS256 |
|
||||||
|
| Storage Label Claim | preferred_username |
|
||||||
|
| Role Claim | immich_role |
|
||||||
|
| Storage Quota Claim | immich_quota |
|
||||||
|
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
|
||||||
|
| Button Text | Sign in with Keycloak (recommended) |
|
||||||
|
| Auto Register | Enabled (optional) |
|
||||||
|
| Auto Launch | Enabled (optional) |
|
||||||
|
| Mobile Redirect URI Override | Disabled |
|
||||||
|
| Mobile Redirect URI | |
|
||||||
|
|
||||||
|
Role Claim can be managed via Client Role. Remember to create a mapper with claim name `immich_role`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
[oidc]: https://openid.net/connect/
|
[oidc]: https://openid.net/connect/
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ VectorChord is the successor extension to pgvecto.rs, allowing for higher perfor
|
|||||||
|
|
||||||
### Migrating from pgvecto.rs
|
### Migrating from pgvecto.rs
|
||||||
|
|
||||||
Support for pgvecto.rs will be dropped in a later release, hence we recommend all users currently using pgvecto.rs to migrate to VectorChord at their convenience. There are two primary approaches to do so.
|
Support for pgvecto.rs has been dropped as of 3.0, hence all users currently using pgvecto.rs should migrate to VectorChord. There are two primary approaches to do so.
|
||||||
|
|
||||||
The easiest option is to have both extensions installed during the migration:
|
The easiest option is to have both extensions installed during the migration:
|
||||||
|
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ To see local changes to `@immich/ui` in Immich, do the following:
|
|||||||
|
|
||||||
1. Install `@immich/ui` as a sibling to `immich/`, for example `/home/user/immich` and `/home/user/ui`
|
1. Install `@immich/ui` as a sibling to `immich/`, for example `/home/user/immich` and `/home/user/ui`
|
||||||
2. Build the `@immich/ui` project via `pnpm run build`
|
2. Build the `@immich/ui` project via `pnpm run build`
|
||||||
3. Uncomment the corresponding volume in web service of the `docker/docker-compose.dev.yaml` file (`../../ui:/usr/ui`)
|
3. Uncomment the corresponding volume in web service of the `docker/docker-compose.dev.yml` file (`../../ui:/usr/src/ui`)
|
||||||
4. Uncomment the corresponding alias in the `web/vite.config.js` file (`'@immich/ui': path.resolve(\_\_dirname, '../../ui')`)
|
4. Uncomment the corresponding alias in the `web/vite.config.ts` file (`'@immich/ui': path.resolve(\_\_dirname, '../../ui/packages/ui')`)
|
||||||
5. Uncomment the import statement in `web/src/app.css` file `@import '/usr/ui/dist/theme/default.css';` and comment out `@import '@immich/ui/theme/default.css';`
|
5. Uncomment the import statement in `web/src/app.css` file `@import '../../../ui/packages/ui/dist/theme/default.css';` and comment out `@import '@immich/ui/theme/default.css';`
|
||||||
6. Start up the stack via `make dev`
|
6. Start up the stack via `make dev`
|
||||||
7. After making changes in `@immich/ui`, rebuild it (`pnpm run build`)
|
7. After making changes in `@immich/ui`, rebuild it (`pnpm run build`)
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
||||||
@@ -26,7 +26,7 @@ You can search the following types of content:
|
|||||||
| Time frame | Start and end date of a specific time bucket |
|
| Time frame | Start and end date of a specific time bucket |
|
||||||
| Media type | Image or video or both |
|
| Media type | Image or video or both |
|
||||||
| Display options | In Archive, in Favorites or Not in any album |
|
| Display options | In Archive, in Favorites or Not in any album |
|
||||||
| Start rating | User-assigned start rating |
|
| Star rating | User-assigned star rating |
|
||||||
|
|
||||||
<img src={require('./img/advanced-search-filters.webp').default} width="70%" title='Advanced search filters' />
|
<img src={require('./img/advanced-search-filters.webp').default} width="70%" title='Advanced search filters' />
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a
|
|||||||
| `JPEG 2000` | `.jp2` | :white_check_mark: | |
|
| `JPEG 2000` | `.jp2` | :white_check_mark: | |
|
||||||
| `JPEG` | `.jpeg` `.jpg` `.jpe` `.insp` | :white_check_mark: | |
|
| `JPEG` | `.jpeg` `.jpg` `.jpe` `.insp` | :white_check_mark: | |
|
||||||
| `JPEG XL` | `.jxl` | :white_check_mark: | |
|
| `JPEG XL` | `.jxl` | :white_check_mark: | |
|
||||||
|
| `MPO` | `.mpo` | :white_check_mark: | Multi-Picture |
|
||||||
| `PNG` | `.png` | :white_check_mark: | |
|
| `PNG` | `.png` | :white_check_mark: | |
|
||||||
| `PSD` | `.psd` | :white_check_mark: | Adobe Photoshop |
|
| `PSD` | `.psd` | :white_check_mark: | Adobe Photoshop |
|
||||||
| `RAW` | `.raw` | :white_check_mark: | |
|
| `RAW` | `.raw` | :white_check_mark: | |
|
||||||
@@ -28,17 +29,17 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a
|
|||||||
|
|
||||||
## Video formats
|
## Video formats
|
||||||
|
|
||||||
| Format | Extension(s) | Supported? | Notes |
|
| Format | Extension(s) | Supported? | Notes |
|
||||||
| :---------- | :-------------------- | :----------------: | :---- |
|
| :---------- | :-------------------------- | :----------------: | :---- |
|
||||||
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
|
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
|
||||||
| `AVI` | `.avi` | :white_check_mark: | |
|
| `AVI` | `.avi` | :white_check_mark: | |
|
||||||
| `FLV` | `.flv` | :white_check_mark: | |
|
| `FLV` | `.flv` | :white_check_mark: | |
|
||||||
| `M4V` | `.m4v` | :white_check_mark: | |
|
| `M4V` | `.m4v` | :white_check_mark: | |
|
||||||
| `MATROSKA` | `.mkv` | :white_check_mark: | |
|
| `MATROSKA` | `.mkv` | :white_check_mark: | |
|
||||||
| `MP2T` | `.mts` `.m2ts` `.m2t` | :white_check_mark: | |
|
| `MP2T` | `.mts` `.m2ts` `.m2t` `.ts` | :white_check_mark: | |
|
||||||
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
|
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
|
||||||
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
|
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
|
||||||
| `MXF` | `.mxf` | :white_check_mark: | |
|
| `MXF` | `.mxf` | :white_check_mark: | |
|
||||||
| `QUICKTIME` | `.mov` | :white_check_mark: | |
|
| `QUICKTIME` | `.mov` | :white_check_mark: | |
|
||||||
| `WEBM` | `.webm` | :white_check_mark: | |
|
| `WEBM` | `.webm` | :white_check_mark: | |
|
||||||
| `WMV` | `.wmv` | :white_check_mark: | |
|
| `WMV` | `.wmv` | :white_check_mark: | |
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
You may decide that you'd like to modify the style document which is used to
|
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
|
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
|
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)
|
`style.json` for [light theme](https://tiles.immich.cloud/v1/style/light.json)
|
||||||
and [dark theme](https://github.com/immich-app/immich/blob/main/server/resources/style-dark.json)
|
and [dark theme](https://tiles.immich.cloud/v1/style/dark.json)
|
||||||
can be used as a basis for creating your own style.
|
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
|
There are several sources for already-made `style.json` map themes, as well as
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ def upload(file):
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'deviceAssetId': f'{file}-{stats.st_mtime}',
|
|
||||||
'deviceId': 'python',
|
|
||||||
'fileCreatedAt': datetime.fromtimestamp(stats.st_mtime),
|
'fileCreatedAt': datetime.fromtimestamp(stats.st_mtime),
|
||||||
'fileModifiedAt': datetime.fromtimestamp(stats.st_mtime),
|
'fileModifiedAt': datetime.fromtimestamp(stats.st_mtime),
|
||||||
'isFavorite': 'false',
|
'isFavorite': 'false',
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ You can learn how to set up Tailscale together with Immich with the [tutorial vi
|
|||||||
### Cons
|
### Cons
|
||||||
|
|
||||||
- The Tailscale client usually needs to run as root on your devices and it increases the attack surface slightly compared to a minimal Wireguard server. e.g., an [RCE vulnerability](https://github.com/tailscale/tailscale/security/advisories/GHSA-vqp6-rc3h-83cp) was discovered in the Windows Tailscale client in November 2022.
|
- The Tailscale client usually needs to run as root on your devices and it increases the attack surface slightly compared to a minimal Wireguard server. e.g., an [RCE vulnerability](https://github.com/tailscale/tailscale/security/advisories/GHSA-vqp6-rc3h-83cp) was discovered in the Windows Tailscale client in November 2022.
|
||||||
- Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) that permits up to 3 users and up to 100 devices.
|
- Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) suitable for personal use.
|
||||||
- Tailscale needs to be installed and running on both server-side and client-side.
|
- Tailscale needs to be installed and running on both server-side and client-side.
|
||||||
|
|
||||||
## Option 3: Reverse Proxy
|
## Option 3: Reverse Proxy
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ The default configuration looks like this:
|
|||||||
"defaultStorageQuota": null,
|
"defaultStorageQuota": null,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"issuerUrl": "",
|
"issuerUrl": "",
|
||||||
|
"endSessionEndpoint": "",
|
||||||
"mobileOverrideEnabled": false,
|
"mobileOverrideEnabled": false,
|
||||||
"mobileRedirectUri": "",
|
"mobileRedirectUri": "",
|
||||||
"profileSigningAlgorithm": "none",
|
"profileSigningAlgorithm": "none",
|
||||||
|
|||||||
@@ -29,22 +29,23 @@ These environment variables are used by the `docker-compose.yml` file and do **N
|
|||||||
|
|
||||||
## General
|
## General
|
||||||
|
|
||||||
| Variable | Description | Default | Containers | Workers |
|
| Variable | Description | Default | Containers | Workers |
|
||||||
| :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
|
| :---------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
|
||||||
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
|
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
|
||||||
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, 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_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_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_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_CONFIG_FILE` | Path to config file | | server | api, microservices |
|
||||||
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
|
| `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 |
|
||||||
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
|
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
|
||||||
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
|
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
|
||||||
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
|
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
|
||||||
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
|
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
|
||||||
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
|
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
|
||||||
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
|
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
|
||||||
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | 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"`.
|
\*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.
|
`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.
|
||||||
@@ -80,7 +81,7 @@ Information on the current workers can be found [here](/administration/jobs-work
|
|||||||
| `DB_PASSWORD` | Database password | `postgres` | server, database<sup>\*1</sup> |
|
| `DB_PASSWORD` | Database password | `postgres` | server, database<sup>\*1</sup> |
|
||||||
| `DB_DATABASE_NAME` | Database name | `immich` | server, database<sup>\*1</sup> |
|
| `DB_DATABASE_NAME` | Database name | `immich` | server, database<sup>\*1</sup> |
|
||||||
| `DB_SSL_MODE` | Database SSL mode | | server |
|
| `DB_SSL_MODE` | Database SSL mode | | server |
|
||||||
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
|
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`]) | | server |
|
||||||
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
|
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
|
||||||
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database |
|
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database |
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Immich requires [**Docker**](https://docs.docker.com/get-started/get-docker/) wi
|
|||||||
The Compose plugin will be installed by both Docker Engine and Desktop by following the linked installation guides; it can also be [separately installed](https://docs.docker.com/compose/install/).
|
The Compose plugin will be installed by both Docker Engine and Desktop by following the linked installation guides; it can also be [separately installed](https://docs.docker.com/compose/install/).
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
Immich requires the command `docker compose`; the similarly named `docker-compose` is [deprecated](https://docs.docker.com/compose/migrate/) and is no longer supported by Immich.
|
Immich requires the command `docker compose`; the similarly named `docker-compose` is [deprecated](https://docs.docker.com/retired/#docker-compose-v1-replaced-by-compose-v2) and is no longer supported by Immich.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Special requirements for Windows users
|
### Special requirements for Windows users
|
||||||
|
|||||||
@@ -130,7 +130,3 @@ These storage mediums have different performance characteristics. As a result, t
|
|||||||
#### Can I use the new database image as a general PostgreSQL image outside of Immich?
|
#### Can I use the new database image as a general PostgreSQL image outside of Immich?
|
||||||
|
|
||||||
It’s a standard PostgreSQL container image that additionally contains the VectorChord, pgvector, and (optionally) pgvecto.rs extensions. If you were using the previous pgvecto.rs image for other purposes, you can similarly do so with this image.
|
It’s a standard PostgreSQL container image that additionally contains the VectorChord, pgvector, and (optionally) pgvecto.rs extensions. If you were using the previous pgvecto.rs image for other purposes, you can similarly do so with this image.
|
||||||
|
|
||||||
#### If pgvecto.rs and pgvector still work, why should I switch to VectorChord?
|
|
||||||
|
|
||||||
VectorChord is faster, more stable, uses less RAM, and (with the settings Immich uses) offers higher-quality results than pgvector and pgvecto.rs. This translates to better search and facial recognition experiences. In addition, pgvecto.rs support will be dropped in the future, so changing it sooner will avoid disruption.
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ You can read more about the differences between storage template engine on and o
|
|||||||
|
|
||||||
The admin user can set the template by using the template builder in the `Administration -> Settings -> Storage Template`. Immich provides a set of variables that you can use in constructing the template, along with additional custom text. If the template produces [multiple files with the same filename, they won't be overwritten](https://github.com/immich-app/immich/discussions/3324) as a sequence number is appended to the filename.
|
The admin user can set the template by using the template builder in the `Administration -> Settings -> Storage Template`. Immich provides a set of variables that you can use in constructing the template, along with additional custom text. If the template produces [multiple files with the same filename, they won't be overwritten](https://github.com/immich-app/immich/discussions/3324) as a sequence number is appended to the filename.
|
||||||
|
|
||||||
|
Date and time variables in storage templates are rendered in the server's local timezone.
|
||||||
|
|
||||||
```bash title="Default template"
|
```bash title="Default template"
|
||||||
Year/Year-Month-Day/Filename.Extension
|
Year/Year-Month-Day/Filename.Extension
|
||||||
```
|
```
|
||||||
|
|||||||
+11
-11
@@ -17,10 +17,10 @@
|
|||||||
"write-heading-ids": "docusaurus write-heading-ids"
|
"write-heading-ids": "docusaurus write-heading-ids"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docusaurus/core": "~3.9.0",
|
"@docusaurus/core": "~3.10.0",
|
||||||
"@docusaurus/preset-classic": "~3.9.0",
|
"@docusaurus/preset-classic": "~3.10.0",
|
||||||
"@docusaurus/theme-common": "~3.9.0",
|
"@docusaurus/theme-common": "~3.10.0",
|
||||||
"@docusaurus/theme-mermaid": "~3.9.0",
|
"@docusaurus/theme-mermaid": "~3.10.0",
|
||||||
"@mdi/js": "^7.3.67",
|
"@mdi/js": "^7.3.67",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mdi/react": "^1.6.1",
|
||||||
"@mdx-js/react": "^3.0.0",
|
"@mdx-js/react": "^3.0.0",
|
||||||
@@ -30,17 +30,17 @@
|
|||||||
"postcss": "^8.4.25",
|
"postcss": "^8.4.25",
|
||||||
"prism-react-renderer": "^2.3.1",
|
"prism-react-renderer": "^2.3.1",
|
||||||
"raw-loader": "^4.0.2",
|
"raw-loader": "^4.0.2",
|
||||||
"react": "^18.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^18.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"tailwindcss": "^3.2.4",
|
"tailwindcss": "^3.2.4",
|
||||||
"url": "^0.11.0"
|
"url": "^0.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@docusaurus/module-type-aliases": "~3.9.0",
|
"@docusaurus/module-type-aliases": "~3.10.0",
|
||||||
"@docusaurus/tsconfig": "^3.7.0",
|
"@docusaurus/tsconfig": "^3.10.0",
|
||||||
"@docusaurus/types": "^3.7.0",
|
"@docusaurus/types": "^3.10.0",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"typescript": "^5.1.6"
|
"typescript": "^6.0.0"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
@@ -58,6 +58,6 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.13.1"
|
"node": "24.15.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+6
-2
@@ -1,7 +1,11 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"label": "v2.6.2",
|
"label": "v2.7.5",
|
||||||
"url": "https://docs.v2.6.2.archive.immich.app"
|
"url": "https://docs.v2.7.5.archive.immich.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "v2.6.3",
|
||||||
|
"url": "https://docs.v2.6.3.archive.immich.app"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "v2.5.6",
|
"label": "v2.5.6",
|
||||||
|
|||||||
+1
-5
@@ -1,8 +1,4 @@
|
|||||||
{
|
{
|
||||||
// This file is not used in compilation. It is here just for a nice editor experience.
|
// This file is not used in compilation. It is here just for a nice editor experience.
|
||||||
"extends": "@docusaurus/tsconfig",
|
"extends": "@docusaurus/tsconfig"
|
||||||
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": "."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { exportJWK, generateKeyPair } from 'jose';
|
import {
|
||||||
|
calculateJwkThumbprint,
|
||||||
|
exportJWK,
|
||||||
|
importPKCS8,
|
||||||
|
importSPKI,
|
||||||
|
SignJWT,
|
||||||
|
} from 'jose';
|
||||||
import Provider from 'oidc-provider';
|
import Provider from 'oidc-provider';
|
||||||
|
import { PRIVATE_KEY_PEM, PUBLIC_KEY_PEM } from './test-keys';
|
||||||
|
|
||||||
export enum OAuthClient {
|
export enum OAuthClient {
|
||||||
DEFAULT = 'client-default',
|
DEFAULT = 'client-default',
|
||||||
@@ -44,6 +51,29 @@ const claims = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const privateKey = await importPKCS8(PRIVATE_KEY_PEM, 'RS256', {
|
||||||
|
extractable: true,
|
||||||
|
});
|
||||||
|
const publicKey = await importSPKI(PUBLIC_KEY_PEM, 'RS256', {
|
||||||
|
extractable: true,
|
||||||
|
});
|
||||||
|
const kid = await calculateJwkThumbprint(await exportJWK(publicKey));
|
||||||
|
|
||||||
|
export async function generateLogoutToken(iss: string, sub: string) {
|
||||||
|
return await new SignJWT({
|
||||||
|
iss: iss,
|
||||||
|
aud: OAuthClient.DEFAULT,
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
jti: crypto.randomUUID(),
|
||||||
|
sub: sub,
|
||||||
|
events: {
|
||||||
|
'http://schemas.openid.net/event/backchannel-logout': {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: 'RS256', typ: 'logout+jwt', kid: kid })
|
||||||
|
.sign(privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
const withDefaultClaims = (sub: string) => ({
|
const withDefaultClaims = (sub: string) => ({
|
||||||
sub,
|
sub,
|
||||||
email: `${sub}@immich.app`,
|
email: `${sub}@immich.app`,
|
||||||
@@ -66,8 +96,6 @@ const getClaims = (sub: string, use?: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
|
||||||
|
|
||||||
const redirectUris = [
|
const redirectUris = [
|
||||||
'http://127.0.0.1:2285/auth/login',
|
'http://127.0.0.1:2285/auth/login',
|
||||||
'https://photos.immich.app/oauth/mobile-redirect',
|
'https://photos.immich.app/oauth/mobile-redirect',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"start": "tsx startup.ts"
|
"start": "tsx startup.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jose": "^5.6.3",
|
"jose": "^6.0.0",
|
||||||
"@types/oidc-provider": "^9.0.0",
|
"@types/oidc-provider": "^9.0.0",
|
||||||
"oidc-provider": "^9.0.0",
|
"oidc-provider": "^9.0.0",
|
||||||
"tsx": "^4.20.6"
|
"tsx": "^4.20.6"
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export const PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCVj5C7hzN3E2HO
|
||||||
|
TcJ+DN/e2NSTQFj4rPylz4J8xjm8Es7l0k2kK5EEGvUNVGZbw7s055c+6kwP9eqg
|
||||||
|
B5XFE7+26Fcq1sou6Tbm310kU4dnMW5l2CgwrhaGyb1pNysao0AMLT60dFYqtUwn
|
||||||
|
ha9ceCsa+ZU1JrknVf3rONtppBvhWoI7CO9XX1keVQ0unHPzCWUjpXTzC8OGEbmB
|
||||||
|
2w7ZIUf8OfJkd5RZ4OtIpML71W9n13aDxT50x2/EW/pFLFtQ/oaleOKHpvlRXDRX
|
||||||
|
W86G4moUJym3gHMXMUj2aOcFG2UJnpLruKz3i5qZwYiTRlBP6O9EIQNCVtYxchuN
|
||||||
|
V1CCcBU1AgMBAAECggEAJLfXMu8Nx89ynPVyyUMMaFfoEpHC9iR0L5obQVpiPMYK
|
||||||
|
VRqVVLecdftPS9s7eQ58BNBRzdC0ZVu841aRYs3HLNbsZZhPkYZQpAxU//Dg5okY
|
||||||
|
fzj7Hv5yidt4HN9+Pd8z/3lRMnj4WapifLaBt8xJ2ujJBMBRxzJBsXDnT0+Kx7+y
|
||||||
|
bYDeuVfyUTEikaK3QZTbuRF3D3eiuN16GG+hv8UqTF2eYbPxdiLjYpTSHa4mH88C
|
||||||
|
qfJz2Xt4SEzmyeo3G+MO17wDFOwtEe8ojlJfULHnHJSFdUwTfYIFM1bg5/fJ9MOS
|
||||||
|
/fO3TSG+wkQqjQa6eoGssAzP87fL2XNLzlDtGY/7uQKBgQDHuJHOtf1EjOvNYiP7
|
||||||
|
EN+8QGs41ghzt9CQRQxWbHpusR3IW3P83KMXwYmrlG70oOUXBRGSB/ESXUofXc5W
|
||||||
|
pu5+Y55S44aUnu/a9yOBttYW0dtHZSL0zFT+PlVASwUzFZ2zcH1KXlUkSpfL5OAD
|
||||||
|
PyDDTnBZ2AWh45fRO9wLo6PPuQKBgQC/tI03RqU3mOjqukKbquYeIpXHfRU5Z0DM
|
||||||
|
u9ru1THYEl6fmkMXycxo/mvW3awyFuyKy/VodqIgKnFgumEqCHZh6OAMm/LC7TfA
|
||||||
|
l9tjFSs/MyOqQVD4kbX+z6Oq4c4GccDoXfsQ3gzECoBapegi/F+6/25y+/C8ghXb
|
||||||
|
J/Jg1GQXXQKBgQDFgWbfzuVZZyrBfu4qGLPJDMN7/114YizknwPma3xf/tN/EcGQ
|
||||||
|
K/k1QvWMMkvPq1UiAKcxjJ0AFjV482FcG9T6NDWbrtmmG88C8Sex3Ue2ZW2+GuwI
|
||||||
|
vhDHJIlV/Vp0/Elp7DJa2xLDwuh+gCZvz3vs6KL+ljxrrhCyn8mp0PfsMQKBgFFZ
|
||||||
|
KnuETOO0zVGdzFoGQTQUdP58A5+iQwsdxB+I9Ge+E80iRso3ZbhADj7VPhbbR3D2
|
||||||
|
b6LuhImluQrUzBpsEOAnU7vGCVPSGdBuIDiBaSKebsn2gYeZPWNtdQQ0YZq2dqek
|
||||||
|
Cb/0mfIuipzsvf7qnSza62F7q4IyqVegMegI+Jg5AoGATM3NMy7JZeKzSkm+3ohU
|
||||||
|
3xZOwgqKV9SH+0OeYWpuBxT7D7FlrKKI4NJ3XN3hg2f/DJAF6dH11CPe7pk94yol
|
||||||
|
HMbh+PQUQ6GYvAzxIOvagWboQ3lzeyubNMpyFjfOrIE/WOQCUBZ9tIwCHIarIuyi
|
||||||
|
QRuNOj3+U8T/n1Ww352HBdw=
|
||||||
|
-----END PRIVATE KEY-----`;
|
||||||
|
|
||||||
|
export const PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlY+Qu4czdxNhzk3Cfgzf
|
||||||
|
3tjUk0BY+Kz8pc+CfMY5vBLO5dJNpCuRBBr1DVRmW8O7NOeXPupMD/XqoAeVxRO/
|
||||||
|
tuhXKtbKLuk25t9dJFOHZzFuZdgoMK4Whsm9aTcrGqNADC0+tHRWKrVMJ4WvXHgr
|
||||||
|
GvmVNSa5J1X96zjbaaQb4VqCOwjvV19ZHlUNLpxz8wllI6V08wvDhhG5gdsO2SFH
|
||||||
|
/DnyZHeUWeDrSKTC+9VvZ9d2g8U+dMdvxFv6RSxbUP6GpXjih6b5UVw0V1vOhuJq
|
||||||
|
FCcpt4BzFzFI9mjnBRtlCZ6S67is94uamcGIk0ZQT+jvRCEDQlbWMXIbjVdQgnAV
|
||||||
|
NQIDAQAB
|
||||||
|
-----END PUBLIC KEY-----`;
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
24.13.1
|
24.15.0
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich-e2e-redis
|
container_name: immich-e2e-redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "2.6.2",
|
"version": "2.7.5",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -32,15 +32,15 @@
|
|||||||
"@playwright/test": "^1.44.1",
|
"@playwright/test": "^1.44.1",
|
||||||
"@socket.io/component-emitter": "^3.1.2",
|
"@socket.io/component-emitter": "^3.1.2",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^24.11.0",
|
"@types/node": "^24.12.2",
|
||||||
"@types/pg": "^8.15.1",
|
"@types/pg": "^8.15.1",
|
||||||
"@types/pngjs": "^6.0.4",
|
"@types/pngjs": "^6.0.4",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^7.0.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"eslint": "^10.0.0",
|
"eslint": "^10.0.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"eslint-plugin-unicorn": "^63.0.0",
|
"eslint-plugin-unicorn": "^64.0.0",
|
||||||
"exiftool-vendored": "^35.0.0",
|
"exiftool-vendored": "^35.0.0",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
@@ -51,13 +51,13 @@
|
|||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"socket.io-client": "^4.7.4",
|
"socket.io-client": "^4.7.4",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^6.0.0",
|
||||||
"typescript-eslint": "^8.28.0",
|
"typescript-eslint": "^8.28.0",
|
||||||
"utimes": "^5.2.1",
|
"utimes": "^5.2.1",
|
||||||
"vite-tsconfig-paths": "^6.1.1",
|
"vite-tsconfig-paths": "^6.1.1",
|
||||||
"vitest": "^4.0.0"
|
"vitest": "^4.0.0"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.13.1"
|
"node": "24.15.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',
|
invalid: 'invalid-uuid',
|
||||||
// valid uuid v4
|
// valid uuid v4
|
||||||
notFound: '00000000-0000-4000-a000-000000000000',
|
notFound: '00000000-0000-4000-a000-000000000000',
|
||||||
|
dummy: '00000000-0000-4000-a000-000000000001',
|
||||||
|
dummy2: '00000000-0000-4000-a000-000000000002',
|
||||||
};
|
};
|
||||||
|
|
||||||
const adminLoginDto = {
|
const adminLoginDto = {
|
||||||
|
|||||||
+4
-42
@@ -2,82 +2,44 @@ import { expect } from 'vitest';
|
|||||||
|
|
||||||
export const errorDto = {
|
export const errorDto = {
|
||||||
unauthorized: {
|
unauthorized: {
|
||||||
error: 'Unauthorized',
|
|
||||||
statusCode: 401,
|
|
||||||
message: 'Authentication required',
|
message: 'Authentication required',
|
||||||
correlationId: expect.any(String),
|
|
||||||
},
|
},
|
||||||
unauthorizedWithMessage: (message: string) => ({
|
unauthorizedWithMessage: (message: string) => ({
|
||||||
error: 'Unauthorized',
|
|
||||||
statusCode: 401,
|
|
||||||
message,
|
message,
|
||||||
correlationId: expect.any(String),
|
|
||||||
}),
|
}),
|
||||||
forbidden: {
|
forbidden: {
|
||||||
error: 'Forbidden',
|
|
||||||
statusCode: 403,
|
|
||||||
message: expect.any(String),
|
message: expect.any(String),
|
||||||
correlationId: expect.any(String),
|
|
||||||
},
|
},
|
||||||
missingPermission: (permission: string) => ({
|
missingPermission: (permission: string) => ({
|
||||||
error: 'Forbidden',
|
|
||||||
statusCode: 403,
|
|
||||||
message: `Missing required permission: ${permission}`,
|
message: `Missing required permission: ${permission}`,
|
||||||
correlationId: expect.any(String),
|
|
||||||
}),
|
}),
|
||||||
wrongPassword: {
|
wrongPassword: {
|
||||||
error: 'Bad Request',
|
|
||||||
statusCode: 400,
|
|
||||||
message: 'Wrong password',
|
message: 'Wrong password',
|
||||||
correlationId: expect.any(String),
|
|
||||||
},
|
},
|
||||||
invalidToken: {
|
invalidToken: {
|
||||||
error: 'Unauthorized',
|
|
||||||
statusCode: 401,
|
|
||||||
message: 'Invalid user token',
|
message: 'Invalid user token',
|
||||||
correlationId: expect.any(String),
|
|
||||||
},
|
},
|
||||||
invalidShareKey: {
|
invalidShareKey: {
|
||||||
error: 'Unauthorized',
|
|
||||||
statusCode: 401,
|
|
||||||
message: 'Invalid share key',
|
message: 'Invalid share key',
|
||||||
correlationId: expect.any(String),
|
|
||||||
},
|
},
|
||||||
passwordRequired: {
|
passwordRequired: {
|
||||||
error: 'Unauthorized',
|
|
||||||
statusCode: 401,
|
|
||||||
message: 'Password required',
|
message: 'Password required',
|
||||||
correlationId: expect.any(String),
|
|
||||||
},
|
},
|
||||||
badRequest: (message: any = null) => ({
|
badRequest: (message: any = null) => ({
|
||||||
error: 'Bad Request',
|
|
||||||
statusCode: 400,
|
|
||||||
message: message ?? expect.anything(),
|
message: message ?? expect.anything(),
|
||||||
correlationId: expect.any(String),
|
}),
|
||||||
|
validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray<string | number>; message: string }>) => ({
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array),
|
||||||
}),
|
}),
|
||||||
noPermission: {
|
noPermission: {
|
||||||
error: 'Bad Request',
|
|
||||||
statusCode: 400,
|
|
||||||
message: expect.stringContaining('Not found or no'),
|
message: expect.stringContaining('Not found or no'),
|
||||||
correlationId: expect.any(String),
|
|
||||||
},
|
},
|
||||||
incorrectLogin: {
|
incorrectLogin: {
|
||||||
error: 'Unauthorized',
|
|
||||||
statusCode: 401,
|
|
||||||
message: 'Incorrect email or password',
|
message: 'Incorrect email or password',
|
||||||
correlationId: expect.any(String),
|
|
||||||
},
|
},
|
||||||
alreadyHasAdmin: {
|
alreadyHasAdmin: {
|
||||||
error: 'Bad Request',
|
|
||||||
statusCode: 400,
|
|
||||||
message: 'The server already has an admin',
|
message: 'The server already has an admin',
|
||||||
correlationId: expect.any(String),
|
|
||||||
},
|
|
||||||
invalidEmail: {
|
|
||||||
error: 'Bad Request',
|
|
||||||
statusCode: 400,
|
|
||||||
message: ['email must be an email'],
|
|
||||||
correlationId: expect.any(String),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ describe('/admin/database-backups', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await utils.resetDatabase();
|
await utils.resetDatabase();
|
||||||
admin = await utils.adminSetup();
|
admin = await utils.adminSetup({
|
||||||
|
onboarding: false,
|
||||||
|
});
|
||||||
await utils.resetBackups(admin.accessToken);
|
await utils.resetBackups(admin.accessToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,7 +96,9 @@ describe('/admin/database-backups', () => {
|
|||||||
({ status, body }) => status === 200 && !body.maintenanceMode,
|
({ 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 () => {
|
it.sequential('should not work when the server is configured', async () => {
|
||||||
|
|||||||
@@ -130,12 +130,11 @@ describe('/albums', () => {
|
|||||||
describe('GET /albums', () => {
|
describe('GET /albums', () => {
|
||||||
it("should not show other users' favorites", async () => {
|
it("should not show other users' favorites", async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get(`/albums/${user1Albums[0].id}?withoutAssets=false`)
|
.get(`/albums/${user1Albums[0].id}`)
|
||||||
.set('Authorization', `Bearer ${user2.accessToken}`);
|
.set('Authorization', `Bearer ${user2.accessToken}`);
|
||||||
expect(status).toEqual(200);
|
expect(status).toEqual(200);
|
||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
...user1Albums[0],
|
...user1Albums[0],
|
||||||
assets: [expect.objectContaining({ isFavorite: false })],
|
|
||||||
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
|
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
|
||||||
lastModifiedAssetTimestamp: expect.any(String),
|
lastModifiedAssetTimestamp: expect.any(String),
|
||||||
startDate: expect.any(String),
|
startDate: expect.any(String),
|
||||||
@@ -155,23 +154,31 @@ describe('/albums', () => {
|
|||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
ownerId: user1.userId,
|
|
||||||
albumName: user1SharedLink,
|
albumName: user1SharedLink,
|
||||||
|
albumUsers: expect.arrayContaining([
|
||||||
|
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||||
|
]),
|
||||||
shared: true,
|
shared: true,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
ownerId: user1.userId,
|
|
||||||
albumName: user1SharedEditorUser,
|
albumName: user1SharedEditorUser,
|
||||||
|
albumUsers: expect.arrayContaining([
|
||||||
|
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||||
|
]),
|
||||||
shared: true,
|
shared: true,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
ownerId: user1.userId,
|
|
||||||
albumName: user1SharedViewerUser,
|
albumName: user1SharedViewerUser,
|
||||||
|
albumUsers: expect.arrayContaining([
|
||||||
|
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||||
|
]),
|
||||||
shared: true,
|
shared: true,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
ownerId: user2.userId,
|
|
||||||
albumName: user2SharedUser,
|
albumName: user2SharedUser,
|
||||||
|
albumUsers: expect.arrayContaining([
|
||||||
|
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user2.userId }) },
|
||||||
|
]),
|
||||||
shared: true,
|
shared: true,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
@@ -185,23 +192,31 @@ describe('/albums', () => {
|
|||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
ownerId: user1.userId,
|
|
||||||
albumName: user1SharedEditorUser,
|
albumName: user1SharedEditorUser,
|
||||||
|
albumUsers: expect.arrayContaining([
|
||||||
|
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||||
|
]),
|
||||||
shared: true,
|
shared: true,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
ownerId: user1.userId,
|
|
||||||
albumName: user1SharedViewerUser,
|
albumName: user1SharedViewerUser,
|
||||||
|
albumUsers: expect.arrayContaining([
|
||||||
|
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||||
|
]),
|
||||||
shared: true,
|
shared: true,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
ownerId: user1.userId,
|
|
||||||
albumName: user1SharedLink,
|
albumName: user1SharedLink,
|
||||||
|
albumUsers: expect.arrayContaining([
|
||||||
|
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||||
|
]),
|
||||||
shared: true,
|
shared: true,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
ownerId: user1.userId,
|
|
||||||
albumName: user1NotShared,
|
albumName: user1NotShared,
|
||||||
|
albumUsers: expect.arrayContaining([
|
||||||
|
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||||
|
]),
|
||||||
shared: false,
|
shared: false,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
@@ -217,23 +232,31 @@ describe('/albums', () => {
|
|||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
ownerId: user1.userId,
|
|
||||||
albumName: user1SharedEditorUser,
|
albumName: user1SharedEditorUser,
|
||||||
|
albumUsers: expect.arrayContaining([
|
||||||
|
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||||
|
]),
|
||||||
shared: true,
|
shared: true,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
ownerId: user1.userId,
|
|
||||||
albumName: user1SharedViewerUser,
|
albumName: user1SharedViewerUser,
|
||||||
|
albumUsers: expect.arrayContaining([
|
||||||
|
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||||
|
]),
|
||||||
shared: true,
|
shared: true,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
ownerId: user1.userId,
|
|
||||||
albumName: user1SharedLink,
|
albumName: user1SharedLink,
|
||||||
|
albumUsers: expect.arrayContaining([
|
||||||
|
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||||
|
]),
|
||||||
shared: true,
|
shared: true,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
ownerId: user2.userId,
|
|
||||||
albumName: user2SharedUser,
|
albumName: user2SharedUser,
|
||||||
|
albumUsers: expect.arrayContaining([
|
||||||
|
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user2.userId }) },
|
||||||
|
]),
|
||||||
shared: true,
|
shared: true,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
@@ -249,8 +272,10 @@ describe('/albums', () => {
|
|||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
ownerId: user1.userId,
|
|
||||||
albumName: user1NotShared,
|
albumName: user1NotShared,
|
||||||
|
albumUsers: expect.arrayContaining([
|
||||||
|
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
|
||||||
|
]),
|
||||||
shared: false,
|
shared: false,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
@@ -287,13 +312,17 @@ describe('/albums', () => {
|
|||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
ownerId: user4.userId,
|
|
||||||
albumName: user4DeletedAsset,
|
albumName: user4DeletedAsset,
|
||||||
|
albumUsers: expect.arrayContaining([
|
||||||
|
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user4.userId }) },
|
||||||
|
]),
|
||||||
shared: false,
|
shared: false,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
ownerId: user4.userId,
|
|
||||||
albumName: user4Empty,
|
albumName: user4Empty,
|
||||||
|
albumUsers: expect.arrayContaining([
|
||||||
|
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user4.userId }) },
|
||||||
|
]),
|
||||||
shared: false,
|
shared: false,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
@@ -304,13 +333,12 @@ describe('/albums', () => {
|
|||||||
describe('GET /albums/:id', () => {
|
describe('GET /albums/:id', () => {
|
||||||
it('should return album info for own album', async () => {
|
it('should return album info for own album', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get(`/albums/${user1Albums[0].id}?withoutAssets=false`)
|
.get(`/albums/${user1Albums[0].id}`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
...user1Albums[0],
|
...user1Albums[0],
|
||||||
assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
|
|
||||||
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
|
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
|
||||||
lastModifiedAssetTimestamp: expect.any(String),
|
lastModifiedAssetTimestamp: expect.any(String),
|
||||||
startDate: expect.any(String),
|
startDate: expect.any(String),
|
||||||
@@ -322,7 +350,7 @@ describe('/albums', () => {
|
|||||||
|
|
||||||
it('should return album info for shared album (editor)', async () => {
|
it('should return album info for shared album (editor)', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get(`/albums/${user2Albums[0].id}?withoutAssets=false`)
|
.get(`/albums/${user2Albums[0].id}`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
@@ -331,14 +359,14 @@ describe('/albums', () => {
|
|||||||
|
|
||||||
it('should return album info for shared album (viewer)', async () => {
|
it('should return album info for shared album (viewer)', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get(`/albums/${user1Albums[3].id}?withoutAssets=false`)
|
.get(`/albums/${user1Albums[3].id}`)
|
||||||
.set('Authorization', `Bearer ${user2.accessToken}`);
|
.set('Authorization', `Bearer ${user2.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toMatchObject({ id: user1Albums[3].id });
|
expect(body).toMatchObject({ id: user1Albums[3].id });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return album info with assets when withoutAssets is undefined', async () => {
|
it('should return album info', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get(`/albums/${user1Albums[0].id}`)
|
.get(`/albums/${user1Albums[0].id}`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||||
@@ -346,25 +374,6 @@ describe('/albums', () => {
|
|||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
...user1Albums[0],
|
...user1Albums[0],
|
||||||
assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
|
|
||||||
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
|
|
||||||
lastModifiedAssetTimestamp: expect.any(String),
|
|
||||||
startDate: expect.any(String),
|
|
||||||
endDate: expect.any(String),
|
|
||||||
albumUsers: expect.any(Array),
|
|
||||||
shared: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return album info without assets when withoutAssets is true', async () => {
|
|
||||||
const { status, body } = await request(app)
|
|
||||||
.get(`/albums/${user1Albums[0].id}?withoutAssets=true`)
|
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
|
||||||
|
|
||||||
expect(status).toBe(200);
|
|
||||||
expect(body).toEqual({
|
|
||||||
...user1Albums[0],
|
|
||||||
assets: [],
|
|
||||||
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
|
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
|
||||||
assetCount: 1,
|
assetCount: 1,
|
||||||
lastModifiedAssetTimestamp: expect.any(String),
|
lastModifiedAssetTimestamp: expect.any(String),
|
||||||
@@ -379,21 +388,21 @@ describe('/albums', () => {
|
|||||||
await utils.deleteAssets(user1.accessToken, [user1Asset2.id]);
|
await utils.deleteAssets(user1.accessToken, [user1Asset2.id]);
|
||||||
|
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get(`/albums/${user2Albums[0].id}?withoutAssets=true`)
|
.get(`/albums/${user2Albums[0].id}`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toEqual({
|
expect(body).toEqual(
|
||||||
...user2Albums[0],
|
expect.objectContaining({
|
||||||
assets: [],
|
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
|
||||||
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
|
assetCount: 1,
|
||||||
assetCount: 1,
|
lastModifiedAssetTimestamp: expect.any(String),
|
||||||
lastModifiedAssetTimestamp: expect.any(String),
|
endDate: expect.any(String),
|
||||||
endDate: expect.any(String),
|
startDate: expect.any(String),
|
||||||
startDate: expect.any(String),
|
albumUsers: expect.any(Array),
|
||||||
albumUsers: expect.any(Array),
|
shared: true,
|
||||||
shared: true,
|
}),
|
||||||
});
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -419,16 +428,13 @@ describe('/albums', () => {
|
|||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
createdAt: expect.any(String),
|
createdAt: expect.any(String),
|
||||||
updatedAt: expect.any(String),
|
updatedAt: expect.any(String),
|
||||||
ownerId: user1.userId,
|
|
||||||
albumName: 'New album',
|
albumName: 'New album',
|
||||||
description: '',
|
description: '',
|
||||||
albumThumbnailAssetId: null,
|
albumThumbnailAssetId: null,
|
||||||
shared: false,
|
shared: false,
|
||||||
albumUsers: [],
|
albumUsers: [{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) }],
|
||||||
hasSharedLink: false,
|
hasSharedLink: false,
|
||||||
assets: [],
|
|
||||||
assetCount: 0,
|
assetCount: 0,
|
||||||
owner: expect.objectContaining({ email: user1.userEmail }),
|
|
||||||
isActivityEnabled: true,
|
isActivityEnabled: true,
|
||||||
order: AssetOrder.Desc,
|
order: AssetOrder.Desc,
|
||||||
});
|
});
|
||||||
@@ -644,11 +650,11 @@ describe('/albums', () => {
|
|||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
albumUsers: [
|
albumUsers: expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
user: expect.objectContaining({ id: user2.userId }),
|
user: expect.objectContaining({ id: user2.userId }),
|
||||||
}),
|
}),
|
||||||
],
|
]),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -660,7 +666,7 @@ describe('/albums', () => {
|
|||||||
.send({ albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }] });
|
.send({ albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }] });
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest('Cannot be shared with owner'));
|
expect(body).toEqual(errorDto.badRequest('User already added'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not be able to add existing user to shared album', async () => {
|
it('should not be able to add existing user to shared album', async () => {
|
||||||
@@ -686,7 +692,7 @@ describe('/albums', () => {
|
|||||||
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
|
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(album.albumUsers[0].role).toEqual(AlbumUserRole.Viewer);
|
expect(album.albumUsers[1].role).toEqual(AlbumUserRole.Viewer);
|
||||||
|
|
||||||
const { status } = await request(app)
|
const { status } = await request(app)
|
||||||
.put(`/albums/${album.id}/user/${user2.userId}`)
|
.put(`/albums/${album.id}/user/${user2.userId}`)
|
||||||
@@ -701,7 +707,10 @@ describe('/albums', () => {
|
|||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
albumUsers: [expect.objectContaining({ role: AlbumUserRole.Editor })],
|
albumUsers: [
|
||||||
|
expect.objectContaining({ role: AlbumUserRole.Owner }),
|
||||||
|
expect.objectContaining({ role: AlbumUserRole.Editor }),
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -712,7 +721,7 @@ describe('/albums', () => {
|
|||||||
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
|
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(album.albumUsers[0].role).toEqual(AlbumUserRole.Viewer);
|
expect(album.albumUsers[1].role).toEqual(AlbumUserRole.Viewer);
|
||||||
|
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.put(`/albums/${album.id}/user/${user2.userId}`)
|
.put(`/albums/${album.id}/user/${user2.userId}`)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
AssetMediaResponseDto,
|
AssetMediaResponseDto,
|
||||||
AssetMediaStatus,
|
AssetMediaStatus,
|
||||||
AssetResponseDto,
|
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
AssetVisibility,
|
AssetVisibility,
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
@@ -19,7 +18,7 @@ import { Socket } from 'socket.io-client';
|
|||||||
import { createUserDto, uuidDto } from 'src/fixtures';
|
import { createUserDto, uuidDto } from 'src/fixtures';
|
||||||
import { makeRandomImage } from 'src/generators';
|
import { makeRandomImage } from 'src/generators';
|
||||||
import { errorDto } from 'src/responses';
|
import { errorDto } from 'src/responses';
|
||||||
import { app, asBearerAuth, tempDir, TEN_TIMES, testAssetDir, utils } from 'src/utils';
|
import { app, asBearerAuth, tempDir, testAssetDir, utils } from 'src/utils';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
@@ -95,8 +94,8 @@ describe('/asset', () => {
|
|||||||
utils.createAsset(user1.accessToken),
|
utils.createAsset(user1.accessToken),
|
||||||
utils.createAsset(user1.accessToken, {
|
utils.createAsset(user1.accessToken, {
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
fileCreatedAt: yesterday.toISO(),
|
fileCreatedAt: yesterday.toUTC().toISO(),
|
||||||
fileModifiedAt: yesterday.toISO(),
|
fileModifiedAt: yesterday.toUTC().toISO(),
|
||||||
assetData: { filename: 'example.mp4' },
|
assetData: { filename: 'example.mp4' },
|
||||||
}),
|
}),
|
||||||
utils.createAsset(user1.accessToken),
|
utils.createAsset(user1.accessToken),
|
||||||
@@ -380,62 +379,12 @@ describe('/asset', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /assets/random', () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
await Promise.all([
|
|
||||||
utils.createAsset(user1.accessToken),
|
|
||||||
utils.createAsset(user1.accessToken),
|
|
||||||
utils.createAsset(user1.accessToken),
|
|
||||||
utils.createAsset(user1.accessToken),
|
|
||||||
utils.createAsset(user1.accessToken),
|
|
||||||
utils.createAsset(user1.accessToken),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'thumbnailGeneration');
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(TEN_TIMES)('should return 1 random assets', async () => {
|
|
||||||
const { status, body } = await request(app)
|
|
||||||
.get('/assets/random')
|
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
|
||||||
|
|
||||||
expect(status).toBe(200);
|
|
||||||
|
|
||||||
const assets: AssetResponseDto[] = body;
|
|
||||||
expect(assets.length).toBe(1);
|
|
||||||
expect(assets[0].ownerId).toBe(user1.userId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(TEN_TIMES)('should return 2 random assets', async () => {
|
|
||||||
const { status, body } = await request(app)
|
|
||||||
.get('/assets/random?count=2')
|
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
|
||||||
|
|
||||||
expect(status).toBe(200);
|
|
||||||
|
|
||||||
const assets: AssetResponseDto[] = body;
|
|
||||||
expect(assets.length).toBe(2);
|
|
||||||
|
|
||||||
for (const asset of assets) {
|
|
||||||
expect(asset.ownerId).toBe(user1.userId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it.skip('should return 1 asset if there are 10 assets in the database but user 2 only has 1', async () => {
|
|
||||||
const { status, body } = await request(app)
|
|
||||||
.get('/assets/random')
|
|
||||||
.set('Authorization', `Bearer ${user2.accessToken}`);
|
|
||||||
|
|
||||||
expect(status).toBe(200);
|
|
||||||
expect(body).toEqual([expect.objectContaining({ id: user2Assets[0].id })]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PUT /assets/:id', () => {
|
describe('PUT /assets/:id', () => {
|
||||||
it('should require access', async () => {
|
it('should require access', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.put(`/assets/${user2Assets[0].id}`)
|
.put(`/assets/${user2Assets[0].id}`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({});
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.noPermission);
|
expect(body).toEqual(errorDto.noPermission);
|
||||||
});
|
});
|
||||||
@@ -1142,8 +1091,6 @@ describe('/asset', () => {
|
|||||||
const { body, status } = await request(app)
|
const { body, status } = await request(app)
|
||||||
.post('/assets')
|
.post('/assets')
|
||||||
.set('Authorization', `Bearer ${quotaUser.accessToken}`)
|
.set('Authorization', `Bearer ${quotaUser.accessToken}`)
|
||||||
.field('deviceAssetId', 'example-image')
|
|
||||||
.field('deviceId', 'e2e')
|
|
||||||
.field('fileCreatedAt', new Date().toISOString())
|
.field('fileCreatedAt', new Date().toISOString())
|
||||||
.field('fileModifiedAt', new Date().toISOString())
|
.field('fileModifiedAt', new Date().toISOString())
|
||||||
.attach('assetData', makeRandomImage(), 'example.jpg');
|
.attach('assetData', makeRandomImage(), 'example.jpg');
|
||||||
@@ -1160,8 +1107,6 @@ describe('/asset', () => {
|
|||||||
const { body, status } = await request(app)
|
const { body, status } = await request(app)
|
||||||
.post('/assets')
|
.post('/assets')
|
||||||
.set('Authorization', `Bearer ${quotaUser.accessToken}`)
|
.set('Authorization', `Bearer ${quotaUser.accessToken}`)
|
||||||
.field('deviceAssetId', 'example-image')
|
|
||||||
.field('deviceId', 'e2e')
|
|
||||||
.field('fileCreatedAt', new Date().toISOString())
|
.field('fileCreatedAt', new Date().toISOString())
|
||||||
.field('fileModifiedAt', new Date().toISOString())
|
.field('fileModifiedAt', new Date().toISOString())
|
||||||
.attach('assetData', randomBytes(2014), 'example.jpg');
|
.attach('assetData', randomBytes(2014), 'example.jpg');
|
||||||
@@ -1215,29 +1160,4 @@ describe('/asset', () => {
|
|||||||
expect(video.checksum).toStrictEqual(checksum);
|
expect(video.checksum).toStrictEqual(checksum);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /assets/exist', () => {
|
|
||||||
it('ignores invalid deviceAssetIds', async () => {
|
|
||||||
const response = await utils.checkExistingAssets(user1.accessToken, {
|
|
||||||
deviceId: 'test-assets-exist',
|
|
||||||
deviceAssetIds: ['invalid', 'INVALID'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.existingIds).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the IDs of existing assets', async () => {
|
|
||||||
await utils.createAsset(user1.accessToken, {
|
|
||||||
deviceId: 'test-assets-exist',
|
|
||||||
deviceAssetId: 'test-asset-0',
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await utils.checkExistingAssets(user1.accessToken, {
|
|
||||||
deviceId: 'test-assets-exist',
|
|
||||||
deviceAssetIds: ['test-asset-0'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.existingIds).toEqual(['test-asset-0']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LibraryResponseDto, LoginResponseDto, getAllLibraries } from '@immich/sdk';
|
import { LibraryResponseDto, LoginResponseDto, getAllLibraries } from '@immich/sdk';
|
||||||
import { cpSync, existsSync } from 'node:fs';
|
import { cpSync, existsSync, rmSync, unlinkSync } from 'node:fs';
|
||||||
import { Socket } from 'socket.io-client';
|
import { Socket } from 'socket.io-client';
|
||||||
import { userDto, uuidDto } from 'src/fixtures';
|
import { userDto, uuidDto } from 'src/fixtures';
|
||||||
import { errorDto } from 'src/responses';
|
import { errorDto } from 'src/responses';
|
||||||
@@ -110,7 +110,9 @@ describe('/libraries', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not create an external library with duplicate exclusion patterns', async () => {
|
it('should not create an external library with duplicate exclusion patterns', async () => {
|
||||||
@@ -125,7 +127,9 @@ describe('/libraries', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,7 +161,9 @@ describe('/libraries', () => {
|
|||||||
.send({ name: '' });
|
.send({ name: '' });
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['name should not be empty']));
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([{ path: ['name'], message: 'Too small: expected string to have >=1 characters' }]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should change the import paths', async () => {
|
it('should change the import paths', async () => {
|
||||||
@@ -181,7 +187,9 @@ describe('/libraries', () => {
|
|||||||
.send({ importPaths: [''] });
|
.send({ importPaths: [''] });
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['each value in importPaths should not be empty']));
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([{ path: ['importPaths'], message: 'Array items must not be empty' }]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject duplicate import paths', async () => {
|
it('should reject duplicate import paths', async () => {
|
||||||
@@ -191,7 +199,9 @@ describe('/libraries', () => {
|
|||||||
.send({ importPaths: ['/path', '/path'] });
|
.send({ importPaths: ['/path', '/path'] });
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should change the exclusion pattern', async () => {
|
it('should change the exclusion pattern', async () => {
|
||||||
@@ -215,7 +225,9 @@ describe('/libraries', () => {
|
|||||||
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
|
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject an empty exclusion pattern', async () => {
|
it('should reject an empty exclusion pattern', async () => {
|
||||||
@@ -225,7 +237,9 @@ describe('/libraries', () => {
|
|||||||
.send({ exclusionPatterns: [''] });
|
.send({ exclusionPatterns: [''] });
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['each value in exclusionPatterns should not be empty']));
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array items must not be empty' }]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -768,6 +782,553 @@ describe('/libraries', () => {
|
|||||||
|
|
||||||
utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
|
utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set an asset offline if its file is missing', async () => {
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/offline`],
|
||||||
|
});
|
||||||
|
|
||||||
|
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
|
|
||||||
|
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
|
||||||
|
expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
|
||||||
|
expect(trashedAsset.isOffline).toEqual(true);
|
||||||
|
|
||||||
|
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
expect(newAssets.items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set an asset offline if its file is not in any import path', async () => {
|
||||||
|
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/offline`],
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
|
|
||||||
|
utils.createDirectory(`${testAssetDir}/temp/another-path/`);
|
||||||
|
|
||||||
|
await utils.updateLibrary(admin.accessToken, library.id, {
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/another-path/`],
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
|
||||||
|
expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
|
||||||
|
expect(trashedAsset.isOffline).toBe(true);
|
||||||
|
|
||||||
|
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
|
expect(newAssets.items).toEqual([]);
|
||||||
|
|
||||||
|
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
utils.removeDirectory(`${testAssetDir}/temp/another-path/`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set an asset offline if its file is covered by an exclusion pattern', async () => {
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp`],
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets } = await utils.searchAssets(admin.accessToken, {
|
||||||
|
libraryId: library.id,
|
||||||
|
originalFileName: 'assetB.png',
|
||||||
|
});
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
|
|
||||||
|
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/directoryB/**'] });
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
|
||||||
|
expect(trashedAsset.isTrashed).toBe(true);
|
||||||
|
expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/directoryB/assetB.png`);
|
||||||
|
expect(trashedAsset.isOffline).toBe(true);
|
||||||
|
|
||||||
|
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
|
expect(newAssets.items).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
originalFileName: 'assetA.png',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not set an asset offline if its file exists, is in an import path, and not covered by an exclusion pattern', async () => {
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp`],
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets: assetsBefore } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
expect(assetsBefore.count).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
|
expect(assets).toEqual(assetsBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('xmp metadata', async () => {
|
||||||
|
it('should import metadata from file.xmp', async () => {
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/xmp`],
|
||||||
|
});
|
||||||
|
|
||||||
|
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
|
||||||
|
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
|
expect(newAssets.items).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
originalFileName: 'glarus.nef',
|
||||||
|
fileCreatedAt: '2000-09-27T12:35:33.000Z',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should import metadata from file.ext.xmp', async () => {
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/xmp`],
|
||||||
|
});
|
||||||
|
|
||||||
|
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
|
||||||
|
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
|
expect(newAssets.items).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
originalFileName: 'glarus.nef',
|
||||||
|
fileCreatedAt: '2000-09-27T12:35:33.000Z',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should import metadata in file.ext.xmp before file.xmp if both exist', async () => {
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/xmp`],
|
||||||
|
});
|
||||||
|
|
||||||
|
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
|
||||||
|
cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
|
||||||
|
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
|
expect(newAssets.items).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
originalFileName: 'glarus.nef',
|
||||||
|
fileCreatedAt: '2000-09-27T12:35:33.000Z',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should switch from using file.xmp to file.ext.xmp when asset refreshes', async () => {
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/xmp`],
|
||||||
|
});
|
||||||
|
|
||||||
|
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
|
||||||
|
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
|
||||||
|
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
|
||||||
|
unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`);
|
||||||
|
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
|
expect(newAssets.items).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
originalFileName: 'glarus.nef',
|
||||||
|
fileCreatedAt: '2010-09-27T12:35:33.000Z',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => {
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/xmp`],
|
||||||
|
});
|
||||||
|
|
||||||
|
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
|
||||||
|
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
|
||||||
|
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
|
expect(newAssets.items).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
originalFileName: 'glarus.nef',
|
||||||
|
fileCreatedAt: '2000-09-27T12:35:33.000Z',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => {
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/xmp`],
|
||||||
|
});
|
||||||
|
|
||||||
|
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
|
||||||
|
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
|
||||||
|
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
|
expect(newAssets.items).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
originalFileName: 'glarus.nef',
|
||||||
|
fileCreatedAt: '2000-09-27T12:35:33.000Z',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should switch from using file.ext.xmp to file.xmp when asset refreshes', async () => {
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/xmp`],
|
||||||
|
});
|
||||||
|
|
||||||
|
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
|
||||||
|
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
|
||||||
|
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
|
||||||
|
unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`);
|
||||||
|
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
|
expect(newAssets.items).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
originalFileName: 'glarus.nef',
|
||||||
|
fileCreatedAt: '2010-09-27T12:35:33.000Z',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should switch from using file.ext.xmp to file metadata', async () => {
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/xmp`],
|
||||||
|
});
|
||||||
|
|
||||||
|
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
|
||||||
|
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
|
||||||
|
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`);
|
||||||
|
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
|
expect(newAssets.items).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
originalFileName: 'glarus.nef',
|
||||||
|
fileCreatedAt: '2010-07-20T17:27:12.000Z',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should switch from using file.xmp to file metadata', async () => {
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/xmp`],
|
||||||
|
});
|
||||||
|
|
||||||
|
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
|
||||||
|
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
|
||||||
|
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`);
|
||||||
|
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
|
expect(newAssets.items).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
originalFileName: 'glarus.nef',
|
||||||
|
fileCreatedAt: '2010-07-20T17:27:12.000Z',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set an offline asset to online if its file exists, is in an import path, and not covered by an exclusion pattern', async () => {
|
||||||
|
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/offline`],
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
|
|
||||||
|
utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
|
||||||
|
expect(offlineAsset.isTrashed).toBe(true);
|
||||||
|
expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
|
||||||
|
expect(offlineAsset.isOffline).toBe(true);
|
||||||
|
|
||||||
|
{
|
||||||
|
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const backOnlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
|
||||||
|
|
||||||
|
expect(backOnlineAsset.isTrashed).toBe(false);
|
||||||
|
expect(backOnlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
|
||||||
|
expect(backOnlineAsset.isOffline).toBe(false);
|
||||||
|
|
||||||
|
{
|
||||||
|
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set a trashed offline asset to online but keep it in trash', async () => {
|
||||||
|
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/offline`],
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
|
|
||||||
|
await utils.deleteAssets(admin.accessToken, [assets.items[0].id]);
|
||||||
|
|
||||||
|
{
|
||||||
|
const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
|
||||||
|
|
||||||
|
expect(trashedAsset.isTrashed).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
|
||||||
|
expect(offlineAsset.isTrashed).toBe(true);
|
||||||
|
expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
|
||||||
|
expect(offlineAsset.isOffline).toBe(true);
|
||||||
|
|
||||||
|
{
|
||||||
|
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const backOnlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
|
||||||
|
|
||||||
|
expect(backOnlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
|
||||||
|
expect(backOnlineAsset.isOffline).toBe(false);
|
||||||
|
expect(backOnlineAsset.isTrashed).toBe(true);
|
||||||
|
|
||||||
|
{
|
||||||
|
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not set an offline asset to online if its file exists, is not covered by an exclusion pattern, but is outside of all import paths', async () => {
|
||||||
|
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/offline`],
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
|
utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
{
|
||||||
|
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
|
||||||
|
|
||||||
|
expect(offlineAsset.isTrashed).toBe(true);
|
||||||
|
expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
|
||||||
|
expect(offlineAsset.isOffline).toBe(true);
|
||||||
|
|
||||||
|
utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
utils.createDirectory(`${testAssetDir}/temp/another-path/`);
|
||||||
|
|
||||||
|
await utils.updateLibrary(admin.accessToken, library.id, {
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/another-path`],
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
|
||||||
|
|
||||||
|
expect(stillOfflineAsset.isTrashed).toBe(true);
|
||||||
|
expect(stillOfflineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
|
||||||
|
expect(stillOfflineAsset.isOffline).toBe(true);
|
||||||
|
|
||||||
|
{
|
||||||
|
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.removeDirectory(`${testAssetDir}/temp/another-path/`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not set an offline asset to online if its file exists, is in an import path, but is covered by an exclusion pattern', async () => {
|
||||||
|
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/offline`],
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
{
|
||||||
|
const { assets: assetsBefore } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||||
|
expect(assetsBefore.count).toBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`);
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
|
|
||||||
|
const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
|
||||||
|
|
||||||
|
expect(offlineAsset.isTrashed).toBe(true);
|
||||||
|
expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
|
||||||
|
expect(offlineAsset.isOffline).toBe(true);
|
||||||
|
|
||||||
|
utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`);
|
||||||
|
|
||||||
|
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
|
||||||
|
|
||||||
|
await utils.scan(admin.accessToken, library.id);
|
||||||
|
|
||||||
|
const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
|
||||||
|
|
||||||
|
expect(stillOfflineAsset.isTrashed).toBe(true);
|
||||||
|
expect(stillOfflineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
|
||||||
|
expect(stillOfflineAsset.isOffline).toBe(true);
|
||||||
|
|
||||||
|
{
|
||||||
|
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
|
||||||
|
expect(assets.count).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /libraries/:id/validate', () => {
|
describe('POST /libraries/:id/validate', () => {
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ describe('/map', () => {
|
|||||||
.get('/map/reverse-geocode?lon=123')
|
.get('/map/reverse-geocode?lon=123')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if a lat is not a number', async () => {
|
it('should throw an error if a lat is not a number', async () => {
|
||||||
@@ -117,7 +119,9 @@ describe('/map', () => {
|
|||||||
.get('/map/reverse-geocode?lat=abc&lon=123.456')
|
.get('/map/reverse-geocode?lat=abc&lon=123.456')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if a lat is out of range', async () => {
|
it('should throw an error if a lat is out of range', async () => {
|
||||||
@@ -125,7 +129,9 @@ describe('/map', () => {
|
|||||||
.get('/map/reverse-geocode?lat=91&lon=123.456')
|
.get('/map/reverse-geocode?lat=91&lon=123.456')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([{ path: ['lat'], message: 'Too big: expected number to be <=90' }]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if a lon is not provided', async () => {
|
it('should throw an error if a lon is not provided', async () => {
|
||||||
@@ -133,7 +139,9 @@ describe('/map', () => {
|
|||||||
.get('/map/reverse-geocode?lat=75')
|
.get('/map/reverse-geocode?lat=75')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['lon must be a number between -180 and 180']));
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([{ path: ['lon'], message: 'Invalid input: expected number, received NaN' }]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const reverseGeocodeTestCases = [
|
const reverseGeocodeTestCases = [
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { OAuthClient, OAuthUser } from '@immich/e2e-auth-server';
|
import { OAuthClient, OAuthUser, generateLogoutToken } from '@immich/e2e-auth-server';
|
||||||
import {
|
import {
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
SystemConfigOAuthDto,
|
SystemConfigOAuthDto,
|
||||||
getConfigDefaults,
|
getConfigDefaults,
|
||||||
getMyUser,
|
getMyUser,
|
||||||
|
getSessions,
|
||||||
startOAuth,
|
startOAuth,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
@@ -76,6 +77,7 @@ const setupOAuth = async (token: string, dto: Partial<SystemConfigOAuthDto>) =>
|
|||||||
...defaults.oauth,
|
...defaults.oauth,
|
||||||
buttonText: 'Login with Immich',
|
buttonText: 'Login with Immich',
|
||||||
issuerUrl: `${authServer.internal}/.well-known/openid-configuration`,
|
issuerUrl: `${authServer.internal}/.well-known/openid-configuration`,
|
||||||
|
allowInsecureRequests: true,
|
||||||
...dto,
|
...dto,
|
||||||
};
|
};
|
||||||
await updateConfig({ systemConfigDto: { ...defaults, oauth: merged } }, options);
|
await updateConfig({ systemConfigDto: { ...defaults, oauth: merged } }, options);
|
||||||
@@ -87,21 +89,27 @@ describe(`/oauth`, () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await utils.resetDatabase();
|
await utils.resetDatabase();
|
||||||
admin = await utils.adminSetup();
|
admin = await utils.adminSetup();
|
||||||
|
|
||||||
await setupOAuth(admin.accessToken, {
|
|
||||||
enabled: true,
|
|
||||||
clientId: OAuthClient.DEFAULT,
|
|
||||||
clientSecret: OAuthClient.DEFAULT,
|
|
||||||
buttonText: 'Login with Immich',
|
|
||||||
storageLabelClaim: 'immich_username',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /oauth/authorize', () => {
|
describe('POST /oauth/authorize', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await setupOAuth(admin.accessToken, {
|
||||||
|
enabled: true,
|
||||||
|
clientId: OAuthClient.DEFAULT,
|
||||||
|
clientSecret: OAuthClient.DEFAULT,
|
||||||
|
buttonText: 'Login with Immich',
|
||||||
|
storageLabelClaim: 'immich_username',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it(`should throw an error if a redirect uri is not provided`, async () => {
|
it(`should throw an error if a redirect uri is not provided`, async () => {
|
||||||
const { status, body } = await request(app).post('/oauth/authorize').send({});
|
const { status, body } = await request(app).post('/oauth/authorize').send({});
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['redirectUri must be a string', 'redirectUri should not be empty']));
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([
|
||||||
|
{ path: ['redirectUri'], message: 'Invalid input: expected string, received undefined' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return a redirect uri', async () => {
|
it('should return a redirect uri', async () => {
|
||||||
@@ -117,19 +125,60 @@ describe(`/oauth`, () => {
|
|||||||
expect(params.get('redirect_uri')).toBe('http://127.0.0.1:2285/auth/login');
|
expect(params.get('redirect_uri')).toBe('http://127.0.0.1:2285/auth/login');
|
||||||
expect(params.get('state')).toBeDefined();
|
expect(params.get('state')).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not include the prompt parameter when not configured', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.post('/oauth/authorize')
|
||||||
|
.send({ redirectUri: 'http://127.0.0.1:2285/auth/login' });
|
||||||
|
expect(status).toBe(201);
|
||||||
|
|
||||||
|
const params = new URL(body.url).searchParams;
|
||||||
|
expect(params.get('prompt')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include the prompt parameter when configured', async () => {
|
||||||
|
await setupOAuth(admin.accessToken, {
|
||||||
|
enabled: true,
|
||||||
|
clientId: OAuthClient.DEFAULT,
|
||||||
|
clientSecret: OAuthClient.DEFAULT,
|
||||||
|
prompt: 'select_account',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.post('/oauth/authorize')
|
||||||
|
.send({ redirectUri: 'http://127.0.0.1:2285/auth/login' });
|
||||||
|
expect(status).toBe(201);
|
||||||
|
|
||||||
|
const params = new URL(body.url).searchParams;
|
||||||
|
expect(params.get('prompt')).toBe('select_account');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /oauth/callback', () => {
|
describe('POST /oauth/callback', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await setupOAuth(admin.accessToken, {
|
||||||
|
enabled: true,
|
||||||
|
clientId: OAuthClient.DEFAULT,
|
||||||
|
clientSecret: OAuthClient.DEFAULT,
|
||||||
|
buttonText: 'Login with Immich',
|
||||||
|
storageLabelClaim: 'immich_username',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it(`should throw an error if a url is not provided`, async () => {
|
it(`should throw an error if a url is not provided`, async () => {
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send({});
|
const { status, body } = await request(app).post('/oauth/callback').send({});
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['url must be a string', 'url should not be empty']));
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([{ path: ['url'], message: 'Invalid input: expected string, received undefined' }]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`should throw an error if the url is empty`, async () => {
|
it(`should throw an error if the url is empty`, async () => {
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send({ url: '' });
|
const { status, body } = await request(app).post('/oauth/callback').send({ url: '' });
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['url should not be empty']));
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([{ path: ['url'], message: 'Too small: expected string to have >=1 characters' }]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`should throw an error if the state is not provided`, async () => {
|
it(`should throw an error if the state is not provided`, async () => {
|
||||||
@@ -158,10 +207,9 @@ describe(`/oauth`, () => {
|
|||||||
it(`should throw an error if the codeVerifier doesn't match the challenge`, async () => {
|
it(`should throw an error if the codeVerifier doesn't match the challenge`, async () => {
|
||||||
const callbackParams = await loginWithOAuth('oauth-auto-register');
|
const callbackParams = await loginWithOAuth('oauth-auto-register');
|
||||||
const { codeVerifier } = await loginWithOAuth('oauth-auto-register');
|
const { codeVerifier } = await loginWithOAuth('oauth-auto-register');
|
||||||
const { status, body } = await request(app)
|
const { status } = await request(app)
|
||||||
.post('/oauth/callback')
|
.post('/oauth/callback')
|
||||||
.send({ ...callbackParams, codeVerifier });
|
.send({ ...callbackParams, codeVerifier });
|
||||||
console.log(body);
|
|
||||||
expect(status).toBeGreaterThanOrEqual(400);
|
expect(status).toBeGreaterThanOrEqual(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -258,7 +306,7 @@ describe(`/oauth`, () => {
|
|||||||
accessToken: expect.any(String),
|
accessToken: expect.any(String),
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
name: 'OAuth User',
|
name: 'OAuth User',
|
||||||
userEmail: 'oauth-RS256-token@immich.app',
|
userEmail: 'oauth-rs256-token@immich.app',
|
||||||
userId: expect.any(String),
|
userId: expect.any(String),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -292,9 +340,7 @@ describe(`/oauth`, () => {
|
|||||||
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
||||||
expect(status).toBe(500);
|
expect(status).toBe(500);
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'Failed to finish oauth',
|
message: 'Failed to finish oauth',
|
||||||
statusCode: 500,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,7 +359,7 @@ describe(`/oauth`, () => {
|
|||||||
const callbackParams = await loginWithOAuth('oauth-no-auto-register');
|
const callbackParams = await loginWithOAuth('oauth-no-auto-register');
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest('User does not exist and auto registering is disabled.'));
|
expect(body).toEqual(errorDto.badRequest('OAuth authentication failed'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should link to an existing user by email', async () => {
|
it('should link to an existing user by email', async () => {
|
||||||
@@ -333,6 +379,54 @@ describe(`/oauth`, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe(`POST /oauth/backchannel-logout`, () => {
|
||||||
|
it(`should throw an error if the logout_token is not provided`, async () => {
|
||||||
|
const { status, body } = await request(app).post('/oauth/backchannel-logout').send({});
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([
|
||||||
|
{ path: ['logout_token'], message: 'Invalid input: expected string, received undefined' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should throw an error if an invalid logout token is provided`, async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.post('/oauth/backchannel-logout')
|
||||||
|
.send({ logout_token: 'invalid token' });
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest('Error backchannel logout: token validation failed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should logout user if a valid logout token is provided`, async () => {
|
||||||
|
await setupOAuth(admin.accessToken, {
|
||||||
|
enabled: true,
|
||||||
|
clientId: OAuthClient.DEFAULT,
|
||||||
|
clientSecret: OAuthClient.DEFAULT,
|
||||||
|
autoRegister: true,
|
||||||
|
signingAlgorithm: 'RS256',
|
||||||
|
buttonText: 'Login with Immich',
|
||||||
|
});
|
||||||
|
|
||||||
|
const callbackParams = await loginWithOAuth('backchannel-logout-user');
|
||||||
|
const { status: callbackStatus, body: callbackBody } = await request(app)
|
||||||
|
.post('/oauth/callback')
|
||||||
|
.send(callbackParams);
|
||||||
|
expect(callbackStatus).toBe(201);
|
||||||
|
|
||||||
|
await expect(getSessions({ headers: asBearerAuth(callbackBody.accessToken) })).resolves.toHaveLength(1);
|
||||||
|
|
||||||
|
const logoutToken = await generateLogoutToken('http://0.0.0.0:2286', 'backchannel-logout-user');
|
||||||
|
const { status, body } = await request(app).post('/oauth/backchannel-logout').send({ logout_token: logoutToken });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toMatchObject({});
|
||||||
|
|
||||||
|
await expect(getSessions({ headers: asBearerAuth(callbackBody.accessToken) })).rejects.toMatchObject({
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('mobile redirect override', () => {
|
describe('mobile redirect override', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await setupOAuth(admin.accessToken, {
|
await setupOAuth(admin.accessToken, {
|
||||||
@@ -399,4 +493,22 @@ describe(`/oauth`, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('allowInsecureRequests: false', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await setupOAuth(admin.accessToken, {
|
||||||
|
enabled: true,
|
||||||
|
clientId: OAuthClient.DEFAULT,
|
||||||
|
clientSecret: OAuthClient.DEFAULT,
|
||||||
|
allowInsecureRequests: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject OAuth discovery over HTTP', async () => {
|
||||||
|
const { status } = await request(app)
|
||||||
|
.post('/oauth/authorize')
|
||||||
|
.send({ redirectUri: 'http://127.0.0.1:2285/auth/login' });
|
||||||
|
expect(status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ describe('/search', () => {
|
|||||||
const bytes = await readFile(join(testAssetDir, filename));
|
const bytes = await readFile(join(testAssetDir, filename));
|
||||||
assets.push(
|
assets.push(
|
||||||
await utils.createAsset(admin.accessToken, {
|
await utils.createAsset(admin.accessToken, {
|
||||||
deviceAssetId: `test-${filename}`,
|
|
||||||
assetData: { bytes, filename },
|
assetData: { bytes, filename },
|
||||||
...dto,
|
...dto,
|
||||||
}),
|
}),
|
||||||
@@ -458,7 +457,7 @@ describe('/search', () => {
|
|||||||
expect(Array.isArray(body)).toBe(true);
|
expect(Array.isArray(body)).toBe(true);
|
||||||
if (Array.isArray(body)) {
|
if (Array.isArray(body)) {
|
||||||
expect(body.length).toBeGreaterThan(10);
|
expect(body.length).toBeGreaterThan(10);
|
||||||
expect(body[0].name).toEqual(name);
|
expect(body[0].name).toEqual(expect.stringContaining(name));
|
||||||
expect(body[0].admin2name).toEqual(name);
|
expect(body[0].admin2name).toEqual(name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -207,16 +207,6 @@ describe('/server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /server/theme', () => {
|
|
||||||
it('should respond with the server theme', async () => {
|
|
||||||
const { status, body } = await request(app).get('/server/theme');
|
|
||||||
expect(status).toBe(200);
|
|
||||||
expect(body).toEqual({
|
|
||||||
customCss: '',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /server/license', () => {
|
describe('GET /server/license', () => {
|
||||||
it('should require authentication', async () => {
|
it('should require authentication', async () => {
|
||||||
const { status, body } = await request(app).get('/server/license');
|
const { status, body } = await request(app).get('/server/license');
|
||||||
|
|||||||
@@ -243,9 +243,21 @@ describe('/shared-links', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should get data for correct password protected link', async () => {
|
it('should get data for correct password protected link', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/shared-links/login')
|
||||||
|
.send({ password: 'foo' })
|
||||||
|
.query({ key: linkWithPassword.key });
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
|
||||||
|
const cookies = response.get('Set-Cookie') ?? [];
|
||||||
|
expect(cookies).toHaveLength(1);
|
||||||
|
expect(cookies[0]).toContain('immich_shared_link_token');
|
||||||
|
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get('/shared-links/me')
|
.get('/shared-links/me')
|
||||||
.query({ key: linkWithPassword.key, password: 'foo' });
|
.query({ key: linkWithPassword.key })
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
@@ -329,7 +341,9 @@ describe('/shared-links', () => {
|
|||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest());
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require an asset/album id', async () => {
|
it('should require an asset/album id', async () => {
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ describe('/stacks', () => {
|
|||||||
.send({ assetIds: [asset.id] });
|
.send({ assetIds: [asset.id] });
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest());
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([{ path: ['assetIds'], message: 'Too small: expected array to have >=2 items' }]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require a valid id', async () => {
|
it('should require a valid id', async () => {
|
||||||
@@ -51,7 +53,12 @@ describe('/stacks', () => {
|
|||||||
.send({ assetIds: [uuidDto.invalid, uuidDto.invalid] });
|
.send({ assetIds: [uuidDto.invalid, uuidDto.invalid] });
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest());
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([
|
||||||
|
{ path: ['assetIds', 0], message: 'Invalid UUID' },
|
||||||
|
{ path: ['assetIds', 1], message: 'Invalid UUID' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require access', async () => {
|
it('should require access', async () => {
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ describe('/tags', () => {
|
|||||||
.get(`/tags/${uuidDto.invalid}`)
|
.get(`/tags/${uuidDto.invalid}`)
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
|
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get tag details', async () => {
|
it('should get tag details', async () => {
|
||||||
@@ -427,7 +427,7 @@ describe('/tags', () => {
|
|||||||
.delete(`/tags/${uuidDto.invalid}`)
|
.delete(`/tags/${uuidDto.invalid}`)
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
|
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should delete a tag', async () => {
|
it('should delete a tag', async () => {
|
||||||
|
|||||||
@@ -108,14 +108,20 @@ describe('/admin/users', () => {
|
|||||||
expect(body).toEqual(errorDto.forbidden);
|
expect(body).toEqual(errorDto.forbidden);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const key of ['password', 'email', 'name', 'quotaSizeInBytes', 'shouldChangePassword', 'notify']) {
|
for (const [key, message] of [
|
||||||
|
['password', 'Invalid input: expected string, received null'],
|
||||||
|
['email', 'Invalid input: expected email, received object'],
|
||||||
|
['name', 'Invalid input: expected string, received null'],
|
||||||
|
['shouldChangePassword', 'Invalid input: expected boolean, received null'],
|
||||||
|
['notify', 'Invalid input: expected boolean, received null'],
|
||||||
|
] as const) {
|
||||||
it(`should not allow null ${key}`, async () => {
|
it(`should not allow null ${key}`, async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.post(`/admin/users`)
|
.post(`/admin/users`)
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
.send({ ...createUserDto.user1, [key]: null });
|
.send({ ...createUserDto.user1, [key]: null });
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest());
|
expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,14 +159,19 @@ describe('/admin/users', () => {
|
|||||||
expect(body).toEqual(errorDto.forbidden);
|
expect(body).toEqual(errorDto.forbidden);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const key of ['password', 'email', 'name', 'shouldChangePassword']) {
|
for (const [key, message] of [
|
||||||
|
['password', 'Invalid input: expected string, received null'],
|
||||||
|
['email', 'Invalid input: expected email, received object'],
|
||||||
|
['name', 'Invalid input: expected string, received null'],
|
||||||
|
['shouldChangePassword', 'Invalid input: expected boolean, received null'],
|
||||||
|
] as const) {
|
||||||
it(`should not allow null ${key}`, async () => {
|
it(`should not allow null ${key}`, async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.put(`/admin/users/${uuidDto.notFound}`)
|
.put(`/admin/users/${uuidDto.notFound}`)
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
.send({ [key]: null });
|
.send({ [key]: null });
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest());
|
expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +298,8 @@ describe('/admin/users', () => {
|
|||||||
it('should delete user', async () => {
|
it('should delete user', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.delete(`/admin/users/${userToDelete.userId}`)
|
.delete(`/admin/users/${userToDelete.userId}`)
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ describe('/users', () => {
|
|||||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
|
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toMatchObject(errorDto.badRequest('Email already in use by another account'));
|
expect(body).toMatchObject(errorDto.badRequest('Email is not available'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update my email', async () => {
|
it('should update my email', async () => {
|
||||||
@@ -178,7 +178,11 @@ describe('/users', () => {
|
|||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['download.archiveSize must be an integer number']));
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([
|
||||||
|
{ path: ['download', 'archiveSize'], message: 'Invalid input: expected int, received number' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update download archive size', async () => {
|
it('should update download archive size', async () => {
|
||||||
@@ -204,7 +208,11 @@ describe('/users', () => {
|
|||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['download.includeEmbeddedVideos must be a boolean value']));
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([
|
||||||
|
{ path: ['download', 'includeEmbeddedVideos'], message: 'Invalid input: expected boolean, received number' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update download include embedded videos', async () => {
|
it('should update download include embedded videos', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { LoginResponseDto } from '@immich/sdk';
|
import { LoginResponseDto } from '@immich/sdk';
|
||||||
import { test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { utils } from 'src/utils';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { testAssetDir, utils } from 'src/utils';
|
||||||
|
|
||||||
test.describe('Album', () => {
|
test.describe('Album', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
@@ -22,4 +23,41 @@ test.describe('Album', () => {
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
await page.getByRole('button', { name: 'Select photos' }).waitFor();
|
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,7 +1,9 @@
|
|||||||
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
|
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { basename, join } from 'node:path';
|
||||||
import type { Socket } from 'socket.io-client';
|
import type { Socket } from 'socket.io-client';
|
||||||
import { utils } from 'src/utils';
|
import { testAssetDir, utils } from 'src/utils';
|
||||||
|
|
||||||
test.describe('Detail Panel', () => {
|
test.describe('Detail Panel', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
@@ -83,4 +85,42 @@ test.describe('Detail Panel', () => {
|
|||||||
await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
|
await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
|
||||||
await expect(textarea).toHaveValue('new description');
|
await expect(textarea).toHaveValue('new description');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Date editor', () => {
|
||||||
|
test('displays inferred asset timezone', async ({ context, page }) => {
|
||||||
|
const test = {
|
||||||
|
filepath: 'metadata/dates/datetimeoriginal-gps.jpg',
|
||||||
|
expected: {
|
||||||
|
dateTime: '2025-12-01T11:30',
|
||||||
|
// Test with a timezone which is NOT the first among timezones with the same offset
|
||||||
|
// This is to check that the editor does not simply fall back to the first available timezone with that offset
|
||||||
|
// America/Denver (-07:00) is not the first among timezones with offset -07:00
|
||||||
|
timeZoneWithOffset: 'America/Denver (-07:00)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const asset = await utils.createAsset(admin.accessToken, {
|
||||||
|
assetData: {
|
||||||
|
bytes: await readFile(join(testAssetDir, test.filepath)),
|
||||||
|
filename: basename(test.filepath),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
||||||
|
|
||||||
|
// asset viewer -> detail panel -> date editor
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await page.waitForSelector('#immich-asset-viewer');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Info' }).click();
|
||||||
|
await page.getByTestId('detail-panel-edit-date-button').click();
|
||||||
|
await page.waitForSelector('[role="dialog"]');
|
||||||
|
|
||||||
|
const datetime = page.locator('#datetime');
|
||||||
|
await expect(datetime).toHaveValue(test.expected.dateTime);
|
||||||
|
const timezone = page.getByRole('combobox', { name: 'Timezone' });
|
||||||
|
await expect(timezone).toHaveValue(test.expected.timeZoneWithOffset);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ test.describe('Duplicates Utility', () => {
|
|||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
test.beforeEach(async ({ context }) => {
|
||||||
[firstAsset, secondAsset] = await Promise.all([
|
[firstAsset, secondAsset] = await Promise.all([
|
||||||
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-a' }),
|
utils.createAsset(admin.accessToken, {}),
|
||||||
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-b' }),
|
utils.createAsset(admin.accessToken, {}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await updateAssets(
|
await updateAssets(
|
||||||
|
|||||||
@@ -77,18 +77,4 @@ test.describe('Photo Viewer', () => {
|
|||||||
});
|
});
|
||||||
expect(tagAtCenter).toBe('IMG');
|
expect(tagAtCenter).toBe('IMG');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reloads photo when checksum changes', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${asset.id}`);
|
|
||||||
|
|
||||||
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 websocketEvent;
|
|
||||||
|
|
||||||
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,8 +32,12 @@ export function generateThumbhash(rng: SeededRandom): string {
|
|||||||
return Array.from({ length: 10 }, () => rng.nextInt(0, 256).toString(16).padStart(2, '0')).join('');
|
return Array.from({ length: 10 }, () => rng.nextInt(0, 256).toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateDuration(rng: SeededRandom): string {
|
export function generateDuration(rng: SeededRandom): number {
|
||||||
return `${rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS)}.${rng.nextInt(0, 1000).toString().padStart(3, '0')}`;
|
return (
|
||||||
|
rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS) *
|
||||||
|
1000 +
|
||||||
|
rng.nextInt(0, 1000)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateUUID(): string {
|
export function generateUUID(): string {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AlbumUserRole,
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
AssetVisibility,
|
AssetVisibility,
|
||||||
UserAvatarColor,
|
UserAvatarColor,
|
||||||
@@ -315,11 +316,9 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
deviceAssetId: `device-${asset.id}`,
|
|
||||||
ownerId: asset.ownerId,
|
ownerId: asset.ownerId,
|
||||||
owner: owner || defaultOwner,
|
owner: owner || defaultOwner,
|
||||||
libraryId: `library-${asset.ownerId}`,
|
libraryId: `library-${asset.ownerId}`,
|
||||||
deviceId: `device-${asset.ownerId}`,
|
|
||||||
type: asset.isVideo ? AssetTypeEnum.Video : AssetTypeEnum.Image,
|
type: asset.isVideo ? AssetTypeEnum.Video : AssetTypeEnum.Image,
|
||||||
originalPath: `/original/${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
|
originalPath: `/original/${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
|
||||||
originalFileName: `${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
|
originalFileName: `${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
|
||||||
@@ -334,7 +333,7 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
|
|||||||
isArchived: false,
|
isArchived: false,
|
||||||
isTrashed: asset.isTrashed,
|
isTrashed: asset.isTrashed,
|
||||||
visibility: asset.visibility,
|
visibility: asset.visibility,
|
||||||
duration: asset.duration || '0:00:00.00000',
|
duration: asset.duration,
|
||||||
exifInfo,
|
exifInfo,
|
||||||
livePhotoVideoId: asset.livePhotoVideoId,
|
livePhotoVideoId: asset.livePhotoVideoId,
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -422,14 +421,11 @@ export function getAlbum(
|
|||||||
albumThumbnailAssetId: album.thumbnailAssetId,
|
albumThumbnailAssetId: album.thumbnailAssetId,
|
||||||
createdAt: album.createdAt,
|
createdAt: album.createdAt,
|
||||||
updatedAt: album.updatedAt,
|
updatedAt: album.updatedAt,
|
||||||
ownerId: albumOwner.id,
|
albumUsers: [{ user: albumOwner, role: AlbumUserRole.Owner }],
|
||||||
owner: albumOwner,
|
|
||||||
albumUsers: [], // Empty array for non-shared album
|
|
||||||
shared: false,
|
shared: false,
|
||||||
hasSharedLink: false,
|
hasSharedLink: false,
|
||||||
isActivityEnabled: true,
|
isActivityEnabled: true,
|
||||||
assetCount: albumAssets.length,
|
assetCount: albumAssets.length,
|
||||||
assets: albumAssets,
|
|
||||||
startDate: albumAssets.length > 0 ? albumAssets.at(-1)?.fileCreatedAt : undefined,
|
startDate: albumAssets.length > 0 ? albumAssets.at(-1)?.fileCreatedAt : undefined,
|
||||||
endDate: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
|
endDate: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
|
||||||
lastModifiedAssetTimestamp: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
|
lastModifiedAssetTimestamp: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export type MockTimelineAsset = {
|
|||||||
isTrashed: boolean;
|
isTrashed: boolean;
|
||||||
isVideo: boolean;
|
isVideo: boolean;
|
||||||
isImage: boolean;
|
isImage: boolean;
|
||||||
duration: string | null;
|
duration: number | null;
|
||||||
projectionType: string | null;
|
projectionType: string | null;
|
||||||
livePhotoVideoId: string | null;
|
livePhotoVideoId: string | null;
|
||||||
city: string | null;
|
city: string | null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BrowserContext } from '@playwright/test';
|
import { BrowserContext } from '@playwright/test';
|
||||||
import { playwrightHost } from 'playwright.config';
|
import { playwrightHost } from 'src/../playwright.config';
|
||||||
|
|
||||||
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
|
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
|
||||||
await context.addCookies([
|
await context.addCookies([
|
||||||
@@ -173,6 +173,7 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
|
|||||||
'.mpeg',
|
'.mpeg',
|
||||||
'.mpg',
|
'.mpg',
|
||||||
'.mts',
|
'.mts',
|
||||||
|
'.ts',
|
||||||
'.vob',
|
'.vob',
|
||||||
'.webm',
|
'.webm',
|
||||||
'.wmv',
|
'.wmv',
|
||||||
@@ -222,6 +223,7 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
|
|||||||
'.jp2',
|
'.jp2',
|
||||||
'.jpe',
|
'.jpe',
|
||||||
'.jxl',
|
'.jxl',
|
||||||
|
'.mpo',
|
||||||
'.svg',
|
'.svg',
|
||||||
'.tif',
|
'.tif',
|
||||||
'.tiff',
|
'.tiff',
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
return {
|
return {
|
||||||
id: assetId,
|
id: assetId,
|
||||||
deviceAssetId: `device-${assetId}`,
|
|
||||||
ownerId,
|
ownerId,
|
||||||
owner: {
|
owner: {
|
||||||
id: ownerId,
|
id: ownerId,
|
||||||
@@ -27,7 +26,6 @@ export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
|
|||||||
avatarColor: 'blue' as never,
|
avatarColor: 'blue' as never,
|
||||||
},
|
},
|
||||||
libraryId: `library-${ownerId}`,
|
libraryId: `library-${ownerId}`,
|
||||||
deviceId: `device-${ownerId}`,
|
|
||||||
type: AssetTypeEnum.Image,
|
type: AssetTypeEnum.Image,
|
||||||
originalPath: `/original/${assetId}.jpg`,
|
originalPath: `/original/${assetId}.jpg`,
|
||||||
originalFileName: `${assetId}.jpg`,
|
originalFileName: `${assetId}.jpg`,
|
||||||
@@ -42,7 +40,7 @@ export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
|
|||||||
isArchived: false,
|
isArchived: false,
|
||||||
isTrashed: false,
|
isTrashed: false,
|
||||||
visibility: AssetVisibility.Timeline,
|
visibility: AssetVisibility.Timeline,
|
||||||
duration: '0:00:00.00000',
|
duration: null,
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
make: null,
|
make: null,
|
||||||
model: null,
|
model: null,
|
||||||
@@ -69,7 +67,7 @@ export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
|
|||||||
tags: [],
|
tags: [],
|
||||||
people: [],
|
people: [],
|
||||||
unassignedFaces: [],
|
unassignedFaces: [],
|
||||||
stack: null,
|
stack: undefined,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
hasMetadata: true,
|
hasMetadata: true,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { AssetOcrResponseDto } from '@immich/sdk';
|
||||||
|
import { BrowserContext } from '@playwright/test';
|
||||||
|
|
||||||
|
export type MockOcrBox = {
|
||||||
|
text: string;
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
x3: number;
|
||||||
|
y3: number;
|
||||||
|
x4: number;
|
||||||
|
y4: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMockOcrData = (assetId: string, boxes: MockOcrBox[]): AssetOcrResponseDto[] => {
|
||||||
|
return boxes.map((box) => ({
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
assetId,
|
||||||
|
x1: box.x1,
|
||||||
|
y1: box.y1,
|
||||||
|
x2: box.x2,
|
||||||
|
y2: box.y2,
|
||||||
|
x3: box.x3,
|
||||||
|
y3: box.y3,
|
||||||
|
x4: box.x4,
|
||||||
|
y4: box.y4,
|
||||||
|
boxScore: 0.95,
|
||||||
|
textScore: 0.9,
|
||||||
|
text: box.text,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setupOcrMockApiRoutes = async (
|
||||||
|
context: BrowserContext,
|
||||||
|
ocrDataByAssetId: Map<string, AssetOcrResponseDto[]>,
|
||||||
|
) => {
|
||||||
|
await context.route('**/assets/*/ocr', async (route, request) => {
|
||||||
|
if (request.method() !== 'GET') {
|
||||||
|
return route.fallback();
|
||||||
|
}
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const segments = url.pathname.split('/');
|
||||||
|
const assetIdIndex = segments.indexOf('assets') + 1;
|
||||||
|
const assetId = segments[assetIdIndex];
|
||||||
|
|
||||||
|
const ocrData = ocrDataByAssetId.get(assetId) ?? [];
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: ocrData,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import type { AssetOcrResponseDto, 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 { createMockOcrData, setupOcrMockApiRoutes } from 'src/ui/mock-network/ocr-network';
|
||||||
|
import { assetViewerUtils } from '../timeline/utils';
|
||||||
|
import { setupAssetViewerFixture } from './utils';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
const PRIMARY_OCR_BOXES = [
|
||||||
|
{ text: 'Hello World', x1: 0.1, y1: 0.1, x2: 0.4, y2: 0.1, x3: 0.4, y3: 0.15, x4: 0.1, y4: 0.15 },
|
||||||
|
{ text: 'Immich Photo', x1: 0.2, y1: 0.3, x2: 0.6, y2: 0.3, x3: 0.6, y3: 0.36, x4: 0.2, y4: 0.36 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SECONDARY_OCR_BOXES = [
|
||||||
|
{ text: 'Second Asset Text', x1: 0.15, y1: 0.2, x2: 0.55, y2: 0.2, x3: 0.55, y3: 0.26, x4: 0.15, y4: 0.26 },
|
||||||
|
];
|
||||||
|
|
||||||
|
test.describe('OCR bounding boxes', () => {
|
||||||
|
const fixture = setupAssetViewerFixture(920);
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||||
|
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
||||||
|
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OCR bounding boxes appear when clicking OCR button', async ({ page }) => {
|
||||||
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||||
|
|
||||||
|
const ocrButton = page.getByLabel('Text recognition');
|
||||||
|
await expect(ocrButton).toBeVisible();
|
||||||
|
await ocrButton.click();
|
||||||
|
|
||||||
|
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
|
||||||
|
await expect(ocrBoxes).toHaveCount(2);
|
||||||
|
|
||||||
|
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
|
||||||
|
await expect(ocrBoxes.nth(1)).toContainText('Immich Photo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OCR bounding boxes toggle off on second click', async ({ page }) => {
|
||||||
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||||
|
|
||||||
|
const ocrButton = page.getByLabel('Text recognition');
|
||||||
|
await ocrButton.click();
|
||||||
|
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]').first()).toBeVisible();
|
||||||
|
|
||||||
|
await ocrButton.click();
|
||||||
|
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('OCR with stacked assets', () => {
|
||||||
|
const fixture = setupAssetViewerFixture(921);
|
||||||
|
let mockStack: MockStack;
|
||||||
|
let primaryAssetDto: AssetResponseDto;
|
||||||
|
let secondAssetDto: AssetResponseDto;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||||
|
secondAssetDto = createMockStackAsset(fixture.adminUserId);
|
||||||
|
secondAssetDto.originalFileName = 'second-ocr-asset.jpg';
|
||||||
|
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
||||||
|
|
||||||
|
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
||||||
|
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
||||||
|
[secondAssetDto.id, createMockOcrData(secondAssetDto.id, SECONDARY_OCR_BOXES)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('different OCR boxes shown for different stacked assets', async ({ page }) => {
|
||||||
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||||
|
|
||||||
|
const ocrButton = page.getByLabel('Text recognition');
|
||||||
|
await expect(ocrButton).toBeVisible();
|
||||||
|
await ocrButton.click();
|
||||||
|
|
||||||
|
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
|
||||||
|
await expect(ocrBoxes).toHaveCount(2);
|
||||||
|
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
|
||||||
|
|
||||||
|
const stackThumbnails = page.locator('#stack-slideshow [data-asset]');
|
||||||
|
await expect(stackThumbnails).toHaveCount(2);
|
||||||
|
await stackThumbnails.nth(1).click();
|
||||||
|
|
||||||
|
// refreshOcr() clears showOverlay when switching assets, so re-enable it
|
||||||
|
await expect(ocrBoxes).toHaveCount(0);
|
||||||
|
await expect(ocrButton).toBeVisible();
|
||||||
|
await ocrButton.click();
|
||||||
|
|
||||||
|
await expect(ocrBoxes).toHaveCount(1);
|
||||||
|
await expect(ocrBoxes.first()).toContainText('Second Asset Text');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('OCR boxes and zoom', () => {
|
||||||
|
const fixture = setupAssetViewerFixture(922);
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||||
|
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
||||||
|
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OCR boxes scale with zoom', async ({ page }) => {
|
||||||
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||||
|
|
||||||
|
const ocrButton = page.getByLabel('Text recognition');
|
||||||
|
await expect(ocrButton).toBeVisible();
|
||||||
|
await ocrButton.click();
|
||||||
|
|
||||||
|
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
||||||
|
await expect(ocrBox).toBeVisible();
|
||||||
|
|
||||||
|
const initialBox = await ocrBox.boundingBox();
|
||||||
|
expect(initialBox).toBeTruthy();
|
||||||
|
|
||||||
|
const { width, height } = page.viewportSize()!;
|
||||||
|
await page.mouse.move(width / 2, height / 2);
|
||||||
|
await page.mouse.wheel(0, -3);
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
const zoomedBox = await ocrBox.boundingBox();
|
||||||
|
expect(zoomedBox).toBeTruthy();
|
||||||
|
expect(zoomedBox!.width).toBeGreaterThan(initialBox!.width);
|
||||||
|
expect(zoomedBox!.height).toBeGreaterThan(initialBox!.height);
|
||||||
|
}).toPass({ timeout: 2000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('OCR text interaction', () => {
|
||||||
|
const fixture = setupAssetViewerFixture(923);
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||||
|
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
||||||
|
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OCR text box has data-overlay-interactive attribute', async ({ page }) => {
|
||||||
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||||
|
|
||||||
|
await page.getByLabel('Text recognition').click();
|
||||||
|
|
||||||
|
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
||||||
|
await expect(ocrBox).toBeVisible();
|
||||||
|
await expect(ocrBox).toHaveAttribute('data-overlay-interactive');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OCR text box receives focus on click', async ({ page }) => {
|
||||||
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||||
|
|
||||||
|
await page.getByLabel('Text recognition').click();
|
||||||
|
|
||||||
|
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
||||||
|
await expect(ocrBox).toBeVisible();
|
||||||
|
|
||||||
|
await ocrBox.click();
|
||||||
|
await expect(ocrBox).toBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dragging on OCR text box does not trigger image pan', async ({ page }) => {
|
||||||
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||||
|
|
||||||
|
await page.getByLabel('Text recognition').click();
|
||||||
|
|
||||||
|
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
||||||
|
await expect(ocrBox).toBeVisible();
|
||||||
|
|
||||||
|
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
|
||||||
|
const initialTransform = await imgLocator.evaluate((element) => {
|
||||||
|
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||||
|
});
|
||||||
|
|
||||||
|
const box = await ocrBox.boundingBox();
|
||||||
|
expect(box).toBeTruthy();
|
||||||
|
const centerX = box!.x + box!.width / 2;
|
||||||
|
const centerY = box!.y + box!.height / 2;
|
||||||
|
|
||||||
|
await page.mouse.move(centerX, centerY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(centerX + 50, centerY + 30, { steps: 5 });
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
const afterTransform = await imgLocator.evaluate((element) => {
|
||||||
|
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||||
|
});
|
||||||
|
expect(afterTransform).toBe(initialTransform);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('split touch gesture across zoom container does not trigger zoom', async ({ page }) => {
|
||||||
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||||
|
|
||||||
|
await page.getByLabel('Text recognition').click();
|
||||||
|
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
||||||
|
await expect(ocrBox).toBeVisible();
|
||||||
|
|
||||||
|
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
|
||||||
|
const initialTransform = await imgLocator.evaluate((element) => {
|
||||||
|
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewerContent = page.locator('[data-viewer-content]');
|
||||||
|
const viewerBox = await viewerContent.boundingBox();
|
||||||
|
expect(viewerBox).toBeTruthy();
|
||||||
|
|
||||||
|
// Dispatch a synthetic split gesture: one touch inside the viewer, one outside
|
||||||
|
await page.evaluate(
|
||||||
|
({ viewerCenterX, viewerCenterY, outsideY }) => {
|
||||||
|
const viewer = document.querySelector('[data-viewer-content]');
|
||||||
|
if (!viewer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTouch = (id: number, x: number, y: number) => {
|
||||||
|
return new Touch({
|
||||||
|
identifier: id,
|
||||||
|
target: viewer,
|
||||||
|
clientX: x,
|
||||||
|
clientY: y,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const insideTouch = createTouch(0, viewerCenterX, viewerCenterY);
|
||||||
|
const outsideTouch = createTouch(1, viewerCenterX, outsideY);
|
||||||
|
|
||||||
|
const touchStartEvent = new TouchEvent('touchstart', {
|
||||||
|
touches: [insideTouch, outsideTouch],
|
||||||
|
targetTouches: [insideTouch],
|
||||||
|
changedTouches: [insideTouch, outsideTouch],
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const touchMoveEvent = new TouchEvent('touchmove', {
|
||||||
|
touches: [createTouch(0, viewerCenterX, viewerCenterY - 30), createTouch(1, viewerCenterX, outsideY + 30)],
|
||||||
|
targetTouches: [createTouch(0, viewerCenterX, viewerCenterY - 30)],
|
||||||
|
changedTouches: [
|
||||||
|
createTouch(0, viewerCenterX, viewerCenterY - 30),
|
||||||
|
createTouch(1, viewerCenterX, outsideY + 30),
|
||||||
|
],
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const touchEndEvent = new TouchEvent('touchend', {
|
||||||
|
touches: [],
|
||||||
|
targetTouches: [],
|
||||||
|
changedTouches: [insideTouch, outsideTouch],
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
viewer.dispatchEvent(touchStartEvent);
|
||||||
|
viewer.dispatchEvent(touchMoveEvent);
|
||||||
|
viewer.dispatchEvent(touchEndEvent);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
viewerCenterX: viewerBox!.x + viewerBox!.width / 2,
|
||||||
|
viewerCenterY: viewerBox!.y + viewerBox!.height / 2,
|
||||||
|
outsideY: 10, // near the top of the page, outside the viewer
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const afterTransform = await imgLocator.evaluate((element) => {
|
||||||
|
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||||
|
});
|
||||||
|
expect(afterTransform).toBe(initialTransform);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
generateTimelineData,
|
generateTimelineData,
|
||||||
TimelineAssetConfig,
|
TimelineAssetConfig,
|
||||||
TimelineData,
|
TimelineData,
|
||||||
|
toAssetResponseDto,
|
||||||
} from 'src/ui/generators/timeline';
|
} from 'src/ui/generators/timeline';
|
||||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||||
@@ -30,6 +31,10 @@ test.describe('search gallery-viewer', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
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',
|
||||||
|
);
|
||||||
adminUserId = faker.string.uuid();
|
adminUserId = faker.string.uuid();
|
||||||
testContext.adminId = adminUserId;
|
testContext.adminId = adminUserId;
|
||||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||||
@@ -44,7 +49,10 @@ test.describe('search gallery-viewer', () => {
|
|||||||
|
|
||||||
await context.route('**/api/search/metadata', async (route, request) => {
|
await context.route('**/api/search/metadata', async (route, request) => {
|
||||||
if (request.method() === 'POST') {
|
if (request.method() === 'POST') {
|
||||||
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
|
const searchAssets = assets
|
||||||
|
.slice(0, 5)
|
||||||
|
.filter((asset) => !changes.assetDeletions.includes(asset.id))
|
||||||
|
.map((asset) => toAssetResponseDto(asset));
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ test.describe('Timeline', () => {
|
|||||||
await page.keyboard.down('Shift');
|
await page.keyboard.down('Shift');
|
||||||
await thumbnailUtils.withAssetId(page, assets[2].id).hover();
|
await thumbnailUtils.withAssetId(page, assets[2].id).hover();
|
||||||
await expect(
|
await expect(
|
||||||
thumbnailUtils.locator(page).locator('.absolute.top-0.h-full.w-full.bg-immich-primary.opacity-40'),
|
thumbnailUtils.locator(page).locator('.absolute.top-0.size-full.bg-immich-primary.opacity-40'),
|
||||||
).toHaveCount(3);
|
).toHaveCount(3);
|
||||||
await thumbnailUtils.selectButton(page, assets[2].id).click();
|
await thumbnailUtils.selectButton(page, assets[2].id).click();
|
||||||
await page.keyboard.up('Shift');
|
await page.keyboard.up('Shift');
|
||||||
@@ -349,7 +349,7 @@ test.describe('Timeline', () => {
|
|||||||
expect(visibleMockAssetsYearMonths).toContain(month);
|
expect(visibleMockAssetsYearMonths).toContain(month);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
test('Deep link to last photo, scroll up', async ({ page }) => {
|
test.skip('Deep link to last photo, scroll up', async ({ page }) => {
|
||||||
const lastAsset = assets.at(-1)!;
|
const lastAsset = assets.at(-1)!;
|
||||||
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||||
|
|
||||||
@@ -361,7 +361,7 @@ test.describe('Timeline', () => {
|
|||||||
|
|
||||||
await thumbnailUtils.expectInViewport(page, '14e5901f-fd7f-40c0-b186-4d7e7fc67968');
|
await thumbnailUtils.expectInViewport(page, '14e5901f-fd7f-40c0-b186-4d7e7fc67968');
|
||||||
});
|
});
|
||||||
test('Deep link to first bucket, scroll down', async ({ page }) => {
|
test.skip('Deep link to first bucket, scroll down', async ({ page }) => {
|
||||||
const lastAsset = assets.at(0)!;
|
const lastAsset = assets.at(0)!;
|
||||||
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||||
await timelineUtils.locator(page).hover();
|
await timelineUtils.locator(page).hover();
|
||||||
@@ -440,7 +440,7 @@ test.describe('Timeline', () => {
|
|||||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||||
await thumbnailUtils.expectSelectedDisabled(page, asset.id);
|
await thumbnailUtils.expectSelectedDisabled(page, asset.id);
|
||||||
});
|
});
|
||||||
test('Add photos to album', async ({ page }) => {
|
test.skip('Add photos to album', async ({ page }) => {
|
||||||
const album = timelineRestData.album;
|
const album = timelineRestData.album;
|
||||||
await pageUtils.openAlbumPage(page, album.id);
|
await pageUtils.openAlbumPage(page, album.id);
|
||||||
await page.locator('nav button[aria-label="Add photos"]').click();
|
await page.locator('nav button[aria-label="Add photos"]').click();
|
||||||
@@ -752,7 +752,7 @@ test.describe('Timeline', () => {
|
|||||||
await page.getByText('Photos', { exact: true }).click();
|
await page.getByText('Photos', { exact: true }).click();
|
||||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||||
});
|
});
|
||||||
test('open /favorites, archive photo, unarchive photo', async ({ page }) => {
|
test.skip('open /favorites, archive photo, unarchive photo', async ({ page }) => {
|
||||||
await pageUtils.openFavorites(page);
|
await pageUtils.openFavorites(page);
|
||||||
const assetToArchive = getAsset(timelineRestData, 'ad31e29f-2069-4574-b9a9-ad86523c92cb')!;
|
const assetToArchive = getAsset(timelineRestData, 'ad31e29f-2069-4574-b9a9-ad86523c92cb')!;
|
||||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const thumbnailUtils = {
|
|||||||
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"]`);
|
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"]`);
|
||||||
},
|
},
|
||||||
selectButton(page: Page, assetId: string) {
|
selectButton(page: Page, assetId: string) {
|
||||||
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
|
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button[role="checkbox"]`);
|
||||||
},
|
},
|
||||||
selectedAsset(page: Page) {
|
selectedAsset(page: Page) {
|
||||||
return page.locator('[data-thumbnail-focus-container][data-selected]');
|
return page.locator('[data-thumbnail-focus-container][data-selected]');
|
||||||
|
|||||||
+3
-41
@@ -3,7 +3,6 @@ import {
|
|||||||
AssetMediaResponseDto,
|
AssetMediaResponseDto,
|
||||||
AssetResponseDto,
|
AssetResponseDto,
|
||||||
AssetVisibility,
|
AssetVisibility,
|
||||||
CheckExistingAssetsDto,
|
|
||||||
CreateAlbumDto,
|
CreateAlbumDto,
|
||||||
CreateLibraryDto,
|
CreateLibraryDto,
|
||||||
JobCreateDto,
|
JobCreateDto,
|
||||||
@@ -20,7 +19,6 @@ import {
|
|||||||
UserAdminCreateDto,
|
UserAdminCreateDto,
|
||||||
UserPreferencesUpdateDto,
|
UserPreferencesUpdateDto,
|
||||||
ValidateLibraryDto,
|
ValidateLibraryDto,
|
||||||
checkExistingAssets,
|
|
||||||
createAlbum,
|
createAlbum,
|
||||||
createApiKey,
|
createApiKey,
|
||||||
createJob,
|
createJob,
|
||||||
@@ -343,8 +341,6 @@ export const utils = {
|
|||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const _dto = {
|
const _dto = {
|
||||||
deviceAssetId: 'test-1',
|
|
||||||
deviceId: 'test',
|
|
||||||
fileCreatedAt: new Date().toISOString(),
|
fileCreatedAt: new Date().toISOString(),
|
||||||
fileModifiedAt: new Date().toISOString(),
|
fileModifiedAt: new Date().toISOString(),
|
||||||
...dto,
|
...dto,
|
||||||
@@ -375,40 +371,6 @@ export const utils = {
|
|||||||
return body as AssetMediaResponseDto;
|
return body as AssetMediaResponseDto;
|
||||||
},
|
},
|
||||||
|
|
||||||
replaceAsset: async (
|
|
||||||
accessToken: string,
|
|
||||||
assetId: string,
|
|
||||||
dto?: Partial<Omit<AssetMediaCreateDto, 'assetData'>> & { assetData?: FileData },
|
|
||||||
) => {
|
|
||||||
const _dto = {
|
|
||||||
deviceAssetId: 'test-1',
|
|
||||||
deviceId: 'test',
|
|
||||||
fileCreatedAt: new Date().toISOString(),
|
|
||||||
fileModifiedAt: new Date().toISOString(),
|
|
||||||
...dto,
|
|
||||||
};
|
|
||||||
|
|
||||||
const assetData = dto?.assetData?.bytes || makeRandomImage();
|
|
||||||
const filename = dto?.assetData?.filename || 'example.png';
|
|
||||||
|
|
||||||
if (dto?.assetData?.bytes) {
|
|
||||||
console.log(`Uploading ${filename}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const builder = request(app)
|
|
||||||
.put(`/assets/${assetId}/original`)
|
|
||||||
.attach('assetData', assetData, filename)
|
|
||||||
.set('Authorization', `Bearer ${accessToken}`);
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(_dto)) {
|
|
||||||
void builder.field(key, String(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
const { body } = await builder;
|
|
||||||
|
|
||||||
return body as AssetMediaResponseDto;
|
|
||||||
},
|
|
||||||
|
|
||||||
createImageFile: (path: string) => {
|
createImageFile: (path: string) => {
|
||||||
if (!existsSync(dirname(path))) {
|
if (!existsSync(dirname(path))) {
|
||||||
mkdirSync(dirname(path), { recursive: true });
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
@@ -450,9 +412,6 @@ export const utils = {
|
|||||||
|
|
||||||
getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
|
getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
checkExistingAssets: (accessToken: string, checkExistingAssetsDto: CheckExistingAssetsDto) =>
|
|
||||||
checkExistingAssets({ checkExistingAssetsDto }, { headers: asBearerAuth(accessToken) }),
|
|
||||||
|
|
||||||
searchAssets: async (accessToken: string, dto: MetadataSearchDto) => {
|
searchAssets: async (accessToken: string, dto: MetadataSearchDto) => {
|
||||||
return searchAssets({ metadataSearchDto: dto }, { headers: asBearerAuth(accessToken) });
|
return searchAssets({ metadataSearchDto: dto }, { headers: asBearerAuth(accessToken) });
|
||||||
},
|
},
|
||||||
@@ -510,6 +469,9 @@ export const utils = {
|
|||||||
createStack: (accessToken: string, assetIds: string[]) =>
|
createStack: (accessToken: string, assetIds: string[]) =>
|
||||||
createStack({ stackCreateDto: { assetIds } }, { headers: asBearerAuth(accessToken) }),
|
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: (accessToken: string, tags: string[]) =>
|
||||||
upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) }),
|
upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
|
|||||||
+1
-1
Submodule e2e/test-assets updated: 163c251744...6742055402
+3
-1
@@ -14,8 +14,10 @@
|
|||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"paths": {
|
||||||
|
"src/*": ["./src/*"]
|
||||||
|
},
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"baseUrl": "./"
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "vitest*.config.ts"],
|
"include": ["src/**/*.ts", "vitest*.config.ts"],
|
||||||
"exclude": ["dist", "node_modules"]
|
"exclude": ["dist", "node_modules"]
|
||||||
|
|||||||
@@ -178,6 +178,17 @@
|
|||||||
"stop_motion_photo": "Stop bewegingsfoto",
|
"stop_motion_photo": "Stop bewegingsfoto",
|
||||||
"stop_photo_sharing": "Staak die deel van u foto’s?",
|
"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.",
|
"stop_photo_sharing_description": "{partner} sal nie meer toegang tot u foto’s hê nie.",
|
||||||
|
"unnamed_share": "Naamlose deelskakel",
|
||||||
|
"unsaved_change": "Onbewaarde verandering",
|
||||||
|
"unselect_all": "Ontkies alles",
|
||||||
|
"unselect_all_duplicates": "Ontkies alle duplikate",
|
||||||
|
"unselect_all_in": "Ontkies alles in {group}",
|
||||||
|
"unstack": "Ontstapel",
|
||||||
|
"unstack_action_prompt": "{count} ongestapel",
|
||||||
|
"unstacked_assets_count": "{count, plural, one {# item} other {# items}} ontstapel",
|
||||||
|
"unsupported_field_type": "Onondersteunde veldtipe",
|
||||||
|
"unsupported_file_type": "Lêer {file} kan nie opgelaai word nie omdat die lêertipe {type} nie ondersteun word nie.",
|
||||||
|
"untagged": "Sonder etiket",
|
||||||
"untitled_workflow": "Naamlose werkvloei",
|
"untitled_workflow": "Naamlose werkvloei",
|
||||||
"up_next": "Volgende",
|
"up_next": "Volgende",
|
||||||
"update_location_action_prompt": "Werk die ligging van {count} gekose items by met:",
|
"update_location_action_prompt": "Werk die ligging van {count} gekose items by met:",
|
||||||
@@ -187,6 +198,7 @@
|
|||||||
"upload_concurrency": "Aantal gelyktydige oplaaie",
|
"upload_concurrency": "Aantal gelyktydige oplaaie",
|
||||||
"upload_details": "Oplaaidetails",
|
"upload_details": "Oplaaidetails",
|
||||||
"upload_dialog_info": "Wil u ’n rugsteun maak van die gekose item(s) op die bediener?",
|
"upload_dialog_info": "Wil u ’n rugsteun maak van die gekose item(s) op die bediener?",
|
||||||
|
"upload_dialog_title": "Laai item op",
|
||||||
"upload_error_with_count": "Oplaaifout vir {count, plural, one {# item} other {# items}}",
|
"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_errors": "Oplaai voltooi met {count, plural, one {# fout} other {# foute}}, verfris die blad om die nuwe items te sien.",
|
||||||
"upload_finished": "Klaar opgelaai",
|
"upload_finished": "Klaar opgelaai",
|
||||||
@@ -257,6 +269,7 @@
|
|||||||
"viewer_remove_from_stack": "Verwyder van stapel",
|
"viewer_remove_from_stack": "Verwyder van stapel",
|
||||||
"viewer_stack_use_as_main_asset": "Gebruik as hoofitem",
|
"viewer_stack_use_as_main_asset": "Gebruik as hoofitem",
|
||||||
"viewer_unstack": "Ontstapel",
|
"viewer_unstack": "Ontstapel",
|
||||||
|
"visibility": "Sigbaarheid",
|
||||||
"visibility_changed": "Sigbaarheid verander vir {count, plural, one {# mens} other {# mense}}",
|
"visibility_changed": "Sigbaarheid verander vir {count, plural, one {# mens} other {# mense}}",
|
||||||
"visual": "Visueel",
|
"visual": "Visueel",
|
||||||
"visual_builder": "Visuele bouer",
|
"visual_builder": "Visuele bouer",
|
||||||
|
|||||||
+22
-15
@@ -3,7 +3,7 @@
|
|||||||
"account": "حساب",
|
"account": "حساب",
|
||||||
"account_settings": "إعدادات الحساب",
|
"account_settings": "إعدادات الحساب",
|
||||||
"acknowledge": "أُدرك ذلك",
|
"acknowledge": "أُدرك ذلك",
|
||||||
"action": "عملية",
|
"action": "إجراء",
|
||||||
"action_common_update": "تحديث",
|
"action_common_update": "تحديث",
|
||||||
"action_description": "مجموعة من الفعاليات التي ستنفذ على الأصول التي تم تصفيتها",
|
"action_description": "مجموعة من الفعاليات التي ستنفذ على الأصول التي تم تصفيتها",
|
||||||
"actions": "عمليات",
|
"actions": "عمليات",
|
||||||
@@ -61,8 +61,8 @@
|
|||||||
"backup_onboarding_1_description": "نسخة خارج الموقع في موقع آخر.",
|
"backup_onboarding_1_description": "نسخة خارج الموقع في موقع آخر.",
|
||||||
"backup_onboarding_2_description": "نسخ محلية على أجهزة مختلفة. يشمل ذلك الملفات الرئيسية ونسخة احتياطية محلية منها.",
|
"backup_onboarding_2_description": "نسخ محلية على أجهزة مختلفة. يشمل ذلك الملفات الرئيسية ونسخة احتياطية محلية منها.",
|
||||||
"backup_onboarding_3_description": "إجمالي نُسخ بياناتك، بما في ذلك الملفات الأصلية. يشمل ذلك نسخةً واحدةً خارج الموقع ونسختين محليتين.",
|
"backup_onboarding_3_description": "إجمالي نُسخ بياناتك، بما في ذلك الملفات الأصلية. يشمل ذلك نسخةً واحدةً خارج الموقع ونسختين محليتين.",
|
||||||
"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_footer": "لمزيد من المعلومات حول النسخ الاحتياطي لـ <link>Immich</link>، يرجى الرجوع إلى <link>الوثائق</link>.",
|
||||||
"backup_onboarding_parts_title": "يتضمن النسخ الاحتياطي 3-2-1 ما يلي:",
|
"backup_onboarding_parts_title": "يتضمن النسخ الاحتياطي 3-2-1 ما يلي:",
|
||||||
"backup_onboarding_title": "النسخ الاحتياطية",
|
"backup_onboarding_title": "النسخ الاحتياطية",
|
||||||
"backup_settings": "إعدادات تفريغ قاعدة البيانات",
|
"backup_settings": "إعدادات تفريغ قاعدة البيانات",
|
||||||
@@ -333,7 +333,7 @@
|
|||||||
"storage_template_migration_description": "قم بتطبيق القالب الحالي <link>{template}</link> على المحتويات التي تم رفعها سابقًا",
|
"storage_template_migration_description": "قم بتطبيق القالب الحالي <link>{template}</link> على المحتويات التي تم رفعها سابقًا",
|
||||||
"storage_template_migration_info": "تغييرات النموذج الخزني ستغير جميع الصيغ الى احرف صغيرة. تغييرات النموذج ستنطبق فقط على المحتويات الجديدة. لتطبيق النموذج على المحتويات التي تم رفعها سابقًا، قم بتشغيل <link>{job}</link>.",
|
"storage_template_migration_info": "تغييرات النموذج الخزني ستغير جميع الصيغ الى احرف صغيرة. تغييرات النموذج ستنطبق فقط على المحتويات الجديدة. لتطبيق النموذج على المحتويات التي تم رفعها سابقًا، قم بتشغيل <link>{job}</link>.",
|
||||||
"storage_template_migration_job": "وظيفة تهجير قالب التخزين",
|
"storage_template_migration_job": "وظيفة تهجير قالب التخزين",
|
||||||
"storage_template_more_details": "لمزيد من التفاصيل حول هذه الميزة، يرجى الرجوع إلى <template-link>Storage Template</template-link> و<implications-link>implications</implications-link>",
|
"storage_template_more_details": "لمزيد من التفاصيل حول هذه الميزة، يرجى الرجوع إلى <template-link>Storage Template</template-link> و <implications-link>implications</implications-link>.",
|
||||||
"storage_template_onboarding_description_v2": "عند التفعيل. هذه الخاصية ستقوم بالترتيب التلقائي للملفات بناء على نموذج معرف من قبل المستخدم. رجاء اطلع على <link>التوثيق</link>.",
|
"storage_template_onboarding_description_v2": "عند التفعيل. هذه الخاصية ستقوم بالترتيب التلقائي للملفات بناء على نموذج معرف من قبل المستخدم. رجاء اطلع على <link>التوثيق</link>.",
|
||||||
"storage_template_path_length": "الحد التقريبي لطول المسار: <b>{length, number}</b>/{limit, number}",
|
"storage_template_path_length": "الحد التقريبي لطول المسار: <b>{length, number}</b>/{limit, number}",
|
||||||
"storage_template_settings": "قالب التخزين",
|
"storage_template_settings": "قالب التخزين",
|
||||||
@@ -372,7 +372,7 @@
|
|||||||
"transcoding_audio_codec": "كود الصوت",
|
"transcoding_audio_codec": "كود الصوت",
|
||||||
"transcoding_audio_codec_description": "Opus هو الخيار ذو أعلى جودة، ولكنه يتمتع بتوافق أقل مع الأجهزة أو البرمجيات القديمة.",
|
"transcoding_audio_codec_description": "Opus هو الخيار ذو أعلى جودة، ولكنه يتمتع بتوافق أقل مع الأجهزة أو البرمجيات القديمة.",
|
||||||
"transcoding_bitrate_description": "مقاطع الفيديو التي يتجاوز معدل البت أقصى قيمة أو التي لا تكون في تنسيق مقبول",
|
"transcoding_bitrate_description": "مقاطع الفيديو التي يتجاوز معدل البت أقصى قيمة أو التي لا تكون في تنسيق مقبول",
|
||||||
"transcoding_codecs_learn_more": "لمعرفة المزيد حول المصطلحات المستخدمة هنا، يرجى الرجوع إلى وثائق FFmpeg لل<h264-link>H.264 codec</h264-link>, <hevc-link>HEVC codec</hevc-link> and <vp9-link>VP9 codec</vp9-link>.",
|
"transcoding_codecs_learn_more": "لمعرفة المزيد حول المصطلحات المستخدمة هنا، يرجى الرجوع إلى وثائق FFmpeg لـ <h264-link>H.264 codec</h264-link>، و <hevc-link>HEVC codec</hevc-link> و <vp9-link>VP9 codec</vp9-link>.",
|
||||||
"transcoding_constant_quality_mode": "وضع الجودة الثابتة",
|
"transcoding_constant_quality_mode": "وضع الجودة الثابتة",
|
||||||
"transcoding_constant_quality_mode_description": "ICQ أفضل من CQP، ولكن بعض أجهزة عتاد التسريع لا تدعم هذا الوضع. تعيين هذا الخيار يسجعل الأفضلية للوضع المحدد عند استخدام الترميز بناءً على الجودة. يتم تجاهله بواسطة NVENC لأنه لا يدعم ICQ.",
|
"transcoding_constant_quality_mode_description": "ICQ أفضل من CQP، ولكن بعض أجهزة عتاد التسريع لا تدعم هذا الوضع. تعيين هذا الخيار يسجعل الأفضلية للوضع المحدد عند استخدام الترميز بناءً على الجودة. يتم تجاهله بواسطة NVENC لأنه لا يدعم ICQ.",
|
||||||
"transcoding_constant_rate_factor": "عامل معدل الجودة الثابت (-crf)",
|
"transcoding_constant_rate_factor": "عامل معدل الجودة الثابت (-crf)",
|
||||||
@@ -441,7 +441,7 @@
|
|||||||
"user_successfully_removed": "المستخدم {email} تمت ازالته بنجاح.",
|
"user_successfully_removed": "المستخدم {email} تمت ازالته بنجاح.",
|
||||||
"users_page_description": "صفحة ادارة المستخدمين",
|
"users_page_description": "صفحة ادارة المستخدمين",
|
||||||
"version_check_enabled_description": "تفعيل التحقق من الإصدارات الجديدة",
|
"version_check_enabled_description": "تفعيل التحقق من الإصدارات الجديدة",
|
||||||
"version_check_implications": "تعتمد ميزة التحقق من الإصدار على التواصل الدوري مع github.com",
|
"version_check_implications": "تعتمد ميزة التحقق من الإصدار على التواصل الدوري مع {server}",
|
||||||
"version_check_settings": "التحقق من الإصدار",
|
"version_check_settings": "التحقق من الإصدار",
|
||||||
"version_check_settings_description": "تفعيل/تعطيل الإشعار لإصدار جديد",
|
"version_check_settings_description": "تفعيل/تعطيل الإشعار لإصدار جديد",
|
||||||
"video_conversion_job": "تحويل أشرطة الفيديو",
|
"video_conversion_job": "تحويل أشرطة الفيديو",
|
||||||
@@ -849,9 +849,12 @@
|
|||||||
"create_link_to_share": "إنشاء رابط للمشاركة",
|
"create_link_to_share": "إنشاء رابط للمشاركة",
|
||||||
"create_link_to_share_description": "السماح لأي شخص لديه الرابط بمشاهدة الصورة (الصور) المحددة",
|
"create_link_to_share_description": "السماح لأي شخص لديه الرابط بمشاهدة الصورة (الصور) المحددة",
|
||||||
"create_new": "انشاء جديد",
|
"create_new": "انشاء جديد",
|
||||||
|
"create_new_face": "إنشاء وجه جديد",
|
||||||
"create_new_person": "إنشاء شخص جديد",
|
"create_new_person": "إنشاء شخص جديد",
|
||||||
"create_new_person_hint": "تعيين المحتويات المحددة لشخص جديد",
|
"create_new_person_hint": "تعيين المحتويات المحددة لشخص جديد",
|
||||||
"create_new_user": "إنشاء مستخدم جديد",
|
"create_new_user": "إنشاء مستخدم جديد",
|
||||||
|
"create_person": "إنشاء شخص",
|
||||||
|
"create_person_subtitle": "أضف اسماً للوجه المحدد لإنشاء الشخص الجديد والإشارة إليه",
|
||||||
"create_shared_album_page_share_add_assets": "إضافة الأصول",
|
"create_shared_album_page_share_add_assets": "إضافة الأصول",
|
||||||
"create_shared_album_page_share_select_photos": "حدد الصور",
|
"create_shared_album_page_share_select_photos": "حدد الصور",
|
||||||
"create_shared_link": "انشاء رابط مشترك",
|
"create_shared_link": "انشاء رابط مشترك",
|
||||||
@@ -866,6 +869,7 @@
|
|||||||
"crop_aspect_ratio_fixed": "تم الاصلاح",
|
"crop_aspect_ratio_fixed": "تم الاصلاح",
|
||||||
"crop_aspect_ratio_free": "حر",
|
"crop_aspect_ratio_free": "حر",
|
||||||
"crop_aspect_ratio_original": "اصلي",
|
"crop_aspect_ratio_original": "اصلي",
|
||||||
|
"crop_aspect_ratio_square": "مربع",
|
||||||
"curated_object_page_title": "أشياء",
|
"curated_object_page_title": "أشياء",
|
||||||
"current_device": "الجهاز الحالي",
|
"current_device": "الجهاز الحالي",
|
||||||
"current_pin_code": "رمز PIN الحالي",
|
"current_pin_code": "رمز PIN الحالي",
|
||||||
@@ -880,7 +884,7 @@
|
|||||||
"daily_title_text_date": "E ، MMM DD",
|
"daily_title_text_date": "E ، MMM DD",
|
||||||
"daily_title_text_date_year": "E ، MMM DD ، yyyy",
|
"daily_title_text_date_year": "E ، MMM DD ، yyyy",
|
||||||
"dark": "معتم",
|
"dark": "معتم",
|
||||||
"dark_theme": "تبديل المظهر الداكن",
|
"dark_theme": "تبديل المظهر إلى الداكن",
|
||||||
"date": "تاريخ",
|
"date": "تاريخ",
|
||||||
"date_after": "التارخ بعد",
|
"date_after": "التارخ بعد",
|
||||||
"date_and_time": "التاريخ و الوقت",
|
"date_and_time": "التاريخ و الوقت",
|
||||||
@@ -891,10 +895,8 @@
|
|||||||
"day": "يوم",
|
"day": "يوم",
|
||||||
"days": "ايام",
|
"days": "ايام",
|
||||||
"deduplicate_all": "إلغاء تكرار الكل",
|
"deduplicate_all": "إلغاء تكرار الكل",
|
||||||
"deduplication_criteria_1": "حجم الصورة بوحدات البايت",
|
"default_locale": "الإعدادات المحلية الافتراضية",
|
||||||
"deduplication_criteria_2": "عدد بيانات EXIF",
|
"default_locale_description": "تنسيق التواريخ والأرقام بناءً على الإعدادات المحلية للمتصفح",
|
||||||
"deduplication_info": "معلومات إلغاء البيانات المكررة",
|
|
||||||
"deduplication_info_description": "لتحديد الأصول مسبقا تلقائيا وإزالة التكرارات بكميات كبيرة، ننظر إلى:",
|
|
||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
"delete_action_confirmation_message": "هل انت متأكد من حذف هذا الملف؟ هذا سؤدي الى نقل الملف الى سلة مهملات الخادم وسيتم اشعارك ان كنت تريد حذفه على الجهاز",
|
"delete_action_confirmation_message": "هل انت متأكد من حذف هذا الملف؟ هذا سؤدي الى نقل الملف الى سلة مهملات الخادم وسيتم اشعارك ان كنت تريد حذفه على الجهاز",
|
||||||
"delete_action_prompt": "تم حذف {count}",
|
"delete_action_prompt": "تم حذف {count}",
|
||||||
@@ -970,7 +972,7 @@
|
|||||||
"downloading_media": "تنزيل الوسائط",
|
"downloading_media": "تنزيل الوسائط",
|
||||||
"drop_files_to_upload": "قم بإسقاط الملفات في أي مكان لرفعها",
|
"drop_files_to_upload": "قم بإسقاط الملفات في أي مكان لرفعها",
|
||||||
"duplicates": "التكرارات",
|
"duplicates": "التكرارات",
|
||||||
"duplicates_description": "قم بحل كل مجموعة من خلال الإشارة إلى التكرارات، إن وجدت",
|
"duplicates_description": "قم بحل كل مجموعة من خلال الإشارة إلى التكرارات، إن وجدت.",
|
||||||
"duration": "المدة",
|
"duration": "المدة",
|
||||||
"edit": "تعديل",
|
"edit": "تعديل",
|
||||||
"edit_album": "تعديل الألبوم",
|
"edit_album": "تعديل الألبوم",
|
||||||
@@ -1387,9 +1389,11 @@
|
|||||||
"library_page_sort_title": "عنوان الألبوم",
|
"library_page_sort_title": "عنوان الألبوم",
|
||||||
"licenses": "رُخَص",
|
"licenses": "رُخَص",
|
||||||
"light": "المضيئ",
|
"light": "المضيئ",
|
||||||
|
"light_theme": "التبديل إلى المظهر الفاتح",
|
||||||
"like": "اعجاب",
|
"like": "اعجاب",
|
||||||
"like_deleted": "تم حذف الإعجاب",
|
"like_deleted": "تم حذف الإعجاب",
|
||||||
"link_motion_video": "رابط فيديو الحركة",
|
"link_motion_video": "رابط فيديو الحركة",
|
||||||
|
"link_to_docs": "لمزيد من المعلومات، يُرجى الرجوع إلى <link>الوثائق</link>.",
|
||||||
"link_to_oauth": "الربط مع OAuth",
|
"link_to_oauth": "الربط مع OAuth",
|
||||||
"linked_oauth_account": "حساب مرتبط بـ OAuth",
|
"linked_oauth_account": "حساب مرتبط بـ OAuth",
|
||||||
"list": "قائمة",
|
"list": "قائمة",
|
||||||
@@ -1651,6 +1655,7 @@
|
|||||||
"only_favorites": "المفضلة فقط",
|
"only_favorites": "المفضلة فقط",
|
||||||
"open": "فتح",
|
"open": "فتح",
|
||||||
"open_calendar": "افتح الرزنامة",
|
"open_calendar": "افتح الرزنامة",
|
||||||
|
"open_in_browser": "فتح في متصفح",
|
||||||
"open_in_map_view": "فتح في عرض الخريطة",
|
"open_in_map_view": "فتح في عرض الخريطة",
|
||||||
"open_in_openstreetmap": "فتح في OpenStreetMap",
|
"open_in_openstreetmap": "فتح في OpenStreetMap",
|
||||||
"open_the_search_filters": "افتح مرشحات البحث",
|
"open_the_search_filters": "افتح مرشحات البحث",
|
||||||
@@ -2212,6 +2217,7 @@
|
|||||||
"tag": "العلامة",
|
"tag": "العلامة",
|
||||||
"tag_assets": "أصول العلامة",
|
"tag_assets": "أصول العلامة",
|
||||||
"tag_created": "تم إنشاء العلامة: {tag}",
|
"tag_created": "تم إنشاء العلامة: {tag}",
|
||||||
|
"tag_face": "علِّم الوجه",
|
||||||
"tag_feature_description": "تصفح الصور ومقاطع الفيديو المجمعة حسب مواضيع العلامات المنطقية",
|
"tag_feature_description": "تصفح الصور ومقاطع الفيديو المجمعة حسب مواضيع العلامات المنطقية",
|
||||||
"tag_not_found_question": "لا يمكن العثور على علامة؟ <link>قم بإنشاء علامة جديدة.</link>",
|
"tag_not_found_question": "لا يمكن العثور على علامة؟ <link>قم بإنشاء علامة جديدة.</link>",
|
||||||
"tag_people": "علِّم الأشخاص",
|
"tag_people": "علِّم الأشخاص",
|
||||||
@@ -2386,13 +2392,14 @@
|
|||||||
"view_name": "عرض",
|
"view_name": "عرض",
|
||||||
"view_next_asset": "عرض المحتوى التالي",
|
"view_next_asset": "عرض المحتوى التالي",
|
||||||
"view_previous_asset": "عرض المحتوى السابق",
|
"view_previous_asset": "عرض المحتوى السابق",
|
||||||
"view_qr_code": "عرض رمز الاستجابة السريعة",
|
"view_qr_code": "عرض رمز الاستجابة السريعة",
|
||||||
"view_similar_photos": "عرض صور مشابهة",
|
"view_similar_photos": "عرض صور مشابهة",
|
||||||
"view_stack": "عرض التكديس",
|
"view_stack": "عرض التكديس",
|
||||||
"view_user": "عرض المستخدم",
|
"view_user": "عرض المستخدم",
|
||||||
"viewer_remove_from_stack": "حذف من الكومه أو المجموعة",
|
"viewer_remove_from_stack": "حذف من الكومه أو المجموعة",
|
||||||
"viewer_stack_use_as_main_asset": "استخدم كأصل رئيسي",
|
"viewer_stack_use_as_main_asset": "استخدم كأصل رئيسي",
|
||||||
"viewer_unstack": "فك الكومه",
|
"viewer_unstack": "فك الكومه",
|
||||||
|
"visibility": "إمكانية الرؤية",
|
||||||
"visibility_changed": "الرؤية تغيرت لـ {count, plural, one {شخص واحد} other {# عدة أشخاص}}",
|
"visibility_changed": "الرؤية تغيرت لـ {count, plural, one {شخص واحد} other {# عدة أشخاص}}",
|
||||||
"visual": "مرئي",
|
"visual": "مرئي",
|
||||||
"visual_builder": "اداة نشاء مرئية",
|
"visual_builder": "اداة نشاء مرئية",
|
||||||
@@ -2404,14 +2411,14 @@
|
|||||||
"welcome_to_immich": "مرحباً بك في Immich",
|
"welcome_to_immich": "مرحباً بك في Immich",
|
||||||
"width": "عُرض",
|
"width": "عُرض",
|
||||||
"wifi_name": "اسم شبكة Wi-Fi",
|
"wifi_name": "اسم شبكة Wi-Fi",
|
||||||
"workflow_delete_prompt": "هل أنت متأكد من حذف سير العمل هذا؟",
|
"workflow_delete_prompt": "متأكد من حذف سير العمل هذا؟",
|
||||||
"workflow_deleted": "تم حذف سير العمل",
|
"workflow_deleted": "تم حذف سير العمل",
|
||||||
"workflow_description": "وصف سير العمل",
|
"workflow_description": "وصف سير العمل",
|
||||||
"workflow_info": "معلومات سير العمل",
|
"workflow_info": "معلومات سير العمل",
|
||||||
"workflow_json": "ملف JSON لسير العمل",
|
"workflow_json": "ملف JSON لسير العمل",
|
||||||
"workflow_json_help": "قم بتعديل إعدادات سير العمل بصيغة JSON. ستتم مزامنة التغييرات مع أداة الإنشاء المرئية.",
|
"workflow_json_help": "قم بتعديل إعدادات سير العمل بصيغة JSON. ستتم مزامنة التغييرات مع أداة الإنشاء المرئية.",
|
||||||
"workflow_name": "اسم سير العمل",
|
"workflow_name": "اسم سير العمل",
|
||||||
"workflow_navigation_prompt": "هل انت متاكد من المغادرة بدون حفظ التغييرات؟",
|
"workflow_navigation_prompt": "متاكد من المغادرة بدون حفظ التغييرات؟",
|
||||||
"workflow_summary": "ملخص سير العمل",
|
"workflow_summary": "ملخص سير العمل",
|
||||||
"workflow_update_success": "تم تحديث سير العمل بنجاح",
|
"workflow_update_success": "تم تحديث سير العمل بنجاح",
|
||||||
"workflow_updated": "تم تحديث سير العمل",
|
"workflow_updated": "تم تحديث سير العمل",
|
||||||
|
|||||||
+1
-1
@@ -239,7 +239,7 @@
|
|||||||
"user_settings": "Налады карыстальніка",
|
"user_settings": "Налады карыстальніка",
|
||||||
"user_settings_description": "Кіраванне наладамі карыстальніка",
|
"user_settings_description": "Кіраванне наладамі карыстальніка",
|
||||||
"version_check_enabled_description": "Уключыць праверку версіі",
|
"version_check_enabled_description": "Уключыць праверку версіі",
|
||||||
"version_check_implications": "Функцыя праверкі версіі перыядычна звяртаецца да github.com",
|
"version_check_implications": "Функцыя праверкі версіі перыядычна звяртаецца да {server}",
|
||||||
"version_check_settings": "Праверка версіі",
|
"version_check_settings": "Праверка версіі",
|
||||||
"version_check_settings_description": "Уключыць/адключыць апавяшчэнні аб новай версіі"
|
"version_check_settings_description": "Уключыць/адключыць апавяшчэнні аб новай версіі"
|
||||||
},
|
},
|
||||||
|
|||||||
+18
-11
@@ -333,7 +333,7 @@
|
|||||||
"storage_template_migration_description": "Прилагане на текущия <link>{template}</link> към предишно качените файлове",
|
"storage_template_migration_description": "Прилагане на текущия <link>{template}</link> към предишно качените файлове",
|
||||||
"storage_template_migration_info": "Шаблона ще преобразува всички разширения на имената на файловете в долен регистър. Промените в шаблоните ще се прилагат само за нови елементи. За да приложите принудително шаблона към вече качени елементи, изпълнете <link>{job}</link>.",
|
"storage_template_migration_info": "Шаблона ще преобразува всички разширения на имената на файловете в долен регистър. Промените в шаблоните ще се прилагат само за нови елементи. За да приложите принудително шаблона към вече качени елементи, изпълнете <link>{job}</link>.",
|
||||||
"storage_template_migration_job": "Задача за миграция на шаблона за съхранение",
|
"storage_template_migration_job": "Задача за миграция на шаблона за съхранение",
|
||||||
"storage_template_more_details": "За повече подробности относно тази функция се обърнете към шаблона <template-link>Storage Template</template-link> и неговите <implications-link> последствия </implications-link>",
|
"storage_template_more_details": "За повече подробности относно тази функция се обърнете към шаблона <template-link>Storage Template</template-link> и неговите <implications-link>последствия</implications-link>",
|
||||||
"storage_template_onboarding_description_v2": "Когато е разрешена, тази функция ще организира автоматично файловете, според шаблон, дефиниран от потребителя. За допълнителна информация, моля вижте <link>документацията</link>.",
|
"storage_template_onboarding_description_v2": "Когато е разрешена, тази функция ще организира автоматично файловете, според шаблон, дефиниран от потребителя. За допълнителна информация, моля вижте <link>документацията</link>.",
|
||||||
"storage_template_path_length": "Ограничение на дължината на пътя: <b>{length, number}</b>/{limit, number}",
|
"storage_template_path_length": "Ограничение на дължината на пътя: <b>{length, number}</b>/{limit, number}",
|
||||||
"storage_template_settings": "Шаблон за съхранение",
|
"storage_template_settings": "Шаблон за съхранение",
|
||||||
@@ -441,7 +441,7 @@
|
|||||||
"user_successfully_removed": "Потребител {email} е успешно премахнат.",
|
"user_successfully_removed": "Потребител {email} е успешно премахнат.",
|
||||||
"users_page_description": "Страница за администриране на потребители",
|
"users_page_description": "Страница за администриране на потребители",
|
||||||
"version_check_enabled_description": "Активирай проверка на версията",
|
"version_check_enabled_description": "Активирай проверка на версията",
|
||||||
"version_check_implications": "Функцията за проверка на версията разчита на периодична комуникация с github.com",
|
"version_check_implications": "Функцията за проверка на версията разчита на периодична комуникация с {server}",
|
||||||
"version_check_settings": "Проверка на версията",
|
"version_check_settings": "Проверка на версията",
|
||||||
"version_check_settings_description": "Активирайте/деактивирайте известието за нова версия",
|
"version_check_settings_description": "Активирайте/деактивирайте известието за нова версия",
|
||||||
"video_conversion_job": "Транскодиране на видеоклиповете",
|
"video_conversion_job": "Транскодиране на видеоклиповете",
|
||||||
@@ -849,9 +849,12 @@
|
|||||||
"create_link_to_share": "Създаване на линк за споделяне",
|
"create_link_to_share": "Създаване на линк за споделяне",
|
||||||
"create_link_to_share_description": "Позволете на всеки, който има линк, да види избраната(ите) снимка(и)",
|
"create_link_to_share_description": "Позволете на всеки, който има линк, да види избраната(ите) снимка(и)",
|
||||||
"create_new": "СЪЗДАЙ НОВ",
|
"create_new": "СЪЗДАЙ НОВ",
|
||||||
|
"create_new_face": "Създай ново лице",
|
||||||
"create_new_person": "Създаване на ново лице",
|
"create_new_person": "Създаване на ново лице",
|
||||||
"create_new_person_hint": "Присвойте избраните файлове на нов човек",
|
"create_new_person_hint": "Присвойте избраните файлове на нов човек",
|
||||||
"create_new_user": "Създаване на нов потребител",
|
"create_new_user": "Създаване на нов потребител",
|
||||||
|
"create_person": "Създай човек",
|
||||||
|
"create_person_subtitle": "Добави име към избраното лице за да създадеш и да сложиш етикет на новия човек",
|
||||||
"create_shared_album_page_share_add_assets": "ДОБАВИ ОБЕКТИ",
|
"create_shared_album_page_share_add_assets": "ДОБАВИ ОБЕКТИ",
|
||||||
"create_shared_album_page_share_select_photos": "Избери снимки",
|
"create_shared_album_page_share_select_photos": "Избери снимки",
|
||||||
"create_shared_link": "Създай линк за споделяне",
|
"create_shared_link": "Създай линк за споделяне",
|
||||||
@@ -866,6 +869,7 @@
|
|||||||
"crop_aspect_ratio_fixed": "Фиксиран",
|
"crop_aspect_ratio_fixed": "Фиксиран",
|
||||||
"crop_aspect_ratio_free": "Свободен",
|
"crop_aspect_ratio_free": "Свободен",
|
||||||
"crop_aspect_ratio_original": "Оригинален",
|
"crop_aspect_ratio_original": "Оригинален",
|
||||||
|
"crop_aspect_ratio_square": "Квадрат",
|
||||||
"curated_object_page_title": "Неща",
|
"curated_object_page_title": "Неща",
|
||||||
"current_device": "Текущо устройство",
|
"current_device": "Текущо устройство",
|
||||||
"current_pin_code": "Сегашен PIN код",
|
"current_pin_code": "Сегашен PIN код",
|
||||||
@@ -880,7 +884,7 @@
|
|||||||
"daily_title_text_date": "E, dd MMM",
|
"daily_title_text_date": "E, dd MMM",
|
||||||
"daily_title_text_date_year": "E, dd MMM yyyy",
|
"daily_title_text_date_year": "E, dd MMM yyyy",
|
||||||
"dark": "Тъмен",
|
"dark": "Тъмен",
|
||||||
"dark_theme": "Тъмна тема",
|
"dark_theme": "Премини към тъмна тема",
|
||||||
"date": "Дата",
|
"date": "Дата",
|
||||||
"date_after": "Дата след",
|
"date_after": "Дата след",
|
||||||
"date_and_time": "Дата и час",
|
"date_and_time": "Дата и час",
|
||||||
@@ -891,10 +895,8 @@
|
|||||||
"day": "Ден",
|
"day": "Ден",
|
||||||
"days": "Дни",
|
"days": "Дни",
|
||||||
"deduplicate_all": "Дедупликиране на всички",
|
"deduplicate_all": "Дедупликиране на всички",
|
||||||
"deduplication_criteria_1": "Размер на снимката в байтове",
|
"default_locale": "Език по подразбиране",
|
||||||
"deduplication_criteria_2": "Брой EXIF данни",
|
"default_locale_description": "Формат на дата и числа според езиковата настройка на браузъра",
|
||||||
"deduplication_info": "Информация за дедупликацията",
|
|
||||||
"deduplication_info_description": "За автоматично предварително избиране на ресурси и премахване на дубликати на едро, разглеждаме:",
|
|
||||||
"delete": "Изтрий",
|
"delete": "Изтрий",
|
||||||
"delete_action_confirmation_message": "Сигурни ли сте, че искате да изтриете този обект? Следва преместване на обекта в коша за отпадъци на сървъра и ще получите предложение обекта да бъде изтрит локално",
|
"delete_action_confirmation_message": "Сигурни ли сте, че искате да изтриете този обект? Следва преместване на обекта в коша за отпадъци на сървъра и ще получите предложение обекта да бъде изтрит локално",
|
||||||
"delete_action_prompt": "{count} са изтрити",
|
"delete_action_prompt": "{count} са изтрити",
|
||||||
@@ -970,7 +972,7 @@
|
|||||||
"downloading_media": "Изтегляне на медия",
|
"downloading_media": "Изтегляне на медия",
|
||||||
"drop_files_to_upload": "Пуснете файловете, за да ги качите",
|
"drop_files_to_upload": "Пуснете файловете, за да ги качите",
|
||||||
"duplicates": "Дубликати",
|
"duplicates": "Дубликати",
|
||||||
"duplicates_description": "Изберете всяка група, като посочите кои, ако има такива, са дубликати",
|
"duplicates_description": "Изберете всяка група, като посочите кои, ако има такива, са дубликати.",
|
||||||
"duration": "Продължителност",
|
"duration": "Продължителност",
|
||||||
"edit": "Редактиране",
|
"edit": "Редактиране",
|
||||||
"edit_album": "Редактиране на албум",
|
"edit_album": "Редактиране на албум",
|
||||||
@@ -1387,9 +1389,11 @@
|
|||||||
"library_page_sort_title": "Заглавие на албума",
|
"library_page_sort_title": "Заглавие на албума",
|
||||||
"licenses": "Лицензи",
|
"licenses": "Лицензи",
|
||||||
"light": "Светло",
|
"light": "Светло",
|
||||||
|
"light_theme": "Премини към светла тема",
|
||||||
"like": "Харесайте",
|
"like": "Харесайте",
|
||||||
"like_deleted": "Като изтрит",
|
"like_deleted": "Като изтрит",
|
||||||
"link_motion_video": "Линк към видео",
|
"link_motion_video": "Линк към видео",
|
||||||
|
"link_to_docs": "За повече информация вижте <link>документацията</link>.",
|
||||||
"link_to_oauth": "Линк към OAuth",
|
"link_to_oauth": "Линк към OAuth",
|
||||||
"linked_oauth_account": "Свързан OAuth акаунт",
|
"linked_oauth_account": "Свързан OAuth акаунт",
|
||||||
"list": "Лист",
|
"list": "Лист",
|
||||||
@@ -1651,13 +1655,14 @@
|
|||||||
"only_favorites": "Само любими",
|
"only_favorites": "Само любими",
|
||||||
"open": "Отвори",
|
"open": "Отвори",
|
||||||
"open_calendar": "Отвори календар",
|
"open_calendar": "Отвори календар",
|
||||||
|
"open_in_browser": "Отвори в браузър",
|
||||||
"open_in_map_view": "Отвори изглед на карта",
|
"open_in_map_view": "Отвори изглед на карта",
|
||||||
"open_in_openstreetmap": "Отвори в OpenStreetMap",
|
"open_in_openstreetmap": "Отвори в OpenStreetMap",
|
||||||
"open_the_search_filters": "Отвари филтрите за търсене",
|
"open_the_search_filters": "Отвари филтрите за търсене",
|
||||||
"options": "Настройки",
|
"options": "Настройки",
|
||||||
"or": "или",
|
"or": "или",
|
||||||
"organize_into_albums": "Organitzar per àlbums",
|
"organize_into_albums": "Подредете в албуми",
|
||||||
"organize_into_albums_description": "Posar les fotos existents dins dels àlbums fent servir la configuració de sincronització",
|
"organize_into_albums_description": "Добавете наличните снимки в албуми, като използвате текущите настройки за синхронизиране",
|
||||||
"organize_your_library": "Организиране на вашата библиотека",
|
"organize_your_library": "Организиране на вашата библиотека",
|
||||||
"original": "оригинал",
|
"original": "оригинал",
|
||||||
"other": "Други",
|
"other": "Други",
|
||||||
@@ -1805,7 +1810,7 @@
|
|||||||
"purchase_server_description_2": "Статус на поддръжник",
|
"purchase_server_description_2": "Статус на поддръжник",
|
||||||
"purchase_server_title": "Сървър",
|
"purchase_server_title": "Сървър",
|
||||||
"purchase_settings_server_activated": "Продуктовият ключ на сървъра се управлява от администратора",
|
"purchase_settings_server_activated": "Продуктовият ключ на сървъра се управлява от администратора",
|
||||||
"query_asset_id": "Buscar item per ID",
|
"query_asset_id": "Търсене на елемент по ID",
|
||||||
"queue_status": "В опашка {count} от {total}",
|
"queue_status": "В опашка {count} от {total}",
|
||||||
"rate_asset": "Задаване на рейтинг",
|
"rate_asset": "Задаване на рейтинг",
|
||||||
"rating": "Оценка със звезди",
|
"rating": "Оценка със звезди",
|
||||||
@@ -2212,6 +2217,7 @@
|
|||||||
"tag": "Таг",
|
"tag": "Таг",
|
||||||
"tag_assets": "Тагни елементи",
|
"tag_assets": "Тагни елементи",
|
||||||
"tag_created": "Създаден етикет: {tag}",
|
"tag_created": "Създаден етикет: {tag}",
|
||||||
|
"tag_face": "Отбележи лице",
|
||||||
"tag_feature_description": "Разглеждане на снимки и видеоклипове, групирани по теми с логически тагове",
|
"tag_feature_description": "Разглеждане на снимки и видеоклипове, групирани по теми с логически тагове",
|
||||||
"tag_not_found_question": "Не можете да намерите етикет? <link>Създайте нов етикет.</link>",
|
"tag_not_found_question": "Не можете да намерите етикет? <link>Създайте нов етикет.</link>",
|
||||||
"tag_people": "Отбележи Хора",
|
"tag_people": "Отбележи Хора",
|
||||||
@@ -2393,6 +2399,7 @@
|
|||||||
"viewer_remove_from_stack": "Премахване от опашката",
|
"viewer_remove_from_stack": "Премахване от опашката",
|
||||||
"viewer_stack_use_as_main_asset": "Използвай като основен",
|
"viewer_stack_use_as_main_asset": "Използвай като основен",
|
||||||
"viewer_unstack": "Премахни от опашката",
|
"viewer_unstack": "Премахни от опашката",
|
||||||
|
"visibility": "Видимост",
|
||||||
"visibility_changed": "Видимостта е променена за {count, plural, one {# човек} other {# човека}}",
|
"visibility_changed": "Видимостта е променена за {count, plural, one {# човек} other {# човека}}",
|
||||||
"visual": "Визуален",
|
"visual": "Визуален",
|
||||||
"visual_builder": "Визуален конструктор",
|
"visual_builder": "Визуален конструктор",
|
||||||
|
|||||||
+304
-1
@@ -231,6 +231,8 @@
|
|||||||
"metadata_settings_description": "মেটাডেটা সেটিংস পরিচালনা করুন (Manage metadata settings)",
|
"metadata_settings_description": "মেটাডেটা সেটিংস পরিচালনা করুন (Manage metadata settings)",
|
||||||
"migration_job": "মাইগ্রেশন (Migration)",
|
"migration_job": "মাইগ্রেশন (Migration)",
|
||||||
"migration_job_description": "অ্যাসেট এবং ফেস থাম্বনেইলগুলোকে সর্বশেষ ফোল্ডার স্ট্রাকচারে মাইগ্রেট করুন। (Migrate thumbnails for assets and faces to the latest folder structure)",
|
"migration_job_description": "অ্যাসেট এবং ফেস থাম্বনেইলগুলোকে সর্বশেষ ফোল্ডার স্ট্রাকচারে মাইগ্রেট করুন। (Migrate thumbnails for assets and faces to the latest folder structure)",
|
||||||
|
"nightly_tasks_cluster_faces_setting_description": "নতুন শনাক্ত হওয়া মুখগুলিতে ফেসিয়াল রিকগনিশন চালান",
|
||||||
|
"nightly_tasks_cluster_new_faces_setting": "নতুন মুখগুলোর গুচ্ছ",
|
||||||
"nightly_tasks_database_cleanup_setting": "ডেটাবেস ক্লিনআপ টাস্কসমূহ (Database cleanup tasks)",
|
"nightly_tasks_database_cleanup_setting": "ডেটাবেস ক্লিনআপ টাস্কসমূহ (Database cleanup tasks)",
|
||||||
"nightly_tasks_database_cleanup_setting_description": "ডেটাবেস থেকে পুরোনো এবং মেয়াদোত্তীর্ণ ডেটা মুছে ফেলুন",
|
"nightly_tasks_database_cleanup_setting_description": "ডেটাবেস থেকে পুরোনো এবং মেয়াদোত্তীর্ণ ডেটা মুছে ফেলুন",
|
||||||
"nightly_tasks_generate_memories_setting": "মেমোরিজ তৈরি করুন (Generate memories)",
|
"nightly_tasks_generate_memories_setting": "মেমোরিজ তৈরি করুন (Generate memories)",
|
||||||
@@ -257,6 +259,20 @@
|
|||||||
"notification_email_secure": "SMTPS (স্মার্ট মেইল ট্রান্সফার প্রোটোকল সিকিউর)",
|
"notification_email_secure": "SMTPS (স্মার্ট মেইল ট্রান্সফার প্রোটোকল সিকিউর)",
|
||||||
"notification_email_secure_description": "SMTPS (SMTP over TLS) ব্যবহার করুন",
|
"notification_email_secure_description": "SMTPS (SMTP over TLS) ব্যবহার করুন",
|
||||||
"notification_email_sent_test_email_button": "টেস্ট ইমেল পাঠান এবং সেভ করুন",
|
"notification_email_sent_test_email_button": "টেস্ট ইমেল পাঠান এবং সেভ করুন",
|
||||||
|
"notification_email_setting_description": "ইমেল নোটিফিকেশন পাঠানোর সেটিংস",
|
||||||
|
"notification_email_test_email": "পরীক্ষামূলক ইমেইল পাঠান",
|
||||||
|
"notification_email_test_email_failed": "পরীক্ষামূলক ইমেল পাঠানো সম্ভব হয়নি, আপনার সেটিংস যাচাই করুন",
|
||||||
|
"notification_email_test_email_sent": "{email}-এ একটি পরীক্ষামূলক ইমেল পাঠানো হয়েছে। অনুগ্রহ করে আপনার ইনবক্স দেখুন।",
|
||||||
|
"notification_email_username_description": "ইমেল সার্ভারে ভেরিফিকেসনের জন্য ব্যবহৃত ইউজারনেম",
|
||||||
|
"notification_enable_email_notifications": "ইমেল নোটিফিকেসন সক্রিয় করুন",
|
||||||
|
"notification_settings": "নোটিফিকেসন সেটিংস",
|
||||||
|
"notification_settings_description": "ইমেইল সহ নোটিফিকেশন সেটিংস পরিচালনা করুন",
|
||||||
|
"oauth_auto_launch": "অটো লঞ্চ",
|
||||||
|
"oauth_auto_launch_description": "লগইন পেজে প্রবেশ করার সাথে সাথে OAuth লগইন প্রক্রিয়াটি স্বয়ংক্রিয়ভাবে শুরু করুন",
|
||||||
|
"oauth_auto_register": "সয়ংক্রিয়ভাবে রেজিস্টার করুন",
|
||||||
|
"oauth_auto_register_description": "OAuth দিয়ে সাইন ইন করার পর নতুন ব্যবহারকারীদের স্বয়ংক্রিয়ভাবে নিবন্ধন করুন",
|
||||||
|
"oauth_button_text": "বাটন টেক্সট",
|
||||||
|
"oauth_client_secret_description": "গোপনীয় ক্লায়েন্টের জন্য প্রয়োজন, অথবা যদি পাবলিক ক্লায়েন্টের জন্য PKCE (Proof Key for Code Exchange) সমর্থিত না হয়।",
|
||||||
"oauth_enable_description": "OAuth-এর মাধ্যমে লগইন করুন",
|
"oauth_enable_description": "OAuth-এর মাধ্যমে লগইন করুন",
|
||||||
"oauth_mobile_redirect_uri": "মোবাইল রিডাইরেক্ট ইউআরআই (URI)",
|
"oauth_mobile_redirect_uri": "মোবাইল রিডাইরেক্ট ইউআরআই (URI)",
|
||||||
"oauth_mobile_redirect_uri_override": "মোবাইল রিডাইরেক্ট ইউআরআই (URI) ওভাররাইড",
|
"oauth_mobile_redirect_uri_override": "মোবাইল রিডাইরেক্ট ইউআরআই (URI) ওভাররাইড",
|
||||||
@@ -323,6 +339,20 @@
|
|||||||
"storage_template_settings": "স্টোরেজ টেমপ্লেট (Storage Template)",
|
"storage_template_settings": "স্টোরেজ টেমপ্লেট (Storage Template)",
|
||||||
"storage_template_settings_description": "আপলোড করা অ্যাসেটের ফোল্ডার স্ট্রাকচার এবং ফাইল নেম ম্যানেজ করুন",
|
"storage_template_settings_description": "আপলোড করা অ্যাসেটের ফোল্ডার স্ট্রাকচার এবং ফাইল নেম ম্যানেজ করুন",
|
||||||
"storage_template_user_label": "<code>{label}</code> হলো ব্যবহারকারীর স্টোরেজ লেবেল (Storage Label)",
|
"storage_template_user_label": "<code>{label}</code> হলো ব্যবহারকারীর স্টোরেজ লেবেল (Storage Label)",
|
||||||
|
"system_settings": "সিস্টেম সেটিংস",
|
||||||
|
"tag_cleanup_job": "ট্যাগ মুছে ফেলা",
|
||||||
|
"template_email_available_tags": "আপনি আপনার টেমপ্লেটে নিম্নলিখিত ভেরিয়েবলগুলো ব্যবহার করতে পারেন: {tags}",
|
||||||
|
"template_email_if_empty": "টেমপ্লেটটি খালি থাকলে ডিফল্ট ইমেল ব্যবহার করা হবে।",
|
||||||
|
"template_email_invite_album": "ইনভাইট অ্যালবাম টেমপ্লেট",
|
||||||
|
"template_email_preview": "প্রিভিউ",
|
||||||
|
"template_email_settings": "ইমেইল টেমপ্লেট",
|
||||||
|
"template_email_update_album": "অ্যালবাম টেমপ্লেট আপডেট করুন",
|
||||||
|
"template_email_welcome": "স্বাগতম ইমেইল টেমপ্লেট",
|
||||||
|
"template_settings": "নোটিফিকেশন টেমপ্লেট",
|
||||||
|
"template_settings_description": "নোটিফিকেশনের জন্য কাস্টম টেমপ্লেট পরিচালনা করুন",
|
||||||
|
"theme_custom_css_settings": "কাস্টম CSS",
|
||||||
|
"theme_custom_css_settings_description": "ক্যাসকেডিং স্টাইল শীট ব্যবহার করে Immich এর ডিজাইন কাস্টমাইজ করা যায়।",
|
||||||
|
"theme_settings": "থীম সেটিংস",
|
||||||
"theme_settings_description": "ইমিচ (Immich) ওয়েব ইন্টারফেসের কাস্টমাইজেশন ম্যানেজ করুন",
|
"theme_settings_description": "ইমিচ (Immich) ওয়েব ইন্টারফেসের কাস্টমাইজেশন ম্যানেজ করুন",
|
||||||
"thumbnail_generation_job": "থাম্বনেইল তৈরি করুন (Generate Thumbnails)",
|
"thumbnail_generation_job": "থাম্বনেইল তৈরি করুন (Generate Thumbnails)",
|
||||||
"thumbnail_generation_job_description": "প্রতিটি অ্যাসেটের জন্য বড়, ছোট এবং ব্লার (অস্পষ্ট) থাম্বনেইল তৈরি করুন, সেই সাথে প্রতিটি ব্যক্তির জন্যও থাম্বনেইল তৈরি করুন।",
|
"thumbnail_generation_job_description": "প্রতিটি অ্যাসেটের জন্য বড়, ছোট এবং ব্লার (অস্পষ্ট) থাম্বনেইল তৈরি করুন, সেই সাথে প্রতিটি ব্যক্তির জন্যও থাম্বনেইল তৈরি করুন।",
|
||||||
@@ -334,8 +364,281 @@
|
|||||||
"transcoding_acceleration_vaapi": "VA-API (ভিডিও অ্যাক্সিলারেশন এপিআই)",
|
"transcoding_acceleration_vaapi": "VA-API (ভিডিও অ্যাক্সিলারেশন এপিআই)",
|
||||||
"transcoding_accepted_audio_codecs": "গ্রহণযোগ্য অডিও কোডেকসমূহ (Accepted audio codecs)",
|
"transcoding_accepted_audio_codecs": "গ্রহণযোগ্য অডিও কোডেকসমূহ (Accepted audio codecs)",
|
||||||
"transcoding_accepted_audio_codecs_description": "কোন অডিও কোডেকগুলো ট্রানসকোড করার প্রয়োজন নেই তা নির্বাচন করুন। এটি শুধুমাত্র নির্দিষ্ট ট্রানসকোড পলিসির (transcode policies) জন্য ব্যবহৃত হয়।",
|
"transcoding_accepted_audio_codecs_description": "কোন অডিও কোডেকগুলো ট্রানসকোড করার প্রয়োজন নেই তা নির্বাচন করুন। এটি শুধুমাত্র নির্দিষ্ট ট্রানসকোড পলিসির (transcode policies) জন্য ব্যবহৃত হয়।",
|
||||||
"transcoding_accepted_containers": "গ্রহণযোগ্য কন্টেইনারসমূহ (Accepted containers)"
|
"transcoding_accepted_containers": "গ্রহণযোগ্য কন্টেইনারসমূহ (Accepted containers)",
|
||||||
|
"transcoding_accepted_containers_description": "কোন কন্টেইনার ফরম্যাটগুলোকে MP4-এ রিমুক্স করার প্রয়োজন নেই তা নির্বাচন করুন। শুধুমাত্র নির্দিষ্ট ট্রান্সকোড পলিসির জন্য ব্যবহৃত হয়।",
|
||||||
|
"transcoding_accepted_video_codecs": "সমর্থিত ভিডিও কোডেকগুলো",
|
||||||
|
"transcoding_accepted_video_codecs_description": "কোন ভিডিও কোডেকগুলো ট্রান্সকোড করার প্রয়োজন নেই তা নির্বাচন করুন। শুধুমাত্র নির্দিষ্ট ট্রান্সকোড নীতির জন্য ব্যবহৃত হয়।",
|
||||||
|
"transcoding_advanced_options_description": "বেশিরভাগ ব্যবহারকারীর পরিবর্তন করার প্রয়োজন নেই এমন অপশনসমূহ",
|
||||||
|
"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_constant_quality_mode": "নির্দিষ্ট মান মোড",
|
||||||
|
"transcoding_constant_quality_mode_description": "ICQ, CQP-এর চেয়ে ভালো মান দেয়, কিন্তু সব হার্ডওয়্যার অ্যাক্সেলারেশন ডিভাইসে কাজ করে না। এই অপশন চালু থাকলে কোয়ালিটি-ভিত্তিক এনকোডিংয়ে এটি প্রাধান্য পাবে। NVENC এটি সমর্থন করে না, তাই এটি উপেক্ষা করা হবে।",
|
||||||
|
"transcoding_constant_rate_factor": "নির্দিষ্ট রেট ফ্যাক্টর (-crf)",
|
||||||
|
"transcoding_constant_rate_factor_description": "ভিডিওর গুণমানের স্তর। সাধারণ মানগুলো হলো H.264-এর জন্য ২৩, HEVC-এর জন্য ২৮, VP9-এর জন্য ৩১ এবং AV1-এর জন্য ৩৫। মান যত কম হবে, ভিডিওর গুণমান তত উন্নত হবে, তবে ফাইলের আকার তত বড় হবে।",
|
||||||
|
"transcoding_disabled_description": "কোনো ভিডিও ট্রান্সকোড করবেন না, এতে কিছু ক্লায়েন্টে প্লেব্যাক নষ্ট হতে পারে",
|
||||||
|
"transcoding_encoding_options": "এনকোডিং এর অপশনগুলি",
|
||||||
|
"transcoding_encoding_options_description": "এনকোড করা ভিডিওগুলির জন্য কোডেক, রেজোলিউশন, কোয়ালিটি এবং অন্যান্য অপশন সেট করুন",
|
||||||
|
"transcoding_hardware_acceleration": "হার্ডওয়্যার এক্সিলারেসন (Acceleration)",
|
||||||
|
"transcoding_hardware_acceleration_description": "পরীক্ষামূলক: দ্রুততর ট্রান্সকোডিং, কিন্তু একই বিটরেটে গুণমান হ্রাস পেতে পারে",
|
||||||
|
"transcoding_hardware_decoding": "হার্ডওয়্যার ডিকোডিং",
|
||||||
|
"transcoding_hardware_decoding_setting_description": "শুধু এনকোডিং অ্যাক্সিলারেশন করার পরিবর্তে এটি এন্ড-টু-এন্ড অ্যাক্সিলারেশন সক্ষম করে। সব ভিডিওতে কাজ নাও করতে পারে।",
|
||||||
|
"transcoding_max_b_frames": "সর্বোচ্চ বি-ফ্রেম (B-frames)",
|
||||||
|
"transcoding_max_b_frames_description": "মান যত বেশি হবে, কমপ্রেশন তত ভালো হবে কিন্তু এনকোডিং ধীরে চলবে। পুরোনো ডিভাইসে হার্ডওয়্যার অ্যাক্সেলারেশন কাজ নাও করতে পারে। ০ দিলে B-frames বন্ধ থাকবে, -১ দিলে এটি নিজে থেকেই ঠিক হবে।",
|
||||||
|
"transcoding_max_bitrate": "সর্বোচ্চ বিটরেট",
|
||||||
|
"transcoding_max_bitrate_description": "সর্বোচ্চ বিটরেট নির্ধারণ করলে ফাইলের আকার আরও অনুমানযোগ্য হতে পারে, তবে এর ফলে কোয়ালিটির কিছুটা অবনতি ঘটে। 720p-তে, VP9 বা HEVC-এর জন্য সাধারণ মান হলো 2600 kbit/s, অথবা H.264-এর জন্য 4500 kbit/s।এর মান 0 সেট করা হলে এটি বন্ধ থাকে। যখন কোনো একক নির্দিষ্ট করা থাকে না, তখন k (kbit/s-এর জন্য) ধরে নেওয়া হয়; তাই 5000, 5000k, এবং 5M (Mbit/s-এর জন্য) সমতুল্য।",
|
||||||
|
"transcoding_max_keyframe_interval": "সর্বোচ্চ কীফ্রেম ব্যবধান",
|
||||||
|
"transcoding_max_keyframe_interval_description": "কীফ্রেমের মধ্যে সর্বোচ্চ ফ্রেম দূরত্ব নির্ধারণ করে। মান কম হলে কমপ্রেশন দক্ষতা কমে, তবে ভিডিওতে খুঁজে বের করা দ্রুত হয় এবং দ্রুত চলমান দৃশ্যে মানও কিছুটা ভালো হতে পারে। ০ দিলে এই মান স্বয়ংক্রিয়ভাবে নির্ধারিত হয়।",
|
||||||
|
"transcoding_optimal_description": "নির্দিষ্ট রেজোলিউশনের চেয়ে বড় বা সমর্থিত ফরম্যাটে নয় এমন ভিডিও",
|
||||||
|
"transcoding_policy": "ট্রান্সকোড নীতি",
|
||||||
|
"transcoding_policy_description": "ভিডিও কখন ট্রান্সকোড করা হবে তা সেট করুন",
|
||||||
|
"transcoding_preferred_hardware_device": "পছন্দের হার্ডওয়্যার ডিভাইস",
|
||||||
|
"transcoding_preferred_hardware_device_description": "শুধুমাত্র VAAPI এবং QSV-এর ক্ষেত্রে প্রযোজ্য। হার্ডওয়্যার ট্রান্সকোডিংয়ের জন্য ব্যবহৃত dri নোড নির্ধারণ করে।",
|
||||||
|
"transcoding_preset_preset": "প্রিসেট (-preset)",
|
||||||
|
"transcoding_preset_preset_description": "কম্প্রেশন স্পিড। ধীরগতির প্রিসেটগুলো ছোট ফাইল তৈরি করে এবং একটি নির্দিষ্ট বিটরেট লক্ষ্য করার সময় গুণমান বৃদ্ধি করে। VP9 'faster'-এর চেয়ে বেশি গতি উপেক্ষা করে।",
|
||||||
|
"transcoding_reference_frames": "রেফারেন্স ফ্রেম",
|
||||||
|
"transcoding_reference_frames_description": "একটি ফ্রেম কম্প্রেস করার সময় কতটি ফ্রেমকে রেফারেন্স হিসেবে নেওয়া হবে। মান যত বেশি হবে, কমপ্রেশন দক্ষতা তত ভালো হবে, তবে এনকোডিং ধীর হবে। ০ দিলে এই মান স্বয়ংক্রিয়ভাবে নির্ধারিত হবে।",
|
||||||
|
"transcoding_required_description": "শুধুমাত্র অনুমোদিত ফরম্যাটে নেই এমন ভিডিও",
|
||||||
|
"transcoding_settings": "ভিডিও ট্রান্সকোডিং সেটিংস",
|
||||||
|
"transcoding_settings_description": "নির্ধারণ করুন কোন ভিডিওগুলোকে ট্রান্সকোড করতে হবে এবং কিভাবে প্রক্রিয়া করতে হবে",
|
||||||
|
"transcoding_target_resolution": "টার্গেট রেজোলিউশন",
|
||||||
|
"transcoding_target_resolution_description": "উচ্চ রেজোলিউশন বেশি বিস্তারিত রাখে, কিন্তু এনকোডিং ধীরে হয়, ফাইল বড় হয়, এবং অ্যাপ ধীর প্রতিক্রিয়া করতে পারে।",
|
||||||
|
"transcoding_temporal_aq": "টেম্পোরাল AQ",
|
||||||
|
"transcoding_temporal_aq_description": "শুধুমাত্র NVENC-এর ক্ষেত্রে প্রযোজ্য। টেম্পোরাল অ্যাডাপটিভ কোয়ান্টাইজেশন (Adaptive Quantization) উচ্চ-বিস্তারিত ও স্বল্প-গতির দৃশ্যের মান বৃদ্ধি করে। পুরোনো ডিভাইসগুলোর সাথে সামঞ্জস্যপূর্ণ নাও হতে পারে।",
|
||||||
|
"transcoding_threads": "থ্রেড",
|
||||||
|
"transcoding_threads_description": "উচ্চ মানে এনকোডিং দ্রুত হয়, কিন্তু সার্ভার কম কাজ করতে পারে। CPU কোরের বেশি মান দেওয়া উচিত নয়। ০ দিলে সর্বাধিক ব্যবহার হবে।",
|
||||||
|
"transcoding_tone_mapping": "টোন-ম্যাপিং",
|
||||||
|
"transcoding_tone_mapping_description": "এইচডিআর (HDR) ভিডিওকে এসডিআর (SDR)-এ রূপান্তর করার সময় এর বাহ্যিক রূপ অক্ষুণ্ণ রাখার চেষ্টা করা হয়। প্রতিটি অ্যালগরিদম রঙ, ডিটেইল এবং উজ্জ্বলতার জন্য ভিন্ন ভিন্ন সমন্বয় করে। হেবল ডিটেইল, মোবিয়াস রঙ এবং রাইনহার্ড উজ্জ্বলতা অক্ষুণ্ণ রাখে।",
|
||||||
|
"transcoding_transcode_policy": "ট্রান্সকোড নীতি",
|
||||||
|
"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": "ভিডিও কোডেক",
|
||||||
|
"transcoding_video_codec_description": "VP9 উচ্চ কর্মদক্ষতা সম্পন্ন এবং ওয়েবের সাথে সামঞ্জস্যপূর্ণ, কিন্তু ট্রান্সকোড করতে বেশি সময় লাগে। HEVC-এর কর্মক্ষমতাও প্রায় একই রকম, কিন্তু এর ওয়েব সামঞ্জস্যতা কম। H.264 ব্যাপকভাবে সামঞ্জস্যপূর্ণ এবং দ্রুত ট্রান্সকোড করা যায়, কিন্তু এটি অনেক বড় ফাইল তৈরি করে। AV1 সবচেয়ে কর্মদক্ষ কোডেক, কিন্তু পুরোনো ডিভাইসগুলোতে এর সমর্থন নেই।",
|
||||||
|
"trash_enabled_description": "ট্র্যাশ ফিচার চালু করুন",
|
||||||
|
"trash_number_of_days": "দিনের সংখ্যা",
|
||||||
|
"trash_number_of_days_description": "ট্র্যাশে থাকা অ্যাসেটগুলো স্থায়ীভাবে মুছে ফেলার আগে রাখার দিন সংখ্যা",
|
||||||
|
"trash_settings": "ট্র্যাশ সেটিংস",
|
||||||
|
"trash_settings_description": "ট্র্যাশ সেটিংস পরিচালনা করুন",
|
||||||
|
"unlink_all_oauth_accounts": "সকল OAuth অ্যাকাউন্ট আনলিঙ্ক করুন",
|
||||||
|
"unlink_all_oauth_accounts_description": "নতুন প্রোভাইডারে মাইগ্রেট করার আগে সব OAuth অ্যাকাউন্ট আনলিঙ্ক করুন।",
|
||||||
|
"unlink_all_oauth_accounts_prompt": "আপনি কি সব OAuth অ্যাকাউন্ট আনলিঙ্ক করতে নিশ্চিত? এটি প্রতিটি ব্যবহারকারীর OAuth আইডি রিসেট করে দেবে এবং এটি আর পূর্বাবস্থায় ফেরানো যাবে না।",
|
||||||
|
"user_cleanup_job": "ইউজার ক্লিনআপ",
|
||||||
|
"user_delete_delay": "<b>{user}</b>-এর অ্যাকাউন্ট এবং অ্যাসেট {delay, plural, one {# day} other {# days}} পর স্থায়ীভাবে মুছে ফেলার জন্য নির্ধারিত হবে।",
|
||||||
|
"user_delete_delay_settings": "মুছে ফেলার সময় বিলম্ব",
|
||||||
|
"user_delete_delay_settings_description": "অ্যাকাউন্ট এবং অ্যাসেট মুছে ফেলার পর কত দিনের মধ্যে স্থায়ীভাবে মুছে ফেলা হবে। ব্যবহারকারী মুছে ফেলার কাজ মধ্যরাতে চালানো হয় এবং দেখা হয় কোন ব্যবহারকারী স্থায়ীভাবে মুছে ফেলার জন্য প্রস্তুত। এই সেটিং পরিবর্তন করলে পরবর্তী এক্সিকিউশনের সময় তা প্রযোজ্য হবে।",
|
||||||
|
"user_delete_immediately": "<b>{user}</b>-এর অ্যাকাউন্ট এবং অ্যাসেট স্থায়ীভাবে মুছে ফেলার জন্য <b>immediately</b> কিউতে অন্তর্ভুক্ত করা হবে।",
|
||||||
|
"user_delete_immediately_checkbox": "ব্যবহারকারী ও অ্যাসেট তৎক্ষণাৎ মুছে ফেলার জন্য কিউ",
|
||||||
|
"user_details": "ব্যবহারকারী তথ্য",
|
||||||
|
"user_management": "ব্যবহারকারী ম্যানেজমেন্ট",
|
||||||
|
"user_password_has_been_reset": "ব্যবহারকারীর পাসওয়ার্ড রিসেট করা হয়েছে:",
|
||||||
|
"user_password_reset_description": "দয়া করে ব্যবহারকারীর জন্য সাময়িক পাসওয়ার্ড দিন এবং জানিয়ে দিন যে তারা পরবর্তী লগইনে পাসওয়ার্ড পরিবর্তন করবেন।",
|
||||||
|
"user_restore_description": "<b>{user}</b> এর অ্যাকাউন্ট পুনরুদ্ধার করা হবে।",
|
||||||
|
"user_restore_scheduled_removal": "ব্যবহারকারী পুনরুদ্ধার করুন - মুছে ফেলার জন্য নির্ধারিত তারিখ:{date, date, long}",
|
||||||
|
"user_settings": "ব্যবহারকারী সেটিংস",
|
||||||
|
"user_settings_description": "ব্যবহারকারী সেটিংস ম্যানেজ করুন",
|
||||||
|
"user_successfully_removed": "সফলভাবে ইউজার {email}-কে সরিয়ে দেওয়া হয়েছে।",
|
||||||
|
"version_check_enabled_description": "ভার্সন যাচাই চালু করুন",
|
||||||
|
"version_check_implications": "ভার্সন চেক ফিচারটি github.com-এর সঙ্গে নিয়মিত সংযোগের ওপর নির্ভরশীল",
|
||||||
|
"version_check_settings": "ভার্সন যাচাই",
|
||||||
|
"version_check_settings_description": "নতুন ভার্সনের নোটিফিকেশন চালু/বন্ধ করুন",
|
||||||
|
"video_conversion_job": "ভিডিও ট্রান্সকোড করুন",
|
||||||
|
"video_conversion_job_description": "ব্রাউজার এবং ডিভাইসে আরও ভালোভাবে চলার জন্য ভিডিও ট্রান্সকোড করুন"
|
||||||
},
|
},
|
||||||
|
"admin_email": "অ্যাডমিনের ইমেইল",
|
||||||
|
"admin_password": "অ্যাডমিনের পাসওয়ার্ড",
|
||||||
|
"administration": "অ্যাডমিন",
|
||||||
|
"advanced": "অ্যাডভান্সড",
|
||||||
|
"age_months": "বয়স {months, plural, one {# month} other {# months}}",
|
||||||
|
"age_year_months": "বয়স ১ বছর, {months, plural, one {# month} other {# months}}",
|
||||||
|
"album_added": "অ্যালবাম যুক্ত করা হয়েছে",
|
||||||
|
"album_added_notification_setting_description": "শেয়ার করা অ্যালবামে যুক্ত হলে ইমেইল নোটিফিকেশন পান",
|
||||||
|
"album_cover_updated": "অ্যালবামের কভার আপডেট হয়েছে",
|
||||||
|
"album_delete_confirmation": "আপনি কি সত্যিই অ্যালবাম {album} মুছে ফেলতে চান?",
|
||||||
|
"album_delete_confirmation_description": "অ্যালবামটি শেয়ার করা থাকলেও অন্য ব্যবহারকারীরা আর এটি অ্যাক্সেস করতে পারবেন না।",
|
||||||
|
"album_info_updated": "অ্যালবামের তথ্য আপডেট করা হয়েছে",
|
||||||
|
"album_leave": "অ্যালবাম থেকে বেরিয়ে যেতে চান ?",
|
||||||
|
"album_leave_confirmation": "আপনি কি নিশ্চিত যে আপনি {album} ছেড়ে যেতে চান?",
|
||||||
|
"album_name": "অ্যালবামের নাম",
|
||||||
|
"album_options": "অ্যালবামের অপশনসমূহ",
|
||||||
|
"album_remove_user": "ব্যবহারকারী সরাতে চান?",
|
||||||
|
"album_remove_user_confirmation": "আপনি কি নিশ্চিত যে আপনি {user}-কে সরাতে চান?",
|
||||||
|
"album_share_no_users": "এই অ্যালবামটি সব ব্যবহারকারীর সঙ্গে শেয়ার করা হয়েছে, বা শেয়ার করার জন্য কোনো ব্যবহারকারী নেই।",
|
||||||
|
"album_updated": "অ্যালবাম আপডেট করা হয়েছে",
|
||||||
|
"album_updated_setting_description": "নতুন অ্যাসেট যুক্ত হলে শেয়ার করা অ্যালবামের জন্য ইমেইল নোটিফিকেশন পান",
|
||||||
|
"album_user_left": "বাম {album}",
|
||||||
|
"album_user_removed": "{user} কে সরানো হয়েছে",
|
||||||
|
"album_with_link_access": "লিঙ্ক থাকা যে কেউ এই অ্যালবামের ছবি ও মানুষজনকে দেখতে পারবে।",
|
||||||
|
"albums": "অ্যালবামসমূহ",
|
||||||
|
"all": "সব",
|
||||||
|
"all_albums": "সকল অ্যালবামসমূহ",
|
||||||
|
"all_people": "সব ব্যবহারকারী",
|
||||||
|
"all_videos": "সব ভিডিও",
|
||||||
|
"allow_dark_mode": "ডার্ক মোড চালু করুন",
|
||||||
|
"allow_edits": "এডিটের অনুমতি দিন",
|
||||||
|
"allow_public_user_to_download": "সাধারণ ব্যবহারকারী ডাউনলোড করতে পারবে",
|
||||||
|
"allow_public_user_to_upload": "সাধারণ ব্যবহারকারী আপলোড করতে পারবে",
|
||||||
|
"anti_clockwise": "বিপরীত দিক",
|
||||||
|
"api_key": "API কী",
|
||||||
|
"api_key_description": "এই মান একবারই দেখানো হবে। উইন্ডো বন্ধ করার আগে অবশ্যই এটি কপি করুন।",
|
||||||
|
"api_key_empty": "API কী-এর নাম খালি রাখা যাবে না",
|
||||||
|
"api_keys": "API কী সমূহ",
|
||||||
|
"app_settings": "অ্যাপ সেটিংস",
|
||||||
|
"appears_in": "v1.106.4 থেকে, অ্যাসেট সাইডবারে ব্যবহার হয় ‘[albums]-এ উপস্থিত’ বোঝাতে",
|
||||||
|
"archive": "আর্কাইভ",
|
||||||
|
"archive_or_unarchive_photo": "ফটো আর্কাইভ অথবা আনআর্কাইভ করুন",
|
||||||
|
"archive_size": "আর্কাইভ সাইজ",
|
||||||
|
"archive_size_description": "ডাউনলোডের আর্কাইভ সাইজ নির্ধারণ করুন (GiB)",
|
||||||
|
"are_these_the_same_person": "এরা কি একই ব্যক্তি?",
|
||||||
|
"are_you_sure_to_do_this": "আপনি কি নিশ্চিত যে আপনি এটি করতে চান?",
|
||||||
|
"asset_added_to_album": "অ্যালবামে যুক্ত করা হয়েছে",
|
||||||
|
"asset_adding_to_album": "অ্যালবামে যুক্ত করা হচ্ছে…",
|
||||||
|
"asset_description_updated": "অ্যাসেটের বিবরণ আপডেট করা হয়েছে",
|
||||||
|
"asset_filename_is_offline": "{filename} অ্যাসেটটি বর্তমানে অফলাইন",
|
||||||
|
"asset_has_unassigned_faces": "অ্যাসেটটির কিছু মুখ অনির্ধারিত ফেস রয়েছে",
|
||||||
|
"asset_hashing": "হ্যাশিং চলছে…",
|
||||||
|
"asset_offline": "অ্যাসেট বর্তমানে অফলাইন",
|
||||||
|
"asset_offline_description": "এই এক্সটার্নাল অ্যাসেটটি এখন ডিস্কে নেই। সহায়তার জন্য Immich অ্যাডমিনিস্ট্রেটরের সাথে যোগাযোগ করুন।",
|
||||||
|
"asset_skipped": "এড়ানো হয়েছে",
|
||||||
|
"asset_skipped_in_trash": "ট্র্যাশে",
|
||||||
|
"asset_uploaded": "আপলোড সম্পন্ন",
|
||||||
|
"asset_uploading": "আপলোড চলছে…",
|
||||||
|
"assets": "অ্যাসেটসমূহ",
|
||||||
|
"assets_added_to_album_count": "অ্যালবামে {count, plural, one {# asset} other {# assets}} যুক্ত করা হয়েছে",
|
||||||
|
"assets_moved_to_trash_count": "{count, plural, one {# asset} other {# assets}} ট্র্যাশে সরানো হয়েছে",
|
||||||
|
"assets_permanently_deleted_count": "{count, plural, one {# asset} other {# assets}} স্থায়ীভাবে মুছে ফেলা হয়েছে",
|
||||||
|
"assets_removed_count": "{count, plural, one {# asset} other {# assets}} সরানো হয়েছে",
|
||||||
|
"assets_restore_confirmation": "আপনি কি সত্যিই আপনার সব ট্র্যাশ করা অ্যাসেট পুনরুদ্ধার করতে চান? এটি পূর্বাবস্থায় ফিরানো যাবে না। তবে অফলাইন অ্যাসেট এইভাবে পুনরুদ্ধার হবে না।",
|
||||||
|
"assets_restored_count": "{count, plural, one {# asset} other {# assets}} পুনরুদ্ধার করা হয়েছে",
|
||||||
|
"assets_trashed_count": "{count, plural, one {# asset} other {# assets}} ট্র্যাশে পাঠানো হয়েছে",
|
||||||
|
"assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} আগেই অ্যালবামে যুক্ত ছিল",
|
||||||
|
"authorized_devices": "অনুমোদিত ডিভাইস",
|
||||||
|
"back": "ফিরে যান",
|
||||||
|
"back_close_deselect": "ফিরে যান, বন্ধ করুন বা নির্বাচন বাতিল করুন",
|
||||||
|
"backward": "পিছনে",
|
||||||
|
"birthdate_saved": "জন্ম তারিখ সংরক্ষণ সম্পন্ন",
|
||||||
|
"birthdate_set_description": "একটি ছবির সময়ে ব্যক্তির বয়স গণনার জন্য জন্ম তারিখ ব্যবহার করা হয়।",
|
||||||
|
"blurred_background": "ব্লারড ব্যাকগ্রাউন্ড",
|
||||||
|
"bugs_and_feature_requests": "বাগ ও ফিচার রিকোয়েস্ট",
|
||||||
|
"build": "বিল্ড",
|
||||||
|
"build_image": "বিল্ড ইমেজ",
|
||||||
|
"bulk_delete_duplicates_confirmation": "আপনি কি সত্যিই {count, plural, one {# duplicate asset} other {# duplicate assets}} একসাথে মুছে ফেলতে চান? প্রতিটি গ্রুপের সবচেয়ে বড় অ্যাসেট রাখা হবে, বাকিগুলো স্থায়ীভাবে মুছে যাবে। এটি পূর্বাবস্থায় ফিরানো যাবে না!",
|
||||||
|
"bulk_keep_duplicates_confirmation": "আপনি কি সত্যিই {count, plural, one {# duplicate asset} other {# duplicate assets}} রাখতে চান? সব ডুপ্লিকেট গ্রুপ ঠিক করা হবে, কোনো কিছু মুছে ফেলা হবে না।",
|
||||||
|
"bulk_trash_duplicates_confirmation": "আপনি কি সত্যিই {count, plural, one {# duplicate asset} other {# duplicate assets}} একসাথে ট্র্যাশ করতে চান? প্রতিটি গ্রুপের সবচেয়ে বড় অ্যাসেট রাখা হবে, বাকিগুলো ট্র্যাশে যাবে।",
|
||||||
|
"buy": "Immich ক্রয় করুন",
|
||||||
|
"camera": "ক্যামেরা",
|
||||||
|
"camera_brand": "ক্যামেরা ব্র্যান্ড",
|
||||||
|
"camera_model": "ক্যামেরা মডেল",
|
||||||
|
"cancel": "বাতিল",
|
||||||
|
"cancel_search": "সার্চ বন্ধ করুন",
|
||||||
|
"cannot_merge_people": "ব্যক্তিদের একত্র করা সম্ভব নয়",
|
||||||
|
"cannot_undo_this_action": "এই কাজ পূর্বাবস্থায় ফেরানো যাবে না!",
|
||||||
|
"cannot_update_the_description": "বিবরণ পরিবর্তন সম্ভব নয়",
|
||||||
|
"change_date": "তারিখ পরিবর্তন",
|
||||||
|
"change_expiration_time": "মেয়াদ শেষের সময় পরিবর্তন",
|
||||||
|
"change_location": "লোকেশন পরিবর্তন",
|
||||||
|
"change_name": "নাম পরিবর্তন করুন",
|
||||||
|
"change_name_successfully": "নাম সফলভাবে পরিবর্তন হয়েছে",
|
||||||
|
"change_password": "পাসওয়ার্ড পরিবর্তন করুন",
|
||||||
|
"change_password_description": "আপনি হয়তো প্রথমবার লগইন করছেন বা পাসওয়ার্ড পরিবর্তনের অনুরোধ করেছেন। নিচে নতুন পাসওয়ার্ড দিন।",
|
||||||
|
"change_your_password": "আপনার পাসওয়ার্ড পরিবর্তন করুন",
|
||||||
|
"changed_visibility_successfully": "ভিসিবিলিটি সফলভাবে পরিবর্তন হয়েছে",
|
||||||
|
"check_logs": "লগ দেখুন",
|
||||||
|
"choose_matching_people_to_merge": "একত্র করার জন্য মিল থাকা ব্যক্তিদের নির্বাচন করুন",
|
||||||
|
"city": "শহর",
|
||||||
|
"clear": "মুছুন",
|
||||||
|
"clear_all": "সব মুছুন",
|
||||||
|
"clear_all_recent_searches": "সাম্প্রতিক সব অনুসন্ধান পরিষ্কার করুন",
|
||||||
|
"clear_message": "মেসেজ পরিষ্কার করুন",
|
||||||
|
"clear_value": "ভ্যালু মুছুন",
|
||||||
|
"clockwise": "ঘড়ির কাঁটার দিকে",
|
||||||
|
"close": "বন্ধ",
|
||||||
|
"collapse": "সংকুচিত করুন",
|
||||||
|
"collapse_all": "সব সংকুচিত",
|
||||||
|
"color": "রং",
|
||||||
|
"color_theme": "কালার থিম",
|
||||||
|
"comment_deleted": "মন্তব্য মুছে ফেলা হয়েছে",
|
||||||
|
"comment_options": "মন্তব্য অপশন",
|
||||||
|
"comments_and_likes": "মন্তব্য ও লাইক",
|
||||||
|
"comments_are_disabled": "মন্তব্য বন্ধ করা হয়েছে",
|
||||||
|
"confirm": "নিশ্চিত",
|
||||||
|
"confirm_admin_password": "অ্যাডমিন পাসওয়ার্ড পুনরায় লিখুন",
|
||||||
|
"confirm_delete_shared_link": "আপনি কি নিশ্চিত যে আপনি এই শেয়ার করা লিঙ্কটি মুছে ফেলতে চান?",
|
||||||
|
"confirm_keep_this_delete_others": "স্ট্যাকের এই অ্যাসেট ছাড়া সব অন্যান্য অ্যাসেট মুছে যাবে। আপনি কি নিশ্চিত যে আপনি চালিয়ে যেতে চান?",
|
||||||
|
"confirm_password": "পাসওয়ার্ড পুনরায় লিখুন",
|
||||||
|
"contain": "মাপমত",
|
||||||
|
"context": "প্রসঙ্গ",
|
||||||
|
"continue": "এগিয়ে যান",
|
||||||
|
"copied_image_to_clipboard": "ছবি ক্লিপবোর্ডে কপি হয়েছে।",
|
||||||
|
"copied_to_clipboard": "ক্লিপবোর্ডে কপি হয়েছে!",
|
||||||
|
"copy_error": "Error-টি কপি করুন",
|
||||||
|
"copy_file_path": "ফাইল পাথ কপি",
|
||||||
|
"copy_image": "ছবি কপি",
|
||||||
|
"copy_link": "লিঙ্ক কপি",
|
||||||
|
"copy_link_to_clipboard": "ক্লিপবোর্ডে লিঙ্ক কপি করুন",
|
||||||
|
"copy_password": "পাসওয়ার্ড কপি করুন",
|
||||||
|
"copy_to_clipboard": "ক্লিপবোর্ডে কপি করুন",
|
||||||
|
"country": "দেশ",
|
||||||
|
"cover": "সম্পূর্ণভাবে",
|
||||||
|
"covers": "কভারস",
|
||||||
|
"create": "তৈরি করুন",
|
||||||
|
"create_album": "অ্যালবাম তৈরি",
|
||||||
|
"create_library": "লাইব্রেরি তৈরি",
|
||||||
|
"create_link": "লিঙ্ক তৈরি",
|
||||||
|
"create_link_to_share": "শেয়ার লিঙ্ক তৈরি",
|
||||||
|
"create_link_to_share_description": "লিঙ্কের মাধ্যমে সবাই নির্বাচিত ছবি দেখতে পারবে",
|
||||||
|
"create_new_person": "নতুন ব্যক্তি যোগ করুন",
|
||||||
|
"create_new_person_hint": "নির্বাচিত অ্যাসেট নতুন ব্যক্তির সঙ্গে যুক্ত করুন",
|
||||||
|
"create_new_user": "নতুন ব্যবহারকারী যোগ করুন",
|
||||||
|
"create_tag": "ট্যাগ তৈরি",
|
||||||
|
"create_tag_description": "নতুন ট্যাগ তৈরি করুন। নেস্টেড ট্যাগের ক্ষেত্রে সম্পূর্ণ পাথ - ফরওয়ার্ড স্ল্যাশসহ দিন।",
|
||||||
|
"create_user": "ব্যবহারকারী যোগ করুন",
|
||||||
|
"created": "যোগ করা হয়েছে",
|
||||||
|
"current_device": "চলতি ডিভাইস",
|
||||||
|
"custom_locale": "কাস্টম লোকেল",
|
||||||
|
"custom_locale_description": "নির্বাচিত ভাষা এবং অঞ্চলের ভিত্তিতে তারিখ, সময় এবং সংখ্যা ফরম্যাট করুন",
|
||||||
|
"dark": "ডার্ক",
|
||||||
|
"date_after": "এর পরের তারিখ",
|
||||||
|
"date_and_time": "তারিখ এবং সময়",
|
||||||
|
"date_before": "এর আগের তারিখ",
|
||||||
|
"date_of_birth_saved": "জন্ম তারিখ সফলভাবে সংরক্ষণ করা হয়েছে",
|
||||||
|
"delete": "মুছুন",
|
||||||
|
"delete_album": "অ্যালবাম মুছুন",
|
||||||
|
"delete_api_key_prompt": "আপনি কি সত্যিই এই API key মুছে ফেলতে চান?",
|
||||||
|
"delete_duplicates_confirmation": "আপনি কি সত্যিই এই ডুপ্লিকেটগুলো স্থায়ীভাবে মুছতে চান?",
|
||||||
|
"delete_key": "key মুছুন",
|
||||||
|
"delete_library": "লাইব্রেরি মুছুন",
|
||||||
|
"delete_link": "লিঙ্ক মুছুন",
|
||||||
|
"delete_others": "বাকিগুলো মুছুন",
|
||||||
|
"delete_shared_link": "শেয়ার করা লিঙ্ক মুছুন",
|
||||||
|
"delete_tag": "ট্যাগ মুছুন",
|
||||||
|
"delete_tag_confirmation_prompt": "আপনি কি নিশ্চিতভাবে {tagName} ট্যাগটি মুছতে চান?",
|
||||||
|
"delete_user": "ইউজার মুছুন",
|
||||||
|
"deleted_shared_link": "শেয়ার করা লিঙ্কটি মুছুন",
|
||||||
|
"deletes_missing_assets": "ডিস্ক থেকে হারানো অ্যাসেটগুলো মুছে",
|
||||||
|
"description": "বিবরন",
|
||||||
|
"details": "বিস্তারিত",
|
||||||
|
"direction": "দিকনির্দেশনা",
|
||||||
|
"disabled": "নিষ্ক্রিয়",
|
||||||
|
"disallow_edits": "সম্পাদনা করার অনুমতি দেবেন না",
|
||||||
|
"discord": "ডিসকর্ড",
|
||||||
|
"discover": "ডিসকভার",
|
||||||
|
"dismiss_all_errors": "সব ত্রুটি বাতিল করুন",
|
||||||
|
"dismiss_error": "ত্রুটি বাতিল করুন",
|
||||||
|
"display_options": "ডিসপ্লে অপশন",
|
||||||
|
"display_order": "ডিসপ্লে অর্ডার",
|
||||||
|
"display_original_photos": "অরিজিনাল ছবি দেখান",
|
||||||
|
"display_original_photos_setting_description": "অরিজিনাল অ্যাসেটটি ওয়েব-সামঞ্জস্যপূর্ণ (web-compatible) হলে অ্যাসেট দেখার সময় থাম্বনেইলের পরিবর্তে মূল ফটোটি প্রদর্শন করতে অগ্রাধিকার দিন। এর ফলে ফটো প্রদর্শনের গতি কিছুটা ধীর হতে পারে।",
|
||||||
|
"do_not_show_again": "এই মেসেজটি আর দেখাবেন না",
|
||||||
|
"documentation": "সহায়ক নির্দেশিকা",
|
||||||
|
"done": "সম্পন্ন",
|
||||||
|
"download": "ডাউনলোড",
|
||||||
|
"download_include_embedded_motion_videos": "এমবেডেড ভিডিও",
|
||||||
|
"download_include_embedded_motion_videos_description": "মোশন ফটোর (motion photos) মধ্যে থাকা ভিডিওগুলোকে আলাদা ফাইল হিসেবে অন্তর্ভুক্ত করুন",
|
||||||
|
"download_settings": "ডাউনলোড",
|
||||||
|
"download_settings_description": "অ্যাসেট ডাউনলোডের সেটিংস পরিচালনা করুন",
|
||||||
|
"open_in_browser": "ব্রাউজারে ওপেন করুন",
|
||||||
"user_usage_stats": "অ্যাকাউন্ট ব্যবহারের পরিসংখ্যান",
|
"user_usage_stats": "অ্যাকাউন্ট ব্যবহারের পরিসংখ্যান",
|
||||||
"user_usage_stats_description": "অ্যাকাউন্ট ব্যবহারের পরিসংখ্যান দেখুন",
|
"user_usage_stats_description": "অ্যাকাউন্ট ব্যবহারের পরিসংখ্যান দেখুন",
|
||||||
"yes": "হ্যাঁ",
|
"yes": "হ্যাঁ",
|
||||||
|
|||||||
+19
-10
@@ -372,7 +372,7 @@
|
|||||||
"transcoding_audio_codec": "Còdec d'àudio",
|
"transcoding_audio_codec": "Còdec d'àudio",
|
||||||
"transcoding_audio_codec_description": "Opus és l'opció de màxima qualitat, però té menor compatibilitat amb dispositius o programari antics.",
|
"transcoding_audio_codec_description": "Opus és l'opció de màxima qualitat, però té menor compatibilitat amb dispositius o programari antics.",
|
||||||
"transcoding_bitrate_description": "Vídeos superiors a la taxa de bits màxima o que no tenen un format acceptat",
|
"transcoding_bitrate_description": "Vídeos superiors a la taxa de bits màxima o que no tenen un format acceptat",
|
||||||
"transcoding_codecs_learn_more": "Per obtenir més informació sobre la terminologia utilitzada, consulteu la documentació de FFmpeg per al <h264-link> còdec H.264</h264-link>, <hevc-link> còdec HEVC</hevc-link> i <vp9-link> còdec VP9</vp9-link>.",
|
"transcoding_codecs_learn_more": "Per obtenir més informació sobre la terminologia utilitzada, consulteu la documentació de FFmpeg per al <h264-link>còdec H.264</h264-link>, <hevc-link>còdec HEVC</hevc-link> i <vp9-link>còdec VP9</vp9-link>.",
|
||||||
"transcoding_constant_quality_mode": "Mode de qualitat constant",
|
"transcoding_constant_quality_mode": "Mode de qualitat constant",
|
||||||
"transcoding_constant_quality_mode_description": "ICQ és millor que CQP, però alguns dispositius d'acceleració de maquinari no admeten aquest mode. Establir aquesta opció preferirà el mode especificat quan utilitzeu la codificació basada en la qualitat. Ignorat per NVENC perquè no és compatible amb ICQ.",
|
"transcoding_constant_quality_mode_description": "ICQ és millor que CQP, però alguns dispositius d'acceleració de maquinari no admeten aquest mode. Establir aquesta opció preferirà el mode especificat quan utilitzeu la codificació basada en la qualitat. Ignorat per NVENC perquè no és compatible amb ICQ.",
|
||||||
"transcoding_constant_rate_factor": "Factor de taxa constant (-crf)",
|
"transcoding_constant_rate_factor": "Factor de taxa constant (-crf)",
|
||||||
@@ -441,7 +441,7 @@
|
|||||||
"user_successfully_removed": "L'usuari {email} s'ha eliminat correctament.",
|
"user_successfully_removed": "L'usuari {email} s'ha eliminat correctament.",
|
||||||
"users_page_description": "Pàgina d'usuaris de l'administrador",
|
"users_page_description": "Pàgina d'usuaris de l'administrador",
|
||||||
"version_check_enabled_description": "Activa la comprovació de la versió",
|
"version_check_enabled_description": "Activa la comprovació de la versió",
|
||||||
"version_check_implications": "La funció de comprovació de versions depèn de comunicacions periòdiques amb github.com",
|
"version_check_implications": "La funció de comprovació de versions depèn de comunicacions periòdiques amb {server}",
|
||||||
"version_check_settings": "Comprovació de versió",
|
"version_check_settings": "Comprovació de versió",
|
||||||
"version_check_settings_description": "Activa/desactiva la notificació de nova versió",
|
"version_check_settings_description": "Activa/desactiva la notificació de nova versió",
|
||||||
"video_conversion_job": "Transcodificació de vídeos",
|
"video_conversion_job": "Transcodificació de vídeos",
|
||||||
@@ -849,9 +849,12 @@
|
|||||||
"create_link_to_share": "Crear enllaç per compartir",
|
"create_link_to_share": "Crear enllaç per compartir",
|
||||||
"create_link_to_share_description": "Deixa que qualsevol persona amb l'enllaç vegi les fotos seleccionades",
|
"create_link_to_share_description": "Deixa que qualsevol persona amb l'enllaç vegi les fotos seleccionades",
|
||||||
"create_new": "CREAR NOU",
|
"create_new": "CREAR NOU",
|
||||||
|
"create_new_face": "Crea una nova cara",
|
||||||
"create_new_person": "Crea una nova persona",
|
"create_new_person": "Crea una nova persona",
|
||||||
"create_new_person_hint": "Assigna els elements seleccionats a una persona nova",
|
"create_new_person_hint": "Assigna els elements seleccionats a una persona nova",
|
||||||
"create_new_user": "Crea un usuari nou",
|
"create_new_user": "Crea un usuari nou",
|
||||||
|
"create_person": "Crea una persona",
|
||||||
|
"create_person_subtitle": "Afegeix un nom a la cara seleccionada per crear i etiquetar la nova persona",
|
||||||
"create_shared_album_page_share_add_assets": "AFEGEIX ELEMENTS",
|
"create_shared_album_page_share_add_assets": "AFEGEIX ELEMENTS",
|
||||||
"create_shared_album_page_share_select_photos": "Escull fotografies",
|
"create_shared_album_page_share_select_photos": "Escull fotografies",
|
||||||
"create_shared_link": "Crea un enllaç compartit",
|
"create_shared_link": "Crea un enllaç compartit",
|
||||||
@@ -866,6 +869,7 @@
|
|||||||
"crop_aspect_ratio_fixed": "Fixat",
|
"crop_aspect_ratio_fixed": "Fixat",
|
||||||
"crop_aspect_ratio_free": "Lliure",
|
"crop_aspect_ratio_free": "Lliure",
|
||||||
"crop_aspect_ratio_original": "Original",
|
"crop_aspect_ratio_original": "Original",
|
||||||
|
"crop_aspect_ratio_square": "Quadrat",
|
||||||
"curated_object_page_title": "Coses",
|
"curated_object_page_title": "Coses",
|
||||||
"current_device": "Dispositiu actual",
|
"current_device": "Dispositiu actual",
|
||||||
"current_pin_code": "Codi PIN actual",
|
"current_pin_code": "Codi PIN actual",
|
||||||
@@ -880,7 +884,7 @@
|
|||||||
"daily_title_text_date": "E, dd MMM",
|
"daily_title_text_date": "E, dd MMM",
|
||||||
"daily_title_text_date_year": "E, dd MMM, yyyy",
|
"daily_title_text_date_year": "E, dd MMM, yyyy",
|
||||||
"dark": "Fosc",
|
"dark": "Fosc",
|
||||||
"dark_theme": "Canviar a tema fosc",
|
"dark_theme": "Canvia a tema fosc",
|
||||||
"date": "Data",
|
"date": "Data",
|
||||||
"date_after": "Data posterior a",
|
"date_after": "Data posterior a",
|
||||||
"date_and_time": "Data i hora",
|
"date_and_time": "Data i hora",
|
||||||
@@ -891,10 +895,8 @@
|
|||||||
"day": "Dia",
|
"day": "Dia",
|
||||||
"days": "Dies",
|
"days": "Dies",
|
||||||
"deduplicate_all": "Desduplica-ho tot",
|
"deduplicate_all": "Desduplica-ho tot",
|
||||||
"deduplication_criteria_1": "Mida d'imatge en bytes",
|
"default_locale": "Configuració regional predeterminada",
|
||||||
"deduplication_criteria_2": "Quantitat de dades EXIF",
|
"default_locale_description": "Format de dades i números en funció de la configuració local",
|
||||||
"deduplication_info": "Informació de deduplicació",
|
|
||||||
"deduplication_info_description": "Per preseleccionar recursos automàticament i eliminar els duplicats de manera massiva, ens fixem en:",
|
|
||||||
"delete": "Esborrar",
|
"delete": "Esborrar",
|
||||||
"delete_action_confirmation_message": "Segur que vols eliminar aquest recurs? Aquesta acció el mourà a la paperera del servidor, i et preguntarà si el vols eliminar localment",
|
"delete_action_confirmation_message": "Segur que vols eliminar aquest recurs? Aquesta acció el mourà a la paperera del servidor, i et preguntarà si el vols eliminar localment",
|
||||||
"delete_action_prompt": "{count} eliminats",
|
"delete_action_prompt": "{count} eliminats",
|
||||||
@@ -970,7 +972,7 @@
|
|||||||
"downloading_media": "Descàrrega multimèdia",
|
"downloading_media": "Descàrrega multimèdia",
|
||||||
"drop_files_to_upload": "Deixeu els fitxers a qualsevol lloc per pujar-los",
|
"drop_files_to_upload": "Deixeu els fitxers a qualsevol lloc per pujar-los",
|
||||||
"duplicates": "Duplicats",
|
"duplicates": "Duplicats",
|
||||||
"duplicates_description": "Resol cada grup indicant, si n'hi ha, quins són duplicats",
|
"duplicates_description": "Resol cada grup indicant, si n'hi ha, quins són duplicats.",
|
||||||
"duration": "Durada",
|
"duration": "Durada",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"edit_album": "Edita l'àlbum",
|
"edit_album": "Edita l'àlbum",
|
||||||
@@ -992,7 +994,7 @@
|
|||||||
"edit_location_dialog_title": "Ubicació",
|
"edit_location_dialog_title": "Ubicació",
|
||||||
"edit_name": "Edita el nom",
|
"edit_name": "Edita el nom",
|
||||||
"edit_people": "Edita la gent",
|
"edit_people": "Edita la gent",
|
||||||
"edit_tag": "Editar etiqueta",
|
"edit_tag": "Edita etiqueta",
|
||||||
"edit_title": "Edita títol",
|
"edit_title": "Edita títol",
|
||||||
"edit_user": "Edita l'usuari",
|
"edit_user": "Edita l'usuari",
|
||||||
"edit_workflow": "Edita el flux de treball",
|
"edit_workflow": "Edita el flux de treball",
|
||||||
@@ -1007,6 +1009,8 @@
|
|||||||
"editor_edits_applied_success": "Les modificacions s'han aplicat correctament",
|
"editor_edits_applied_success": "Les modificacions s'han aplicat correctament",
|
||||||
"editor_flip_horizontal": "Capgira horitzontalment",
|
"editor_flip_horizontal": "Capgira horitzontalment",
|
||||||
"editor_flip_vertical": "Capgira verticalment",
|
"editor_flip_vertical": "Capgira verticalment",
|
||||||
|
"editor_handle_corner": "{corner, select, top_left {Top-left} top_right {Top-right} bottom_left {Bottom-left} bottom_right {Bottom-right} other {A}} cantó per agafar",
|
||||||
|
"editor_handle_edge": "{edge, select, top {Top} bottom {Bottom} left {Left} right {Right} other {An}} cantó per agafar",
|
||||||
"editor_orientation": "Orientació",
|
"editor_orientation": "Orientació",
|
||||||
"editor_reset_all_changes": "Reiniciar canvis",
|
"editor_reset_all_changes": "Reiniciar canvis",
|
||||||
"editor_rotate_left": "Rota 90º al contrari de les agulles",
|
"editor_rotate_left": "Rota 90º al contrari de les agulles",
|
||||||
@@ -1168,7 +1172,7 @@
|
|||||||
"exif_bottom_sheet_description_error": "No s'ha pogut actualitzar la descripció",
|
"exif_bottom_sheet_description_error": "No s'ha pogut actualitzar la descripció",
|
||||||
"exif_bottom_sheet_details": "DETALLS",
|
"exif_bottom_sheet_details": "DETALLS",
|
||||||
"exif_bottom_sheet_location": "UBICACIÓ",
|
"exif_bottom_sheet_location": "UBICACIÓ",
|
||||||
"exif_bottom_sheet_no_description": "Sense descrioció",
|
"exif_bottom_sheet_no_description": "Sense descripció",
|
||||||
"exif_bottom_sheet_people": "PERSONES",
|
"exif_bottom_sheet_people": "PERSONES",
|
||||||
"exif_bottom_sheet_person_add_person": "Afegir nom",
|
"exif_bottom_sheet_person_add_person": "Afegir nom",
|
||||||
"exit_slideshow": "Surt de la presentació de diapositives",
|
"exit_slideshow": "Surt de la presentació de diapositives",
|
||||||
@@ -1385,9 +1389,11 @@
|
|||||||
"library_page_sort_title": "Títol de l'àlbum",
|
"library_page_sort_title": "Títol de l'àlbum",
|
||||||
"licenses": "Llicències",
|
"licenses": "Llicències",
|
||||||
"light": "Llum",
|
"light": "Llum",
|
||||||
|
"light_theme": "Canviar a tema clar",
|
||||||
"like": "M'agrada",
|
"like": "M'agrada",
|
||||||
"like_deleted": "M'agrada suprimit",
|
"like_deleted": "M'agrada suprimit",
|
||||||
"link_motion_video": "Enllaçar vídeo en moviment",
|
"link_motion_video": "Enllaçar vídeo en moviment",
|
||||||
|
"link_to_docs": "Per més informació, mirar la <link>documentation</link>.",
|
||||||
"link_to_oauth": "Enllaç a OAuth",
|
"link_to_oauth": "Enllaç a OAuth",
|
||||||
"linked_oauth_account": "Compte OAuth enllaçat",
|
"linked_oauth_account": "Compte OAuth enllaçat",
|
||||||
"list": "Llista",
|
"list": "Llista",
|
||||||
@@ -1649,6 +1655,7 @@
|
|||||||
"only_favorites": "Només preferits",
|
"only_favorites": "Només preferits",
|
||||||
"open": "Obrir",
|
"open": "Obrir",
|
||||||
"open_calendar": "Obrir el calendari",
|
"open_calendar": "Obrir el calendari",
|
||||||
|
"open_in_browser": "Obre al navegador",
|
||||||
"open_in_map_view": "Obrir a la vista del mapa",
|
"open_in_map_view": "Obrir a la vista del mapa",
|
||||||
"open_in_openstreetmap": "Obre a OpenStreetMap",
|
"open_in_openstreetmap": "Obre a OpenStreetMap",
|
||||||
"open_the_search_filters": "Obriu els filtres de cerca",
|
"open_the_search_filters": "Obriu els filtres de cerca",
|
||||||
@@ -2210,6 +2217,7 @@
|
|||||||
"tag": "Etiqueta",
|
"tag": "Etiqueta",
|
||||||
"tag_assets": "Etiquetar actius",
|
"tag_assets": "Etiquetar actius",
|
||||||
"tag_created": "Etiqueta creada: {tag}",
|
"tag_created": "Etiqueta creada: {tag}",
|
||||||
|
"tag_face": "Etiqueta una cara",
|
||||||
"tag_feature_description": "Exploreu fotos i vídeos agrupats per temes d'etiquetes lògiques",
|
"tag_feature_description": "Exploreu fotos i vídeos agrupats per temes d'etiquetes lògiques",
|
||||||
"tag_not_found_question": "No trobeu una etiqueta? <link>Crear una nova etiqueta.</link>",
|
"tag_not_found_question": "No trobeu una etiqueta? <link>Crear una nova etiqueta.</link>",
|
||||||
"tag_people": "Etiquetar personas",
|
"tag_people": "Etiquetar personas",
|
||||||
@@ -2391,6 +2399,7 @@
|
|||||||
"viewer_remove_from_stack": "Elimina de la pila",
|
"viewer_remove_from_stack": "Elimina de la pila",
|
||||||
"viewer_stack_use_as_main_asset": "Fes servir com a element principal",
|
"viewer_stack_use_as_main_asset": "Fes servir com a element principal",
|
||||||
"viewer_unstack": "Desapila",
|
"viewer_unstack": "Desapila",
|
||||||
|
"visibility": "Visibilitat",
|
||||||
"visibility_changed": "La visibilitat ha canviat per {count, plural, one {# persona} other {# persones}}",
|
"visibility_changed": "La visibilitat ha canviat per {count, plural, one {# persona} other {# persones}}",
|
||||||
"visual": "Visual",
|
"visual": "Visual",
|
||||||
"visual_builder": "Constructor visual",
|
"visual_builder": "Constructor visual",
|
||||||
|
|||||||
+13
-7
@@ -441,7 +441,7 @@
|
|||||||
"user_successfully_removed": "Uživatel {email} byl úspěšně odstraněn.",
|
"user_successfully_removed": "Uživatel {email} byl úspěšně odstraněn.",
|
||||||
"users_page_description": "Stránka správců",
|
"users_page_description": "Stránka správců",
|
||||||
"version_check_enabled_description": "Povolit kontrolu verzí",
|
"version_check_enabled_description": "Povolit kontrolu verzí",
|
||||||
"version_check_implications": "Kontrola verze je založena na pravidelné komunikaci s github.com",
|
"version_check_implications": "Kontrola verze je založena na pravidelné komunikaci s {server}",
|
||||||
"version_check_settings": "Kontrola verze",
|
"version_check_settings": "Kontrola verze",
|
||||||
"version_check_settings_description": "Povolení/zakázání oznámení o nové verzi",
|
"version_check_settings_description": "Povolení/zakázání oznámení o nové verzi",
|
||||||
"video_conversion_job": "Překódování videí",
|
"video_conversion_job": "Překódování videí",
|
||||||
@@ -849,9 +849,12 @@
|
|||||||
"create_link_to_share": "Vytvořit odkaz pro sdílení",
|
"create_link_to_share": "Vytvořit odkaz pro sdílení",
|
||||||
"create_link_to_share_description": "Umožnit každému, kdo má odkaz, zobrazit vybrané fotografie",
|
"create_link_to_share_description": "Umožnit každému, kdo má odkaz, zobrazit vybrané fotografie",
|
||||||
"create_new": "VYTVOŘIT NOVÉ",
|
"create_new": "VYTVOŘIT NOVÉ",
|
||||||
|
"create_new_face": "Vytvořit nový obličej",
|
||||||
"create_new_person": "Vytvořit novou osobu",
|
"create_new_person": "Vytvořit novou osobu",
|
||||||
"create_new_person_hint": "Přiřadit vybrané položky nové osobě",
|
"create_new_person_hint": "Přiřadit vybrané položky nové osobě",
|
||||||
"create_new_user": "Vytvořit nového uživatele",
|
"create_new_user": "Vytvořit nového uživatele",
|
||||||
|
"create_person": "Vytvořit osobu",
|
||||||
|
"create_person_subtitle": "Přidejte jméno ke zvolenému obličeji pro vytvoření a označení nové osoby",
|
||||||
"create_shared_album_page_share_add_assets": "PŘIDAT POLOŽKY",
|
"create_shared_album_page_share_add_assets": "PŘIDAT POLOŽKY",
|
||||||
"create_shared_album_page_share_select_photos": "Vybrat fotografie",
|
"create_shared_album_page_share_select_photos": "Vybrat fotografie",
|
||||||
"create_shared_link": "Vytvořit sdílený odkaz",
|
"create_shared_link": "Vytvořit sdílený odkaz",
|
||||||
@@ -866,6 +869,7 @@
|
|||||||
"crop_aspect_ratio_fixed": "Pevný",
|
"crop_aspect_ratio_fixed": "Pevný",
|
||||||
"crop_aspect_ratio_free": "Volný",
|
"crop_aspect_ratio_free": "Volný",
|
||||||
"crop_aspect_ratio_original": "Původní",
|
"crop_aspect_ratio_original": "Původní",
|
||||||
|
"crop_aspect_ratio_square": "Čtverec",
|
||||||
"curated_object_page_title": "Věci",
|
"curated_object_page_title": "Věci",
|
||||||
"current_device": "Současné zařízení",
|
"current_device": "Současné zařízení",
|
||||||
"current_pin_code": "Aktuální PIN kód",
|
"current_pin_code": "Aktuální PIN kód",
|
||||||
@@ -880,7 +884,7 @@
|
|||||||
"daily_title_text_date": "EEEE, d. MMMM",
|
"daily_title_text_date": "EEEE, d. MMMM",
|
||||||
"daily_title_text_date_year": "EEEE, d. MMMM y",
|
"daily_title_text_date_year": "EEEE, d. MMMM y",
|
||||||
"dark": "Tmavý",
|
"dark": "Tmavý",
|
||||||
"dark_theme": "Přepnout tmavý motiv",
|
"dark_theme": "Přepnout na tmavý motiv",
|
||||||
"date": "Datum",
|
"date": "Datum",
|
||||||
"date_after": "Datum po",
|
"date_after": "Datum po",
|
||||||
"date_and_time": "Datum a čas",
|
"date_and_time": "Datum a čas",
|
||||||
@@ -891,10 +895,8 @@
|
|||||||
"day": "Den",
|
"day": "Den",
|
||||||
"days": "Dnů",
|
"days": "Dnů",
|
||||||
"deduplicate_all": "Odstranit všechny duplicity",
|
"deduplicate_all": "Odstranit všechny duplicity",
|
||||||
"deduplication_criteria_1": "Velikost obrázku v bajtech",
|
"default_locale": "Výchozí národní prostředí",
|
||||||
"deduplication_criteria_2": "Počet EXIF dat",
|
"default_locale_description": "Formátování datumu a čísel podle místního nastavení prohlížeče",
|
||||||
"deduplication_info": "Informace o deduplikaci",
|
|
||||||
"deduplication_info_description": "Pro automatický předvýběr položek a hromadné odstranění duplicit se zohledňuje:",
|
|
||||||
"delete": "Smazat",
|
"delete": "Smazat",
|
||||||
"delete_action_confirmation_message": "Opravdu chcete odstranit tuto položku? Tato akce přesune položku do serverového koše a zeptá se vás, zda ji chcete odstranit lokálně",
|
"delete_action_confirmation_message": "Opravdu chcete odstranit tuto položku? Tato akce přesune položku do serverového koše a zeptá se vás, zda ji chcete odstranit lokálně",
|
||||||
"delete_action_prompt": "{count} smazáno",
|
"delete_action_prompt": "{count} smazáno",
|
||||||
@@ -970,7 +972,7 @@
|
|||||||
"downloading_media": "Stahování média",
|
"downloading_media": "Stahování média",
|
||||||
"drop_files_to_upload": "Pro nahrání sem přetáhněte soubory",
|
"drop_files_to_upload": "Pro nahrání sem přetáhněte soubory",
|
||||||
"duplicates": "Duplicity",
|
"duplicates": "Duplicity",
|
||||||
"duplicates_description": "Vyřešte každou skupinu tak, že uvedete, které skupiny jsou duplicitní",
|
"duplicates_description": "Vyřešte každou skupinu tak, že uvedete, které skupiny jsou duplicitní.",
|
||||||
"duration": "Doba trvání",
|
"duration": "Doba trvání",
|
||||||
"edit": "Upravit",
|
"edit": "Upravit",
|
||||||
"edit_album": "Upravit album",
|
"edit_album": "Upravit album",
|
||||||
@@ -1387,9 +1389,11 @@
|
|||||||
"library_page_sort_title": "Podle názvu alba",
|
"library_page_sort_title": "Podle názvu alba",
|
||||||
"licenses": "Licence",
|
"licenses": "Licence",
|
||||||
"light": "Světlý",
|
"light": "Světlý",
|
||||||
|
"light_theme": "Přepnout na světlý motiv",
|
||||||
"like": "Líbí se mi",
|
"like": "Líbí se mi",
|
||||||
"like_deleted": "Oblíbení smazáno",
|
"like_deleted": "Oblíbení smazáno",
|
||||||
"link_motion_video": "Připojit pohyblivé video",
|
"link_motion_video": "Připojit pohyblivé video",
|
||||||
|
"link_to_docs": "Další informace najdete v <link>dokumentaci</link>.",
|
||||||
"link_to_oauth": "Propojit s OAuth",
|
"link_to_oauth": "Propojit s OAuth",
|
||||||
"linked_oauth_account": "Propojený OAuth účet",
|
"linked_oauth_account": "Propojený OAuth účet",
|
||||||
"list": "Seznam",
|
"list": "Seznam",
|
||||||
@@ -2213,6 +2217,7 @@
|
|||||||
"tag": "Značka",
|
"tag": "Značka",
|
||||||
"tag_assets": "Přiřadit značku",
|
"tag_assets": "Přiřadit značku",
|
||||||
"tag_created": "Vytvořena značka: {tag}",
|
"tag_created": "Vytvořena značka: {tag}",
|
||||||
|
"tag_face": "Označit obličej",
|
||||||
"tag_feature_description": "Procházení fotografií a videí seskupených podle témat logických značek",
|
"tag_feature_description": "Procházení fotografií a videí seskupených podle témat logických značek",
|
||||||
"tag_not_found_question": "Nemůžete najít značku? <link>Vytvořte novou.</link>",
|
"tag_not_found_question": "Nemůžete najít značku? <link>Vytvořte novou.</link>",
|
||||||
"tag_people": "Označit lidi",
|
"tag_people": "Označit lidi",
|
||||||
@@ -2394,6 +2399,7 @@
|
|||||||
"viewer_remove_from_stack": "Odstranit ze seskupení",
|
"viewer_remove_from_stack": "Odstranit ze seskupení",
|
||||||
"viewer_stack_use_as_main_asset": "Použít jako hlavní položku",
|
"viewer_stack_use_as_main_asset": "Použít jako hlavní položku",
|
||||||
"viewer_unstack": "Zrušit seskupení",
|
"viewer_unstack": "Zrušit seskupení",
|
||||||
|
"visibility": "Viditelnost",
|
||||||
"visibility_changed": "Viditelnost změněna u {count, plural, one {# osoby} few {# osob} other {# lidí}}",
|
"visibility_changed": "Viditelnost změněna u {count, plural, one {# osoby} few {# osob} other {# lidí}}",
|
||||||
"visual": "Vizuální",
|
"visual": "Vizuální",
|
||||||
"visual_builder": "Vizuální návrhář",
|
"visual_builder": "Vizuální návrhář",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user