mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1811d42d79 |
@@ -6,12 +6,6 @@ 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.15.0
|
24.13.1
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
name: Auto-close PRs
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
|
||||||
types: [opened, edited, labeled]
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
parse_template:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ github.event.action != 'labeled' && github.event.pull_request.head.repo.fork == true }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
outputs:
|
|
||||||
uses_template: ${{ steps.check.outputs.uses_template }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
sparse-checkout: .github/pull_request_template.md
|
|
||||||
sparse-checkout-cone-mode: false
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Check required sections
|
|
||||||
id: check
|
|
||||||
env:
|
|
||||||
BODY: ${{ github.event.pull_request.body }}
|
|
||||||
run: |
|
|
||||||
OK=true
|
|
||||||
while IFS= read -r header; do
|
|
||||||
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
|
|
||||||
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
|
|
||||||
echo "uses_template=$OK" | tee --append "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
close_template:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: parse_template
|
|
||||||
if: >-
|
|
||||||
${{
|
|
||||||
needs.parse_template.outputs.uses_template == 'false'
|
|
||||||
&& github.event.pull_request.state != 'closed'
|
|
||||||
&& !contains(github.event.pull_request.labels.*.name, 'auto-closed:template')
|
|
||||||
}}
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- name: Comment and close
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
|
||||||
run: |
|
|
||||||
gh api graphql \
|
|
||||||
-f prId="$NODE_ID" \
|
|
||||||
-f body="This PR has been automatically closed as the description doesn't follow [our template](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='
|
|
||||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
|
||||||
addComment(input: {
|
|
||||||
subjectId: $prId,
|
|
||||||
body: $body
|
|
||||||
}) {
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
closePullRequest(input: {
|
|
||||||
pullRequestId: $prId
|
|
||||||
}) {
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
|
|
||||||
- name: Add label
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "auto-closed:template"
|
|
||||||
|
|
||||||
close_llm:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ github.event.action == 'labeled' && github.event.label.name == 'auto-closed:llm' }}
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- name: Comment and close
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
|
||||||
run: |
|
|
||||||
gh api graphql \
|
|
||||||
-f prId="$NODE_ID" \
|
|
||||||
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
|
|
||||||
-f query='
|
|
||||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
|
||||||
addComment(input: {
|
|
||||||
subjectId: $prId,
|
|
||||||
body: $body
|
|
||||||
}) {
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
closePullRequest(input: {
|
|
||||||
pullRequestId: $prId
|
|
||||||
}) {
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
|
|
||||||
reopen:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: parse_template
|
|
||||||
if: >-
|
|
||||||
${{
|
|
||||||
needs.parse_template.outputs.uses_template == 'true'
|
|
||||||
&& github.event.pull_request.state == 'closed'
|
|
||||||
&& contains(github.event.pull_request.labels.*.name, 'auto-closed:template')
|
|
||||||
}}
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- name: Remove template label
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --remove-label "auto-closed:template" || true
|
|
||||||
|
|
||||||
- name: Check for remaining auto-closed labels
|
|
||||||
id: check_labels
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
run: |
|
|
||||||
REMAINING=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels \
|
|
||||||
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
|
|
||||||
echo "remaining=$REMAINING" | tee --append "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Reopen PR
|
|
||||||
if: ${{ steps.check_labels.outputs.remaining == '0' }}
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
|
||||||
run: |
|
|
||||||
gh api graphql \
|
|
||||||
-f prId="$NODE_ID" \
|
|
||||||
-f query='
|
|
||||||
mutation ReopenPR($prId: ID!) {
|
|
||||||
reopenPullRequest(input: {
|
|
||||||
pullRequestId: $prId
|
|
||||||
}) {
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
@@ -51,14 +51,14 @@ jobs:
|
|||||||
should_run: ${{ steps.check.outputs.should_run }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
|
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
filters: |
|
filters: |
|
||||||
@@ -79,7 +79,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||||
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@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
|
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.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@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3.2.2
|
||||||
with:
|
with:
|
||||||
packages: ''
|
packages: ''
|
||||||
|
|
||||||
@@ -153,14 +153,14 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Publish Android Artifact
|
- name: Publish Android Artifact
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -185,13 +185,13 @@ jobs:
|
|||||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
|
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.sha }}
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Flutter SDK
|
- name: Setup Flutter SDK
|
||||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
|
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2
|
||||||
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@7372622e62b60b3cb750dcd2b9e32c247ffec26a # v1.302.0
|
uses: ruby/setup-ruby@v1
|
||||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: ios-release-ipa
|
name: ios-release-ipa
|
||||||
path: mobile/ios/Runner.ipa
|
path: mobile/ios/Runner.ipa
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
actions: write
|
actions: write
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
|
|||||||
@@ -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@f8cb9308b42121e793f835bd14c0b8090420430c # v0.0.39
|
uses: oasdiff/oasdiff-action/breaking@748daafaf3aac877a36307f842a48d55db938ac8 # v0.0.31
|
||||||
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
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
name: Check PR Template
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||||
|
types: [opened, edited]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
parse:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.event.pull_request.head.repo.fork == true }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
uses_template: ${{ steps.check.outputs.uses_template }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
sparse-checkout: .github/pull_request_template.md
|
||||||
|
sparse-checkout-cone-mode: false
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Check required sections
|
||||||
|
id: check
|
||||||
|
env:
|
||||||
|
BODY: ${{ github.event.pull_request.body }}
|
||||||
|
run: |
|
||||||
|
OK=true
|
||||||
|
while IFS= read -r header; do
|
||||||
|
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
|
||||||
|
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
|
||||||
|
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
act:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: parse
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Close PR
|
||||||
|
if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||||
|
run: |
|
||||||
|
gh api graphql \
|
||||||
|
-f prId="$NODE_ID" \
|
||||||
|
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
|
||||||
|
-f query='
|
||||||
|
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||||
|
addComment(input: {
|
||||||
|
subjectId: $prId,
|
||||||
|
body: $body
|
||||||
|
}) {
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
closePullRequest(input: {
|
||||||
|
pullRequestId: $prId
|
||||||
|
}) {
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
|
||||||
|
- name: Reopen PR (sections now present, PR closed)
|
||||||
|
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||||
|
run: |
|
||||||
|
gh api graphql \
|
||||||
|
-f prId="$NODE_ID" \
|
||||||
|
-f query='
|
||||||
|
mutation ReopenPR($prId: ID!) {
|
||||||
|
reopenPullRequest(input: {
|
||||||
|
pullRequestId: $prId
|
||||||
|
}) {
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
}'
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
working-directory: ./cli
|
working-directory: ./cli
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -83,13 +83,13 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.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
|
||||||
@@ -104,7 +104,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate docker image tags
|
- name: Generate docker image tags
|
||||||
id: metadata
|
id: metadata
|
||||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
with:
|
with:
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=false
|
latest=false
|
||||||
@@ -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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
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:557cca601891b8b7d78b940071d35aaf7aaeb9b327d19b22cf282118edbc5272
|
image: ghcr.io/immich-app/mdq:main@sha256:4f9860d04c88f7f87861f8ee84bfeedaec15ed7ca5ca87bc7db44b036f81645f
|
||||||
outputs:
|
outputs:
|
||||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: Close LLM-generated PRs
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [labeled]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
comment_and_close:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.event.label.name == 'llm-generated' }}
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Comment and close
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||||
|
run: |
|
||||||
|
gh api graphql \
|
||||||
|
-f prId="$NODE_ID" \
|
||||||
|
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
|
||||||
|
-f query='
|
||||||
|
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||||
|
addComment(input: {
|
||||||
|
subjectId: $prId,
|
||||||
|
body: $body
|
||||||
|
}) {
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
|
||||||
|
closePullRequest(input: {
|
||||||
|
pullRequestId: $prId
|
||||||
|
}) {
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
}'
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||||
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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||||
|
|
||||||
# ℹ️ 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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ jobs:
|
|||||||
should_run: ${{ steps.check.outputs.should_run }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
|
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
filters: |
|
filters: |
|
||||||
@@ -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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
suffixes: '-rocm'
|
suffixes: '-rocm'
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
runner-mapping: '{"linux/amd64": "pokedex-large"}'
|
runner-mapping: '{"linux/amd64": "pokedex-large"}'
|
||||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@61a0fc2b41524edcc7c9fffb8bb178e6b0ccf21d # multi-runner-build-workflow-v2.3.0
|
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
@@ -155,7 +155,7 @@ jobs:
|
|||||||
name: Build and Push Server
|
name: Build and Push Server
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@61a0fc2b41524edcc7c9fffb8bb178e6b0ccf21d # multi-runner-build-workflow-v2.3.0
|
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
@@ -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@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||||
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@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||||
with:
|
with:
|
||||||
needs: ${{ toJSON(needs) }}
|
needs: ${{ toJSON(needs) }}
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ jobs:
|
|||||||
should_run: ${{ steps.check.outputs.should_run }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
|
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
filters: |
|
filters: |
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.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:
|
|||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Upload build output
|
- name: Upload build output
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: docs-build-output
|
name: docs-build-output
|
||||||
path: docs/build/
|
path: docs/build/
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
artifact: ${{ steps.get-artifact.outputs.result }}
|
artifact: ${{ steps.get-artifact.outputs.result }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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:
|
|||||||
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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
env:
|
env:
|
||||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
with:
|
with:
|
||||||
@@ -119,7 +119,7 @@ jobs:
|
|||||||
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
|
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -131,11 +131,11 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
|
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
|
||||||
|
|
||||||
- name: Load parameters
|
- name: Load parameters
|
||||||
id: parameters
|
id: parameters
|
||||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.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@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
|
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.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 }}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
|
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
|
||||||
|
|
||||||
- name: Destroy Docs Subdomain
|
- name: Destroy Docs Subdomain
|
||||||
env:
|
env:
|
||||||
@@ -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@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
|
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.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@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.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@08c4be7e2e672a47d11bd04269e27e5f3e8529cb # v6.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.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@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0
|
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
||||||
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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.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@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.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 }}
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
|
|
||||||
- name: Require PR to have a changelog label
|
- name: Require PR to have a changelog label
|
||||||
uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5.5.2
|
uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5.5.1
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
mode: exactly
|
mode: exactly
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.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@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb # v6.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.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@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0
|
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
||||||
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@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.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 }}
|
||||||
@@ -136,13 +136,13 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Download APK
|
- name: Download APK
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: release-apk-signed
|
name: release-apk-signed
|
||||||
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@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
tag_name: ${{ needs.bump_version.outputs.version }}
|
tag_name: ${{ needs.bump_version.outputs.version }}
|
||||||
@@ -151,7 +151,6 @@ jobs:
|
|||||||
body_path: misc/release/notes.tmpl
|
body_path: misc/release/notes.tmpl
|
||||||
files: |
|
files: |
|
||||||
docker/docker-compose.yml
|
docker/docker-compose.yml
|
||||||
docker/docker-compose.rootless.yml
|
|
||||||
docker/example.env
|
docker/example.env
|
||||||
docker/hwaccel.ml.yml
|
docker/hwaccel.ml.yml
|
||||||
docker/hwaccel.transcoding.yml
|
docker/hwaccel.transcoding.yml
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
|
|
||||||
- uses: mshick/add-pr-comment@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
|
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
message-id: 'preview-status'
|
message-id: 'preview-status'
|
||||||
@@ -32,12 +32,12 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
|
|
||||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.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@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
|
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||||
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@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
|
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||||
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 }}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
working-directory: ./open-api/typescript-sdk
|
working-directory: ./open-api/typescript-sdk
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
|
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ jobs:
|
|||||||
should_run: ${{ steps.check.outputs.should_run }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
|
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
filters: |
|
filters: |
|
||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
working-directory: ./mobile
|
working-directory: ./mobile
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -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@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
|
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
flutter-version-file: ./mobile/pubspec.yaml
|
flutter-version-file: ./mobile/pubspec.yaml
|
||||||
|
|||||||
+39
-39
@@ -17,14 +17,14 @@ jobs:
|
|||||||
should_run: ${{ steps.check.outputs.should_run }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
|
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
filters: |
|
filters: |
|
||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
working-directory: ./server
|
working-directory: ./server
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -75,7 +75,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
@@ -108,7 +108,7 @@ jobs:
|
|||||||
working-directory: ./cli
|
working-directory: ./cli
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -119,7 +119,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
@@ -155,7 +155,7 @@ jobs:
|
|||||||
working-directory: ./cli
|
working-directory: ./cli
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -166,7 +166,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
@@ -197,7 +197,7 @@ jobs:
|
|||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -208,7 +208,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
@@ -241,7 +241,7 @@ jobs:
|
|||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -252,7 +252,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
@@ -279,7 +279,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -290,7 +290,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
@@ -327,7 +327,7 @@ jobs:
|
|||||||
working-directory: ./e2e
|
working-directory: ./e2e
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -338,7 +338,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
@@ -373,7 +373,7 @@ jobs:
|
|||||||
working-directory: ./server
|
working-directory: ./server
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -385,7 +385,7 @@ jobs:
|
|||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
@@ -412,7 +412,7 @@ jobs:
|
|||||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -424,7 +424,7 @@ jobs:
|
|||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
@@ -464,7 +464,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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: e2e-server-docker-logs-${{ matrix.runner }}
|
name: e2e-server-docker-logs-${{ matrix.runner }}
|
||||||
@@ -484,7 +484,7 @@ jobs:
|
|||||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -496,7 +496,7 @@ jobs:
|
|||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
@@ -522,7 +522,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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
if: success() || failure()
|
if: success() || failure()
|
||||||
with:
|
with:
|
||||||
name: e2e-web-test-results-${{ matrix.runner }}
|
name: e2e-web-test-results-${{ matrix.runner }}
|
||||||
@@ -533,7 +533,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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
if: success() || failure()
|
if: success() || failure()
|
||||||
with:
|
with:
|
||||||
name: e2e-ui-test-results-${{ matrix.runner }}
|
name: e2e-ui-test-results-${{ matrix.runner }}
|
||||||
@@ -544,7 +544,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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
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 +554,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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: e2e-web-docker-logs-${{ matrix.runner }}
|
name: e2e-web-docker-logs-${{ matrix.runner }}
|
||||||
@@ -566,7 +566,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: always()
|
if: always()
|
||||||
steps:
|
steps:
|
||||||
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||||
with:
|
with:
|
||||||
needs: ${{ toJSON(needs) }}
|
needs: ${{ toJSON(needs) }}
|
||||||
mobile-unit-tests:
|
mobile-unit-tests:
|
||||||
@@ -578,7 +578,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -588,7 +588,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@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
|
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
flutter-version-file: ./mobile/pubspec.yaml
|
flutter-version-file: ./mobile/pubspec.yaml
|
||||||
@@ -610,7 +610,7 @@ jobs:
|
|||||||
working-directory: ./machine-learning
|
working-directory: ./machine-learning
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -620,7 +620,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@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.11
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -650,7 +650,7 @@ jobs:
|
|||||||
working-directory: ./.github
|
working-directory: ./.github
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -661,7 +661,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
@@ -680,7 +680,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -701,7 +701,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -712,7 +712,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
@@ -763,7 +763,7 @@ jobs:
|
|||||||
working-directory: ./server
|
working-directory: ./server
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -774,7 +774,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ jobs:
|
|||||||
should_run: ${{ steps.check.outputs.should_run }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
|
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
filters: |
|
filters: |
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.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 }}
|
||||||
@@ -68,6 +68,6 @@ jobs:
|
|||||||
permissions: {}
|
permissions: {}
|
||||||
if: always()
|
if: always()
|
||||||
steps:
|
steps:
|
||||||
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||||
with:
|
with:
|
||||||
needs: ${{ toJSON(needs) }}
|
needs: ${{ toJSON(needs) }}
|
||||||
|
|||||||
@@ -28,5 +28,3 @@ vite.config.js.timestamp-*
|
|||||||
.pnpm-store
|
.pnpm-store
|
||||||
.devcontainer/library
|
.devcontainer/library
|
||||||
.devcontainer/.env*
|
.devcontainer/.env*
|
||||||
*.tsbuildinfo
|
|
||||||
*.tsbuildInfo
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
[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
+12
@@ -13,6 +13,10 @@
|
|||||||
"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
|
||||||
},
|
},
|
||||||
@@ -25,10 +29,18 @@
|
|||||||
"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
|
||||||
},
|
},
|
||||||
"[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.15.0
|
24.13.1
|
||||||
|
|||||||
+8
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.7.5",
|
"version": "2.5.6",
|
||||||
"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.12.2",
|
"@types/node": "^24.11.0",
|
||||||
"@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,14 +28,15 @@
|
|||||||
"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": "^64.0.0",
|
"eslint-plugin-unicorn": "^63.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": "^6.0.0",
|
"typescript": "^5.3.3",
|
||||||
"typescript-eslint": "^8.58.0",
|
"typescript-eslint": "^8.28.0",
|
||||||
"vite": "^8.0.0",
|
"vite": "^7.0.0",
|
||||||
|
"vite-tsconfig-paths": "^6.0.0",
|
||||||
"vitest": "^4.0.0",
|
"vitest": "^4.0.0",
|
||||||
"vitest-fetch-mock": "^0.4.0",
|
"vitest-fetch-mock": "^0.4.0",
|
||||||
"yaml": "^2.3.1"
|
"yaml": "^2.3.1"
|
||||||
@@ -68,6 +69,6 @@
|
|||||||
"micromatch": "^4.0.8"
|
"micromatch": "^4.0.8"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.15.0"
|
"node": "24.13.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { AssetRejectReason, AssetUploadAction, checkBulkUpload, defaults, getSupportedMediaTypes } from '@immich/sdk';
|
import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } 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: AssetUploadAction.Accept,
|
action: Action.Accept,
|
||||||
id: testFilePath,
|
id: testFilePath,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -144,10 +144,10 @@ describe('checkForDuplicates', () => {
|
|||||||
vi.mocked(checkBulkUpload).mockResolvedValue({
|
vi.mocked(checkBulkUpload).mockResolvedValue({
|
||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
action: AssetUploadAction.Reject,
|
action: Action.Reject,
|
||||||
id: testFilePath,
|
id: testFilePath,
|
||||||
assetId: 'fc5621b1-86f6-44a1-9905-403e607df9f5',
|
assetId: 'fc5621b1-86f6-44a1-9905-403e607df9f5',
|
||||||
reason: AssetRejectReason.Duplicate,
|
reason: Reason.Duplicate,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -167,7 +167,7 @@ describe('checkForDuplicates', () => {
|
|||||||
vi.mocked(checkBulkUpload).mockResolvedValue({
|
vi.mocked(checkBulkUpload).mockResolvedValue({
|
||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
action: AssetUploadAction.Accept,
|
action: Action.Accept,
|
||||||
id: testFilePath,
|
id: testFilePath,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -187,7 +187,7 @@ describe('checkForDuplicates', () => {
|
|||||||
mocked.mockResolvedValue({
|
mocked.mockResolvedValue({
|
||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
action: AssetUploadAction.Accept,
|
action: Action.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 === AssetUploadAction.Accept) {
|
if (action === Action.Accept) {
|
||||||
newFiles.push(filepath);
|
newFiles.push(filepath);
|
||||||
} else {
|
} else {
|
||||||
// rejects are always duplicates
|
// rejects are always duplicates
|
||||||
@@ -404,6 +404,8 @@ 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));
|
||||||
|
|||||||
+1
-1
@@ -81,7 +81,7 @@ export const connect = async (url: string, key: string) => {
|
|||||||
|
|
||||||
const [error] = await withError(getMyUser());
|
const [error] = await withError(getMyUser());
|
||||||
if (isHttpError(error)) {
|
if (isHttpError(error)) {
|
||||||
logError(error, `Failed to connect to server ${url}`);
|
logError(error, 'Failed to connect to server');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-6
@@ -15,12 +15,8 @@
|
|||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"rootDir": "./src",
|
"baseUrl": "./",
|
||||||
"paths": {
|
|
||||||
"src/*": ["./src/*"],
|
|
||||||
},
|
|
||||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
|
|
||||||
"types": ["vitest/globals"]
|
"types": ["vitest/globals"]
|
||||||
},
|
},
|
||||||
"exclude": ["dist", "node_modules", "vite.config.ts"]
|
"exclude": ["dist", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-5
@@ -1,12 +1,10 @@
|
|||||||
import { defineConfig, UserConfig } from 'vite';
|
import { defineConfig, UserConfig } from 'vite';
|
||||||
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: { alias: { src: '/src' } },
|
||||||
alias: { src: '/src' },
|
|
||||||
tsconfigPaths: true,
|
|
||||||
},
|
|
||||||
build: {
|
build: {
|
||||||
rolldownOptions: {
|
rollupOptions: {
|
||||||
input: 'src/index.ts',
|
input: 'src/index.ts',
|
||||||
output: {
|
output: {
|
||||||
dir: 'dist',
|
dir: 'dist',
|
||||||
@@ -18,6 +16,7 @@ export default defineConfig({
|
|||||||
// bundle everything except for Node built-ins
|
// bundle everything except for Node built-ins
|
||||||
noExternal: /^(?!node:).*$/,
|
noExternal: /^(?!node:).*$/,
|
||||||
},
|
},
|
||||||
|
plugins: [tsconfigPaths()],
|
||||||
test: {
|
test: {
|
||||||
name: 'cli:unit',
|
name: 'cli:unit',
|
||||||
globals: true,
|
globals: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tools]
|
[tools]
|
||||||
terragrunt = "1.0.2"
|
terragrunt = "0.99.4"
|
||||||
opentofu = "1.11.6"
|
opentofu = "1.11.5"
|
||||||
|
|
||||||
[tasks."tg:fmt"]
|
[tasks."tg:fmt"]
|
||||||
run = "terragrunt hclfmt"
|
run = "terragrunt hclfmt"
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ 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
|
||||||
@@ -91,7 +90,6 @@ 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
|
||||||
@@ -157,7 +155,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
|
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||||
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:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
|
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||||
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:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3
|
image: prom/prometheus@sha256:4a61322ac1103a0e3aea2a61ef1718422a48fa046441f299d71e660a3bc71ae9
|
||||||
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.4.2-ubuntu@sha256:78839fe49e1425c02416fa8072591533a72bd9598e563b54a07d78f9e27fb5d3
|
image: grafana/grafana:12.3.2-ubuntu@sha256:6cca4b429a1dc0d37d401dee54825c12d40056c3c6f3f56e3f0d6318ce77749b
|
||||||
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:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
|
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||||
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:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
|
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
24.15.0
|
24.13.1
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ The provided restore process ensures your database is never in a broken state by
|
|||||||
|
|
||||||
## Filesystem
|
## Filesystem
|
||||||
|
|
||||||
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:
|
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.
|
Before Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB |
@@ -14,7 +14,6 @@ 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
|
||||||
|
|
||||||
@@ -50,10 +49,6 @@ 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).
|
||||||
@@ -67,8 +62,6 @@ Once you have a new OAuth client application configured, Immich can be configure
|
|||||||
| `scope` | string | openid email profile | Full list of scopes to send with the request (space delimited) |
|
| `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")**¹** |
|
||||||
@@ -187,7 +180,6 @@ 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) |
|
||||||
@@ -261,40 +253,4 @@ 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/
|
||||||
|
|||||||
@@ -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.yml` file (`../../ui:/usr/src/ui`)
|
3. Uncomment the corresponding volume in web service of the `docker/docker-compose.dev.yaml` file (`../../ui:/usr/ui`)
|
||||||
4. Uncomment the corresponding alias in the `web/vite.config.ts` file (`'@immich/ui': path.resolve(\_\_dirname, '../../ui/packages/ui')`)
|
4. Uncomment the corresponding alias in the `web/vite.config.js` file (`'@immich/ui': path.resolve(\_\_dirname, '../../ui')`)
|
||||||
5. Uncomment the import statement in `web/src/app.css` file `@import '../../../ui/packages/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 '/usr/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`)
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
# 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 |
|
||||||
| Star rating | User-assigned star rating |
|
| Start rating | User-assigned start 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,7 +18,6 @@ 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: | |
|
||||||
@@ -30,13 +29,13 @@ 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` `.ts` | :white_check_mark: | |
|
| `MP2T` | `.mts` `.m2ts` `.m2t` | :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: | |
|
||||||
|
|||||||
@@ -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://tiles.immich.cloud/v1/style/light.json)
|
`style.json` for [light theme](https://github.com/immich-app/immich/tree/main/server/resources/style-light.json)
|
||||||
and [dark theme](https://tiles.immich.cloud/v1/style/dark.json)
|
and [dark theme](https://github.com/immich-app/immich/blob/main/server/resources/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,6 +20,8 @@ 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/) suitable for personal use.
|
- 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 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,7 +193,6 @@ 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",
|
||||||
|
|||||||
@@ -30,14 +30,13 @@ 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 |
|
||||||
| `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 |
|
|
||||||
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
|
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
|
||||||
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
|
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
|
||||||
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
|
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Hardware and software requirements for Immich:
|
|||||||
|
|
||||||
## Hardware
|
## Hardware
|
||||||
|
|
||||||
- **OS**: Recommended Linux or \*nix 64-bit operating system (Ubuntu, Debian, etc).
|
- **OS**: Recommended Linux or \*nix operating system (Ubuntu, Debian, etc).
|
||||||
- Non-Linux OSes tend to provide a poor Docker experience and are strongly discouraged.
|
- Non-Linux OSes tend to provide a poor Docker experience and are strongly discouraged.
|
||||||
Our ability to assist with setup or troubleshooting on non-Linux OSes will be severely reduced.
|
Our ability to assist with setup or troubleshooting on non-Linux OSes will be severely reduced.
|
||||||
If you still want to try to use a non-Linux OS, you can set it up as follows:
|
If you still want to try to use a non-Linux OS, you can set it up as follows:
|
||||||
@@ -19,10 +19,6 @@ Hardware and software requirements for Immich:
|
|||||||
If you have issues, we recommend that you switch to a supported VM deployment.
|
If you have issues, we recommend that you switch to a supported VM deployment.
|
||||||
- **RAM**: Minimum 6GB, recommended 8GB.
|
- **RAM**: Minimum 6GB, recommended 8GB.
|
||||||
- **CPU**: Minimum 2 cores, recommended 4 cores.
|
- **CPU**: Minimum 2 cores, recommended 4 cores.
|
||||||
- Immich runs on the `amd64` and `arm64` platforms.
|
|
||||||
Since `v2.6`, the machine learning container on `amd64` requires the `>= x86-64-v2` [microarchitecture level](https://en.wikipedia.org/wiki/X86-64#Microarchitecture_levels).
|
|
||||||
Most CPUs released since ~2012 support this microarchitecture.
|
|
||||||
If you are using a virtual machine, ensure you have selected a [supported microarchitecture](https://pve.proxmox.com/pve-docs/chapter-qm.html#_qemu_cpu_types).
|
|
||||||
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
|
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
|
||||||
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
|
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
|
||||||
|
|
||||||
@@ -49,7 +45,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/retired/#docker-compose-v1-replaced-by-compose-v2) and is no longer supported by Immich.
|
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.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Special requirements for Windows users
|
### Special requirements for Windows users
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ 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.10.0",
|
"@docusaurus/core": "~3.9.0",
|
||||||
"@docusaurus/preset-classic": "~3.10.0",
|
"@docusaurus/preset-classic": "~3.9.0",
|
||||||
"@docusaurus/theme-common": "~3.10.0",
|
"@docusaurus/theme-common": "~3.9.0",
|
||||||
"@docusaurus/theme-mermaid": "~3.10.0",
|
"@docusaurus/theme-mermaid": "~3.9.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": "^19.0.0",
|
"react": "^18.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^18.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.10.0",
|
"@docusaurus/module-type-aliases": "~3.9.0",
|
||||||
"@docusaurus/tsconfig": "^3.10.0",
|
"@docusaurus/tsconfig": "^3.7.0",
|
||||||
"@docusaurus/types": "^3.10.0",
|
"@docusaurus/types": "^3.7.0",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"typescript": "^6.0.0"
|
"typescript": "^5.1.6"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
@@ -58,6 +58,6 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.15.0"
|
"node": "24.13.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-8
@@ -1,12 +1,4 @@
|
|||||||
[
|
[
|
||||||
{
|
|
||||||
"label": "v2.7.5",
|
|
||||||
"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",
|
||||||
"url": "https://docs.v2.5.6.archive.immich.app"
|
"url": "https://docs.v2.5.6.archive.immich.app"
|
||||||
|
|||||||
+5
-1
@@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
// 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,12 +1,5 @@
|
|||||||
import {
|
import { exportJWK, generateKeyPair } from 'jose';
|
||||||
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',
|
||||||
@@ -51,29 +44,6 @@ 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`,
|
||||||
@@ -96,6 +66,8 @@ 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": "^6.0.0",
|
"jose": "^5.6.3",
|
||||||
"@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"
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
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.15.0
|
24.13.1
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich-e2e-redis
|
container_name: immich-e2e-redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
|
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||||
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.7.5",
|
"version": "2.5.6",
|
||||||
"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.12.2",
|
"@types/node": "^24.11.0",
|
||||||
"@types/pg": "^8.15.1",
|
"@types/pg": "^8.15.1",
|
||||||
"@types/pngjs": "^6.0.4",
|
"@types/pngjs": "^6.0.4",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^6.0.2",
|
||||||
"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": "^64.0.0",
|
"eslint-plugin-unicorn": "^63.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": "^6.0.0",
|
"typescript": "^5.3.3",
|
||||||
"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.15.0"
|
"node": "24.13.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,651 +0,0 @@
|
|||||||
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,8 +2,6 @@ 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 = {
|
||||||
|
|||||||
@@ -2,43 +2,82 @@ 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),
|
||||||
}),
|
}),
|
||||||
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: {
|
invalidEmail: {
|
||||||
|
error: 'Bad Request',
|
||||||
|
statusCode: 400,
|
||||||
message: ['email must be an email'],
|
message: ['email must be an email'],
|
||||||
|
correlationId: expect.any(String),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,9 +94,7 @@ 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,11 +130,12 @@ 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}`)
|
.get(`/albums/${user1Albums[0].id}?withoutAssets=false`)
|
||||||
.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),
|
||||||
@@ -154,31 +155,23 @@ 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,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
@@ -192,31 +185,23 @@ 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,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
@@ -232,31 +217,23 @@ 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,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
@@ -272,10 +249,8 @@ 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,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
@@ -312,17 +287,13 @@ 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,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
@@ -333,12 +304,13 @@ 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}`)
|
.get(`/albums/${user1Albums[0].id}?withoutAssets=false`)
|
||||||
.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),
|
||||||
@@ -350,7 +322,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}`)
|
.get(`/albums/${user2Albums[0].id}?withoutAssets=false`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
@@ -359,14 +331,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}`)
|
.get(`/albums/${user1Albums[3].id}?withoutAssets=false`)
|
||||||
.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', async () => {
|
it('should return album info with assets when withoutAssets is undefined', 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}`);
|
||||||
@@ -374,6 +346,25 @@ 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),
|
||||||
@@ -388,12 +379,13 @@ 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}`)
|
.get(`/albums/${user2Albums[0].id}?withoutAssets=true`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toEqual(
|
expect(body).toEqual({
|
||||||
expect.objectContaining({
|
...user2Albums[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),
|
||||||
@@ -401,8 +393,7 @@ describe('/albums', () => {
|
|||||||
startDate: expect.any(String),
|
startDate: expect.any(String),
|
||||||
albumUsers: expect.any(Array),
|
albumUsers: expect.any(Array),
|
||||||
shared: true,
|
shared: true,
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -428,13 +419,16 @@ 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: [{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) }],
|
albumUsers: [],
|
||||||
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,
|
||||||
});
|
});
|
||||||
@@ -530,19 +524,14 @@ describe('/albums', () => {
|
|||||||
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
|
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be able to update as an editor', async () => {
|
it('should not be able to update as an editor', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.patch(`/albums/${user1Albums[0].id}`)
|
.patch(`/albums/${user1Albums[0].id}`)
|
||||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||||
.send({ albumName: 'New album name' });
|
.send({ albumName: 'New album name' });
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(
|
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
|
||||||
expect.objectContaining({
|
|
||||||
id: user1Albums[0].id,
|
|
||||||
albumName: 'New album name',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -650,11 +639,11 @@ describe('/albums', () => {
|
|||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
albumUsers: expect.arrayContaining([
|
albumUsers: [
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
user: expect.objectContaining({ id: user2.userId }),
|
user: expect.objectContaining({ id: user2.userId }),
|
||||||
}),
|
}),
|
||||||
]),
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -666,7 +655,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('User already added'));
|
expect(body).toEqual(errorDto.badRequest('Cannot be shared with owner'));
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
@@ -692,7 +681,7 @@ describe('/albums', () => {
|
|||||||
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
|
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(album.albumUsers[1].role).toEqual(AlbumUserRole.Viewer);
|
expect(album.albumUsers[0].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}`)
|
||||||
@@ -707,10 +696,7 @@ describe('/albums', () => {
|
|||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
albumUsers: [
|
albumUsers: [expect.objectContaining({ role: AlbumUserRole.Editor })],
|
||||||
expect.objectContaining({ role: AlbumUserRole.Owner }),
|
|
||||||
expect.objectContaining({ role: AlbumUserRole.Editor }),
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -721,7 +707,7 @@ describe('/albums', () => {
|
|||||||
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
|
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(album.albumUsers[1].role).toEqual(AlbumUserRole.Viewer);
|
expect(album.albumUsers[0].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,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
AssetMediaResponseDto,
|
AssetMediaResponseDto,
|
||||||
AssetMediaStatus,
|
AssetMediaStatus,
|
||||||
|
AssetResponseDto,
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
AssetVisibility,
|
AssetVisibility,
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
@@ -18,7 +19,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, testAssetDir, utils } from 'src/utils';
|
import { app, asBearerAuth, tempDir, TEN_TIMES, 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';
|
||||||
|
|
||||||
@@ -94,8 +95,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.toUTC().toISO(),
|
fileCreatedAt: yesterday.toISO(),
|
||||||
fileModifiedAt: yesterday.toUTC().toISO(),
|
fileModifiedAt: yesterday.toISO(),
|
||||||
assetData: { filename: 'example.mp4' },
|
assetData: { filename: 'example.mp4' },
|
||||||
}),
|
}),
|
||||||
utils.createAsset(user1.accessToken),
|
utils.createAsset(user1.accessToken),
|
||||||
@@ -379,12 +380,62 @@ 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);
|
||||||
});
|
});
|
||||||
@@ -1091,6 +1142,8 @@ 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');
|
||||||
@@ -1107,6 +1160,8 @@ 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');
|
||||||
@@ -1160,4 +1215,29 @@ 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ describe('/libraries', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items']));
|
expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
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 +125,7 @@ describe('/libraries', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items']));
|
expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ describe('/libraries', () => {
|
|||||||
.send({ name: '' });
|
.send({ name: '' });
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['[name] Too small: expected string to have >=1 characters']));
|
expect(body).toEqual(errorDto.badRequest(['name should not be empty']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should change the import paths', async () => {
|
it('should change the import paths', async () => {
|
||||||
@@ -181,7 +181,7 @@ describe('/libraries', () => {
|
|||||||
.send({ importPaths: [''] });
|
.send({ importPaths: [''] });
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array items must not be empty']));
|
expect(body).toEqual(errorDto.badRequest(['each value in importPaths should not be empty']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject duplicate import paths', async () => {
|
it('should reject duplicate import paths', async () => {
|
||||||
@@ -191,7 +191,7 @@ describe('/libraries', () => {
|
|||||||
.send({ importPaths: ['/path', '/path'] });
|
.send({ importPaths: ['/path', '/path'] });
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items']));
|
expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should change the exclusion pattern', async () => {
|
it('should change the exclusion pattern', async () => {
|
||||||
@@ -215,7 +215,7 @@ describe('/libraries', () => {
|
|||||||
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
|
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items']));
|
expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject an empty exclusion pattern', async () => {
|
it('should reject an empty exclusion pattern', async () => {
|
||||||
@@ -225,7 +225,7 @@ describe('/libraries', () => {
|
|||||||
.send({ exclusionPatterns: [''] });
|
.send({ exclusionPatterns: [''] });
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array items must not be empty']));
|
expect(body).toEqual(errorDto.badRequest(['each value in exclusionPatterns should not be empty']));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ 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] Invalid input: expected number, received NaN']));
|
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if a lat is not a number', async () => {
|
it('should throw an error if a lat is not a number', async () => {
|
||||||
@@ -117,7 +117,7 @@ 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] Invalid input: expected number, received NaN']));
|
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if a lat is out of range', async () => {
|
it('should throw an error if a lat is out of range', async () => {
|
||||||
@@ -125,7 +125,7 @@ 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] Too big: expected number to be <=90']));
|
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if a lon is not provided', async () => {
|
it('should throw an error if a lon is not provided', async () => {
|
||||||
@@ -133,7 +133,7 @@ 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] Invalid input: expected number, received NaN']));
|
expect(body).toEqual(errorDto.badRequest(['lon must be a number between -180 and 180']));
|
||||||
});
|
});
|
||||||
|
|
||||||
const reverseGeocodeTestCases = [
|
const reverseGeocodeTestCases = [
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { OAuthClient, OAuthUser, generateLogoutToken } from '@immich/e2e-auth-server';
|
import { OAuthClient, OAuthUser } 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';
|
||||||
@@ -77,7 +76,6 @@ 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);
|
||||||
@@ -89,10 +87,7 @@ describe(`/oauth`, () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await utils.resetDatabase();
|
await utils.resetDatabase();
|
||||||
admin = await utils.adminSetup();
|
admin = await utils.adminSetup();
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /oauth/authorize', () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
await setupOAuth(admin.accessToken, {
|
await setupOAuth(admin.accessToken, {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
clientId: OAuthClient.DEFAULT,
|
clientId: OAuthClient.DEFAULT,
|
||||||
@@ -102,10 +97,11 @@ describe(`/oauth`, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /oauth/authorize', () => {
|
||||||
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] Invalid input: expected string, received undefined']));
|
expect(body).toEqual(errorDto.badRequest(['redirectUri must be a string', 'redirectUri should not be empty']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return a redirect uri', async () => {
|
it('should return a redirect uri', async () => {
|
||||||
@@ -121,56 +117,19 @@ 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] Invalid input: expected string, received undefined']));
|
expect(body).toEqual(errorDto.badRequest(['url must be a string', 'url should not be empty']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`should throw an error if the url is empty`, async () => {
|
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] Too small: expected string to have >=1 characters']));
|
expect(body).toEqual(errorDto.badRequest(['url should not be empty']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`should throw an error if the state is not provided`, async () => {
|
it(`should throw an error if the state is not provided`, async () => {
|
||||||
@@ -199,9 +158,10 @@ 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 } = await request(app)
|
const { status, body } = 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -298,7 +258,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),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -332,7 +292,9 @@ 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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -371,50 +333,6 @@ 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.badRequest(['[logout_token] 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, {
|
||||||
@@ -481,22 +399,4 @@ 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,6 +74,7 @@ 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,
|
||||||
}),
|
}),
|
||||||
@@ -457,7 +458,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(expect.stringContaining(name));
|
expect(body[0].name).toEqual(name);
|
||||||
expect(body[0].admin2name).toEqual(name);
|
expect(body[0].admin2name).toEqual(name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -207,6 +207,16 @@ 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,21 +243,9 @@ 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 })
|
.query({ key: linkWithPassword.key, password: 'foo' });
|
||||||
.set('Cookie', cookies);
|
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
|
|||||||
@@ -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] Invalid UUID']));
|
expect(body).toEqual(errorDto.badRequest(['id must be a 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] Invalid UUID']));
|
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should delete a tag', async () => {
|
it('should delete a tag', async () => {
|
||||||
|
|||||||
@@ -287,8 +287,7 @@ 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({
|
||||||
|
|||||||
@@ -178,9 +178,7 @@ describe('/users', () => {
|
|||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(
|
expect(body).toEqual(errorDto.badRequest(['download.archiveSize must be an integer number']));
|
||||||
errorDto.badRequest(['[download.archiveSize] Invalid input: expected int, received number']),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update download archive size', async () => {
|
it('should update download archive size', async () => {
|
||||||
@@ -206,9 +204,7 @@ describe('/users', () => {
|
|||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(
|
expect(body).toEqual(errorDto.badRequest(['download.includeEmbeddedVideos must be a boolean value']));
|
||||||
errorDto.badRequest(['[download.includeEmbeddedVideos] Invalid input: expected boolean, received number']),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update download include embedded videos', async () => {
|
it('should update download include embedded videos', async () => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { LoginResponseDto } from '@immich/sdk';
|
import { LoginResponseDto } from '@immich/sdk';
|
||||||
import { expect, test } from '@playwright/test';
|
import { test } from '@playwright/test';
|
||||||
import { readFileSync } from 'node:fs';
|
import { utils } from 'src/utils';
|
||||||
import { testAssetDir, utils } from 'src/utils';
|
|
||||||
|
|
||||||
test.describe('Album', () => {
|
test.describe('Album', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
@@ -23,41 +22,4 @@ 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,9 +1,7 @@
|
|||||||
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 { testAssetDir, utils } from 'src/utils';
|
import { utils } from 'src/utils';
|
||||||
|
|
||||||
test.describe('Detail Panel', () => {
|
test.describe('Detail Panel', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
@@ -85,42 +83,4 @@ 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { AssetMediaResponseDto, LoginResponseDto, updateAssets } from '@immich/sdk';
|
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
import crypto from 'node:crypto';
|
|
||||||
import { asBearerAuth, utils } from 'src/utils';
|
|
||||||
|
|
||||||
test.describe('Duplicates Utility', () => {
|
|
||||||
let admin: LoginResponseDto;
|
|
||||||
let firstAsset: AssetMediaResponseDto;
|
|
||||||
let secondAsset: AssetMediaResponseDto;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
utils.initSdk();
|
|
||||||
await utils.resetDatabase();
|
|
||||||
admin = await utils.adminSetup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
[firstAsset, secondAsset] = await Promise.all([
|
|
||||||
utils.createAsset(admin.accessToken, {}),
|
|
||||||
utils.createAsset(admin.accessToken, {}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await updateAssets(
|
|
||||||
{
|
|
||||||
assetBulkUpdateDto: {
|
|
||||||
ids: [firstAsset.id, secondAsset.id],
|
|
||||||
duplicateId: crypto.randomUUID(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ headers: asBearerAuth(admin.accessToken) },
|
|
||||||
);
|
|
||||||
|
|
||||||
await utils.setAuthCookies(context, admin.accessToken);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('navigates with arrow keys between duplicate preview assets', async ({ page }) => {
|
|
||||||
await page.goto('/utilities/duplicates');
|
|
||||||
await page.getByRole('button', { name: 'View' }).first().click();
|
|
||||||
await page.waitForSelector('#immich-asset-viewer');
|
|
||||||
|
|
||||||
const getViewedAssetId = () => new URL(page.url()).pathname.split('/').at(-1) ?? '';
|
|
||||||
const initialAssetId = getViewedAssetId();
|
|
||||||
expect([firstAsset.id, secondAsset.id]).toContain(initialAssetId);
|
|
||||||
|
|
||||||
await page.keyboard.press('ArrowRight');
|
|
||||||
await expect.poll(getViewedAssetId).not.toBe(initialAssetId);
|
|
||||||
|
|
||||||
await page.keyboard.press('ArrowLeft');
|
|
||||||
await expect.poll(getViewedAssetId).toBe(initialAssetId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -64,17 +64,17 @@ test.describe('Photo Viewer', () => {
|
|||||||
await expect(original).toHaveAttribute('src', /fullsize/);
|
await expect(original).toHaveAttribute('src', /fullsize/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('right-click targets the img element', async ({ page }) => {
|
test('reloads photo when checksum changes', async ({ page }) => {
|
||||||
await page.goto(`/photos/${asset.id}`);
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
|
||||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||||
await expect(preview).toHaveAttribute('src', /.+/);
|
await expect(preview).toHaveAttribute('src', /.+/);
|
||||||
|
const initialSrc = await preview.getAttribute('src');
|
||||||
|
|
||||||
const box = await preview.boundingBox();
|
const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
|
||||||
const tagAtCenter = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.tagName, {
|
await utils.replaceAsset(admin.accessToken, asset.id);
|
||||||
x: box!.x + box!.width / 2,
|
await websocketEvent;
|
||||||
y: box!.y + box!.height / 2,
|
|
||||||
});
|
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
||||||
expect(tagAtCenter).toBe('IMG');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlbumUserRole,
|
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
AssetVisibility,
|
AssetVisibility,
|
||||||
UserAvatarColor,
|
UserAvatarColor,
|
||||||
@@ -316,9 +315,11 @@ 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'}`,
|
||||||
@@ -333,7 +334,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,
|
duration: asset.duration || '0:00:00.00000',
|
||||||
exifInfo,
|
exifInfo,
|
||||||
livePhotoVideoId: asset.livePhotoVideoId,
|
livePhotoVideoId: asset.livePhotoVideoId,
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -421,11 +422,14 @@ export function getAlbum(
|
|||||||
albumThumbnailAssetId: album.thumbnailAssetId,
|
albumThumbnailAssetId: album.thumbnailAssetId,
|
||||||
createdAt: album.createdAt,
|
createdAt: album.createdAt,
|
||||||
updatedAt: album.updatedAt,
|
updatedAt: album.updatedAt,
|
||||||
albumUsers: [{ user: albumOwner, role: AlbumUserRole.Owner }],
|
ownerId: albumOwner.id,
|
||||||
|
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,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BrowserContext } from '@playwright/test';
|
import { BrowserContext } from '@playwright/test';
|
||||||
import { playwrightHost } from 'src/../playwright.config';
|
import { playwrightHost } from '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,7 +173,6 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
|
|||||||
'.mpeg',
|
'.mpeg',
|
||||||
'.mpg',
|
'.mpg',
|
||||||
'.mts',
|
'.mts',
|
||||||
'.ts',
|
|
||||||
'.vob',
|
'.vob',
|
||||||
'.webm',
|
'.webm',
|
||||||
'.wmv',
|
'.wmv',
|
||||||
@@ -223,7 +222,6 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
|
|||||||
'.jp2',
|
'.jp2',
|
||||||
'.jpe',
|
'.jpe',
|
||||||
'.jxl',
|
'.jxl',
|
||||||
'.mpo',
|
|
||||||
'.svg',
|
'.svg',
|
||||||
'.tif',
|
'.tif',
|
||||||
'.tiff',
|
'.tiff',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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,
|
||||||
@@ -26,6 +27,7 @@ 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`,
|
||||||
@@ -40,7 +42,7 @@ export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
|
|||||||
isArchived: false,
|
isArchived: false,
|
||||||
isTrashed: false,
|
isTrashed: false,
|
||||||
visibility: AssetVisibility.Timeline,
|
visibility: AssetVisibility.Timeline,
|
||||||
duration: null,
|
duration: '0:00:00.00000',
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
make: null,
|
make: null,
|
||||||
model: null,
|
model: null,
|
||||||
@@ -67,7 +69,7 @@ export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
|
|||||||
tags: [],
|
tags: [],
|
||||||
people: [],
|
people: [],
|
||||||
unassignedFaces: [],
|
unassignedFaces: [],
|
||||||
stack: undefined,
|
stack: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
hasMetadata: true,
|
hasMetadata: true,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
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,7 +6,6 @@ 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';
|
||||||
@@ -31,10 +30,6 @@ 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 });
|
||||||
@@ -49,10 +44,7 @@ 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
|
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
|
||||||
.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',
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ test.describe('Timeline', () => {
|
|||||||
expect(visibleMockAssetsYearMonths).toContain(month);
|
expect(visibleMockAssetsYearMonths).toContain(month);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
test.skip('Deep link to last photo, scroll up', async ({ page }) => {
|
test('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.skip('Deep link to first bucket, scroll down', async ({ page }) => {
|
test('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.skip('Add photos to album', async ({ page }) => {
|
test('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.skip('open /favorites, archive photo, unarchive photo', async ({ page }) => {
|
test('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[role="checkbox"]`);
|
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
|
||||||
},
|
},
|
||||||
selectedAsset(page: Page) {
|
selectedAsset(page: Page) {
|
||||||
return page.locator('[data-thumbnail-focus-container][data-selected]');
|
return page.locator('[data-thumbnail-focus-container][data-selected]');
|
||||||
|
|||||||
+41
-3
@@ -3,6 +3,7 @@ import {
|
|||||||
AssetMediaResponseDto,
|
AssetMediaResponseDto,
|
||||||
AssetResponseDto,
|
AssetResponseDto,
|
||||||
AssetVisibility,
|
AssetVisibility,
|
||||||
|
CheckExistingAssetsDto,
|
||||||
CreateAlbumDto,
|
CreateAlbumDto,
|
||||||
CreateLibraryDto,
|
CreateLibraryDto,
|
||||||
JobCreateDto,
|
JobCreateDto,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
UserAdminCreateDto,
|
UserAdminCreateDto,
|
||||||
UserPreferencesUpdateDto,
|
UserPreferencesUpdateDto,
|
||||||
ValidateLibraryDto,
|
ValidateLibraryDto,
|
||||||
|
checkExistingAssets,
|
||||||
createAlbum,
|
createAlbum,
|
||||||
createApiKey,
|
createApiKey,
|
||||||
createJob,
|
createJob,
|
||||||
@@ -341,6 +343,8 @@ 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,
|
||||||
@@ -371,6 +375,40 @@ 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 });
|
||||||
@@ -412,6 +450,9 @@ 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) });
|
||||||
},
|
},
|
||||||
@@ -469,9 +510,6 @@ 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: 0eac5a3738...163c251744
+1
-3
@@ -14,10 +14,8 @@
|
|||||||
"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"]
|
||||||
|
|||||||
+125
-224
@@ -2,147 +2,147 @@
|
|||||||
"about": "Oor",
|
"about": "Oor",
|
||||||
"account": "Rekening",
|
"account": "Rekening",
|
||||||
"account_settings": "Rekeninginstellings",
|
"account_settings": "Rekeninginstellings",
|
||||||
"acknowledge": "Neem kennis",
|
"acknowledge": "Erken",
|
||||||
"action": "Aksie",
|
"action": "Aksie",
|
||||||
"action_common_update": "Werk by",
|
"action_common_update": "Opdateur",
|
||||||
"actions": "Aksies",
|
"actions": "Aksies",
|
||||||
"active": "Aktief",
|
"active": "Aktief",
|
||||||
"activity": "Aktiwiteite",
|
"activity": "Aktiwiteite",
|
||||||
"activity_changed": "Aktiwiteit is {enabled, select, true {geaktiveer} other {gedeaktiveer}}",
|
"activity_changed": "Aktiwiteit is {enabled, select, true {aangeskakel} other {afgeskakel}}",
|
||||||
"add": "Voeg toe",
|
"add": "Voegby",
|
||||||
"add_a_description": "Voeg ’n beskrywing toe",
|
"add_a_description": "Voeg 'n beskrywing by",
|
||||||
"add_a_location": "Voeg ’n ligging toe",
|
"add_a_location": "Voeg 'n ligging by",
|
||||||
"add_a_name": "Voeg ’n naam toe",
|
"add_a_name": "Voeg 'n naam by",
|
||||||
"add_a_title": "Voeg ’n titel toe",
|
"add_a_title": "Voeg 'n titel by",
|
||||||
"add_birthday": "Voeg ’n verjaarsdag toe",
|
"add_birthday": "Voeg 'n verjaarsdag by",
|
||||||
"add_endpoint": "Voeg eindpunt toe",
|
"add_endpoint": "Voeg Koppelvlakpunt by",
|
||||||
"add_exclusion_pattern": "Voeg uitsluitingspatroon toe",
|
"add_exclusion_pattern": "Voeg uitsgluitingspatrone by",
|
||||||
"add_location": "Voeg ligging toe",
|
"add_location": "Voeg ligging by",
|
||||||
"add_more_users": "Voeg meer gebruikers toe",
|
"add_more_users": "Voeg meer gebruikers by",
|
||||||
"add_partner": "Voeg vennoot toe",
|
"add_partner": "Voeg vennoot by",
|
||||||
"add_path": "Voeg pad toe",
|
"add_path": "Voeg pad by",
|
||||||
"add_photos": "Voeg foto’s toe",
|
"add_photos": "Voeg foto's by",
|
||||||
"add_tag": "Voeg etiket toe",
|
"add_tag": "Voeg tag by",
|
||||||
"add_to": "Voeg toe tot…",
|
"add_to": "Voeg by…",
|
||||||
"add_to_album": "Voeg toe tot album",
|
"add_to_album": "Voeg na album",
|
||||||
"add_to_album_bottom_sheet_added": "Tot {album} toegevoeg",
|
"add_to_album_bottom_sheet_added": "By {album} bygevoeg",
|
||||||
"add_to_album_bottom_sheet_already_exists": "Reeds in {album}",
|
"add_to_album_bottom_sheet_already_exists": "Reeds in {album}",
|
||||||
"add_to_albums": "Voeg toe tot albums",
|
"add_to_albums": "Voeg by albums",
|
||||||
"add_to_albums_count": "Voeg toe tot albums ({count})",
|
"add_to_albums_count": "Voeg by ({count}) albums",
|
||||||
"add_to_shared_album": "Voeg toe tot gedeelde album",
|
"add_to_shared_album": "Voeg toe aan gedeelde album",
|
||||||
"add_url": "Voeg bronadres toe",
|
"add_url": "Voeg URL by",
|
||||||
"added_to_archive": "Tot argief toegevoeg",
|
"added_to_archive": "By argief toegevoegd",
|
||||||
"added_to_favorites": "Tot gunstelinge toegevoeg",
|
"added_to_favorites": "By gunstelinge toegevoegd",
|
||||||
"added_to_favorites_count": "{count, number} tot gunstelinge toegevoeg",
|
"added_to_favorites_count": "Het {count, number} by gunstelinge toegevoegd",
|
||||||
"admin": {
|
"admin": {
|
||||||
"add_exclusion_pattern_description": "Voeg uitsluitingspatrone toe. Plekhouers met *, ** en ? word ondersteun. Om alle lêers in enige vouer genaamd “Raw” te ignoreer, gebruik “**/Raw/**”. Om alle lêers wat op “.tif” eindig, te ignoreer, gebruik “**/*.tif”. Om ’n absolute pad te ignoreer, gebruik “/path/to/ignore/**”.",
|
"add_exclusion_pattern_description": "Voeg uitsluitingspatrone by. Globbing met *, ** en ? word ondersteun. Om alle lêers in enige lêergids genaamd \"Raw\" te ignoreer, gebruik \"**/Raw/**\". Om alle lêers wat op \".tif\" eindig, te ignoreer, gebruik \"**/*.tif\". Om 'n absolute pad te ignoreer, gebruik \"/path/to/ignore/**\".",
|
||||||
"admin_user": "Admingebruiker",
|
"admin_user": "Admin gebruiker",
|
||||||
"asset_offline_description": "Hierdie eksterne biblioteekitem word nie meer op skyf gevind nie en is na die asblik geskuif. As die lêer binne die biblioteek geskuif is, gaan u tydlyn na vir die nuwe ooreenstemmende item. Om hierdie item te herstel, maak asseblief seker dat die lêerpad hieronder deur Immich verkry kan word en skandeer die biblioteek.",
|
"asset_offline_description": "Hierdie eksterne biblioteekbate word nie meer op skyf gevind nie en is na die asblik geskuif. As die lêer binne die biblioteek geskuif is, gaan jou tydlyn na vir die nuwe ooreenstemmende bate. Om hierdie bate te herstel, maak asseblief seker dat die lêerpad hieronder deur Immich verkry kan word en skandeer die biblioteek.",
|
||||||
"authentication_settings": "Waarmerkinstellings",
|
"authentication_settings": "Verifikasie instellings",
|
||||||
"authentication_settings_description": "Bestuur wagwoord, OAuth en ander waarmerkinstellings",
|
"authentication_settings_description": "Bestuur wagwoord, OAuth en ander verifikasie instellings",
|
||||||
"authentication_settings_disable_all": "Is u seker u wil alle aantekenmetodes deaktiveer? Aantekening sal heeltemal gedeaktiveer word.",
|
"authentication_settings_disable_all": "Is jy seker jy wil alle aanmeldmetodes deaktiveer? Aanmelding sal heeltemal gedeaktiveer word.",
|
||||||
"authentication_settings_reenable": "Gebruik ’n <link>bedienerbevel</link> om te heraktiveer.",
|
"authentication_settings_reenable": "Om te heraktiveer, gebruik 'n <link>Server Command</link>.",
|
||||||
"background_task_job": "Agtergrondtake",
|
"background_task_job": "Agtergrondtake",
|
||||||
"backup_database": "Skep Databasisstortlêer",
|
"backup_database": "Skep Datastortlêer",
|
||||||
"backup_database_enable_description": "Aktiveer databasisstortlêers",
|
"backup_database_enable_description": "Aktiveer databasisrugsteun",
|
||||||
"backup_keep_last_amount": "Aantal vorige stortlêers om te hou",
|
"backup_keep_last_amount": "Aantal vorige rugsteune om te hou",
|
||||||
"backup_onboarding_3_description": "totale kopieë van u data, insluitend die oorspronklike lêers. Dit sluit 1 kopie op ’n ander perseel en 2 lokale kopieë in.",
|
"backup_onboarding_3_description": "totale kopieë van jou data, insluitende die oorspronklikke lêers. Dit sluit in 1 kopie op 'n ander perseel en 2 kopieë om die huidige rekenaar.",
|
||||||
"backup_onboarding_description": "’n <backblaze-link>3-2-1-rugsteunstrategie</backblaze-link> word sterk aanbeveel om u data veilig te hou. Hou kopieë van u foto’s/video’s sowel as die Immich-databasis vir ’n volledige rugsteunoplossing.",
|
"backup_onboarding_description": "'N <backblaze-link>3-2-1 rugsteun strategie</backblaze-link> word sterk aanbeveel om jou data veilig te hou. Hou kopieë van jou fotos/videos so wel as die Immich databasis vir 'n volledige rugsteun oplossing.",
|
||||||
"backup_onboarding_footer": "Lees hierdie <link>dokument</link> vir meer inligting oor hoe om ’n rugsteunkopie van Immich te maak.",
|
"backup_onboarding_footer": "Vir meer inligting oor hoe om 'n rugsteun kopie van Immich te maak, gaan lees asseblief hierdie <link>dokument</link>.",
|
||||||
"backup_onboarding_parts_title": "’n 3-2-1-rugsteun sluit in:",
|
"backup_onboarding_parts_title": "'N 3-2-1 rugsteun sluit in:",
|
||||||
"backup_onboarding_title": "Rugsteunkopieë",
|
"backup_onboarding_title": "Rugsteun kopieë",
|
||||||
"backup_settings": "Databasisstortinstellings",
|
"backup_settings": "Rugsteun instellings",
|
||||||
"backup_settings_description": "Bestuur databasisrugsteuninstellings.",
|
"backup_settings_description": "Bestuur databasis rugsteun instellings.",
|
||||||
"cleared_jobs": "Take gewis vir: {job}",
|
"cleared_jobs": "Poste gevee vir: {job}",
|
||||||
"config_set_by_file": "Config word tans deur ’n konfigurasielêer gestel",
|
"config_set_by_file": "Config word tans deur 'n konfigurasielêer gestel",
|
||||||
"confirm_delete_library": "Is u seker u wil {library}-biblioteek skrap?",
|
"confirm_delete_library": "Is jy seker jy wil {library}-biblioteek uitvee?",
|
||||||
"confirm_delete_library_assets": "Is u seker u wil hierdie biblioteek skrap? Dit sal {count, plural, one {# bevatte item} other {# bevatte items}} uit Immich skrap en kan nie ongedaan gemaak word nie. Lêers sal op skyf bly.",
|
"confirm_delete_library_assets": "Is jy seker jy wil hierdie biblioteek uitvee? Dit sal {count, plural, one {# bevatte base} other {# bevatte bates}} uit Immich uitvee en kan nie ongedaan gemaak word nie. Lêers sal op skyf bly.",
|
||||||
"confirm_email_below": "Tik “{email}” hieronder ter bevestiging",
|
"confirm_email_below": "Om te bevestig, tik \"{email}\" hieronder",
|
||||||
"confirm_reprocess_all_faces": "Is u seker u wil alle gesigte herverwerk? Dit sal ook genoemde mense skoonmaak.",
|
"confirm_reprocess_all_faces": "Is jy seker jy wil alle gesigte herverwerk? Dit sal ook genoemde mense skoonmaak.",
|
||||||
"confirm_user_password_reset": "Is u seker u wil {user} se wagwoord terugstel?",
|
"confirm_user_password_reset": "Is jy seker jy wil {user} se wagwoord terugstel?",
|
||||||
"confirm_user_pin_code_reset": "Is u seker u wil {user} se PIN-kode herstel?",
|
"confirm_user_pin_code_reset": "Is jy seker jy wil {user} se PIN kode herstel?",
|
||||||
"create_job": "Skep taak",
|
"create_job": "Skep werk",
|
||||||
"cron_expression": "Cron-uitdrukking",
|
"cron_expression": "Cron uitdrukking",
|
||||||
"cron_expression_description": "Stel die skanderingsinterval in met die cron-formaat. Kyk gerus na bv. <link>Crontab Guru</link> vir meer inligting",
|
"cron_expression_description": "Stel die skanderingsinterval in met die cron-formaat. Vir meer inligting verwys asseblief na bv. <link>Crontab Guru</link>",
|
||||||
"cron_expression_presets": "Cron-uitdrukking voorafinstellings",
|
"cron_expression_presets": "Cron uitdrukking voorafinstellings",
|
||||||
"disable_login": "Deaktiveer aantekening",
|
"disable_login": "Deaktiveer aanmelding",
|
||||||
"duplicate_detection_job_description": "Begin masjienleer op items om soortgelyke beelde op te spoor. Maak staat op Slimsoek",
|
"duplicate_detection_job_description": "Begin masjienleer op bates om soortgelyke beelde op te spoor. Maak staat op Smart Search",
|
||||||
"exclusion_pattern_description": "Met uitsluitingspatrone kan u lêers en vouers ignoreer wanneer u u biblioteek skandeer. Dit is nuttig as u vouers het wat lêers bevat wat u nie wil invoer nie, soos RAW-lêers.",
|
"exclusion_pattern_description": "Met uitsluitingspatrone kan jy lêers en vouers ignoreer wanneer jy jou biblioteek skandeer. Dit is nuttig as jy vouers het wat lêers bevat wat jy nie wil invoer nie, soos RAW-lêers.",
|
||||||
"face_detection": "Gesigherkenning",
|
"face_detection": "Gesig herkenning",
|
||||||
"face_detection_description": "Identifiseer die gesigte in media d.m.v. masjienleer. Vir video’s word slegs die duimnael oorweeg. “Herlaai” (ver)werk al die media weer. “Stel terug” verwyder alle huidige gesigdata. “Onverwerk” plaas items in die ry wat nog nie verwerk is nie. Geïdentifiseerde gesigte sal ná voltooiing van Gesigidentifikasie vir Gesigherkenning in die ry geplaas word om hulle in bestaande of nuwe persone te groepeer.",
|
"face_detection_description": "Identifiseer die gesigte in media deur middel van masjienleer. Vir videos word slegs die duimnaelskets oorweeg. “Herlaai” (ver)werk al die media weer. “Stel terug” verwyder alle huidige gesigdata. “Onverwerk” plaas bates in die tou wat nog nie verwerk is nie. Geidentifiseerde gesigte sal ná voltooiing van Gesigidentifikasie vir Gesigherkenning in die tou geplaas word, om hulle in bestaande of nuwe persone te groepeer.",
|
||||||
"facial_recognition_job_description": "Groepeer gesigte in mense. Die stap is vinniger nadat Gesigherkenning klaar is. “Herstel” (her-)groepeer alle gesigte. “Vermiste” plaas gesigte in ry wat nie ’n persoon gekoppel het nie.",
|
"facial_recognition_job_description": "Groepeer gesigte in mense in. Die stap is vinniger nadat Gesig Deteksie klaar is. \"Herstel\" (her-)groepeer alle gesigte. \"Vermiste\" plaas gesigte in ry wat nie 'n persoon gekoppel het nie.",
|
||||||
"failed_job_command": "Bevel {command} het misluk vir taak: {job}",
|
"failed_job_command": "Opdrag {command} het misluk vir werk: {job}",
|
||||||
"force_delete_user_warning": "WAARSKUWING: Dit sal onmiddellik die gebruiker en alle items verwyder. Dit kan nie ontdaan word nie en die lêers kan nie herstel word nie.",
|
"force_delete_user_warning": "WAARSKUWING: Dit sal onmiddellik die gebruiker en alle bates verwyder. Dit kan nie ontdoen word nie en die lêers kan nie herstel word nie.",
|
||||||
"image_format": "Formaat",
|
"image_format": "Formaat",
|
||||||
"image_format_description": "WebP lewer kleiner lêers as JPEG, maar is stadiger om te enkodeer.",
|
"image_format_description": "WebP produseer kleiner lêers as JPEG, maar is stadiger om te enkodeer.",
|
||||||
"image_fullsize_description": "Volgrootte prent met geen metadata, gebruik wanner ingezoem",
|
"image_fullsize_description": "Vol grote prent met geen metadata, gebruik wanner ingezoem",
|
||||||
"image_fullsize_enabled": "Aktiveer spek van volgrootte prent",
|
"image_fullsize_enabled": "Skakel aan vol grote prent generasie",
|
||||||
"image_prefer_embedded_preview": "Verkies ingebedde voorskou",
|
"image_prefer_embedded_preview": "Verkies ingebedde voorskou",
|
||||||
"image_prefer_wide_gamut": "Verkies breëspektrum",
|
"image_prefer_wide_gamut": "Verkies wide gamut",
|
||||||
"image_prefer_wide_gamut_setting_description": "Gebruik Display P3 vir duimnaels. Dit behou die lewendheid van beelde met wye kleurruimtes beter, maar beelde kan anders verskyn op ou toestelle met ’n ou blaaierweergawe. sRGB-beelde gebruik steeds sRGB om kleurverskuiwings te voorkom.",
|
"image_prefer_wide_gamut_setting_description": "Gebruik Display P3 vir kleinkiekies. Dit behou die lewendheid van beelde met wye kleurruimtes beter, maar beelde kan anders verskyn op ou apparate met 'n ou blaaierweergawe. sRGB-beelde gebruik steeds sRGB om kleurverskuiwings te voorkom.",
|
||||||
"image_preview_description": "Mediumgrootte prent met gestroopte metadata, wat gebruik word wanneer ’n enkele item bekyk word en vir masjienleer",
|
"image_preview_description": "Mediumgrootte prent met gestroopte metadata, wat gebruik word wanneer 'n enkele bate bekyk word en vir masjienleer",
|
||||||
"image_preview_quality_description": "Voorskoukwaliteit van 1-100. Hoër is beter, maar lewer groter lêers en kan die toep vertraag. Die stel van ’n lae waarde kan masjienleerkwaliteit beïnvloed.",
|
"image_preview_quality_description": "Voorskou kwaliteit van 1-100. Hoër is beter, maar produseer groter lêers en kan app-reaksie verminder. Die stel van 'n lae waarde kan masjienleerkwaliteit beïnvloed.",
|
||||||
"image_preview_title": "Voorskou-instellings",
|
"image_preview_title": "Voorskou Instellings",
|
||||||
"image_quality": "Kwaliteit",
|
"image_quality": "Kwaliteit",
|
||||||
"image_resolution": "Resolusie",
|
"image_resolution": "Resolusie",
|
||||||
"image_resolution_description": "Hoër resolusies kan meer detail bewaar, maar neem langer om te enkodeer, het groter lêergroottes en kan die toep vertraag.",
|
"image_resolution_description": "Hoër resolusies kan meer detail bewaar, maar neem langer om te enkodeer, het groter lêergroottes en kan app-reaksie verminder.",
|
||||||
"image_settings": "Prentinstellings",
|
"image_settings": "Prent Instellings",
|
||||||
"image_settings_description": "Bestuur die kwaliteit en resolusie van gegenereerde beelde",
|
"image_settings_description": "Bestuur die kwaliteit en resolusie van gegenereerde beelde",
|
||||||
"image_thumbnail_description": "Klein duimnaels sonder metadata, gebruik om groepe foto’s soos die tydlyn te bekyk",
|
"image_thumbnail_description": "Klein kleinkiekies sonder metadata, gebruik om groepe foto's soos die tydlyn te bekyk",
|
||||||
"image_thumbnail_quality_description": "Duinmaelkwaliteit van 1-100. Hoër is beter, maar lewer groter lêers en kan die toep vertraag.",
|
"image_thumbnail_quality_description": "Kleinkiekiekwaliteit van 1-100. Hoër is beter, maar produseer groter lêers en kan die toepassing vertraag.",
|
||||||
"image_thumbnail_title": "Duimnaelinstellings",
|
"image_thumbnail_title": "Kleinkiekie-instellings",
|
||||||
"job_concurrency": "{job} gelyktydigheid",
|
"job_concurrency": "{job} gelyktydigheid",
|
||||||
"job_created": "Taak geskep",
|
"job_created": "Taak gemaak",
|
||||||
"job_not_concurrency_safe": "Hierdie taak kan nie gelyktydig uitgevoer word nie.",
|
"job_not_concurrency_safe": "Hierdie taak kan nie gelyktydig uitgevoer word nie.",
|
||||||
"job_settings": "Taakinstellings",
|
"job_settings": "Agtergrondtaakinstellings",
|
||||||
"job_settings_description": "Bestuur taakgelyktydigheid",
|
"job_settings_description": "Bestuur werkgelyktydigheid",
|
||||||
"library_created": "Biblioteek geskep: {library}",
|
"library_created": "Biblioteek geskep: {library}",
|
||||||
"library_deleted": "Biblioteek geskrap",
|
"library_deleted": "Biblioteek verwyder",
|
||||||
"library_scanning": "Periodieke skandering",
|
"library_scanning": "Periodieke Soek",
|
||||||
"library_scanning_description": "Stel periodieke skandering van biblioteek in",
|
"library_scanning_description": "Stel periodieke deursoek van biblioteek in",
|
||||||
"library_scanning_enable_description": "Aktiveer periodieke biblioteekskandering",
|
"library_scanning_enable_description": "Aktiveer periodieke biblioteekskandering",
|
||||||
"library_settings": "Eksterne biblioteek",
|
"library_settings": "Eksterne Biblioteek",
|
||||||
"library_settings_description": "Eksternebiblioteekinstellings",
|
"library_settings_description": "Eksterne biblioteek verstellings",
|
||||||
"library_tasks_description": "Skandeer eksterne biblioteke vir nuwe of veranderde items",
|
"library_tasks_description": "Deursoek eksterne biblioteke vir nuwe of veranderde bates",
|
||||||
"library_watching_enable_description": "Hou eksterne biblioteke dop vir lêerveranderinge",
|
"library_watching_enable_description": "Hou eksterne biblioteke dop vir leer veranderinge",
|
||||||
"library_watching_settings": "Biblioteekdophou [EKSPERIMENTEEL]",
|
"library_watching_settings": "Biblioteek dop hou (EKSPERIMENTEEL)",
|
||||||
"library_watching_settings_description": "Hou automaties dop vir veranderinge",
|
"library_watching_settings_description": "Hou automaties dop vir veranderinge",
|
||||||
"logging_enable_description": "Aktiveer logboekbyhouding",
|
"logging_enable_description": "Aktifeer \"logging\"",
|
||||||
"logging_level_description": "Wanneer aktief, welke logboekvlak om te gebruik.",
|
"logging_level_description": "Wanneer aktief, watter vlak van \"logs\" om te skep.",
|
||||||
"logging_settings": "Logboek",
|
"logging_settings": "\"Logs\"",
|
||||||
"machine_learning_clip_model": "CLIP-model",
|
"machine_learning_clip_model": "CLIP model",
|
||||||
"machine_learning_duplicate_detection": "Duplikaatbespeuring",
|
"machine_learning_duplicate_detection": "Duplikaat herkenning",
|
||||||
"machine_learning_duplicate_detection_enabled": "Aktiveer duplikaatbespeuring",
|
"machine_learning_duplicate_detection_enabled": "Aktifeer duplikaat herkenning",
|
||||||
"machine_learning_enabled": "Aktiveer masjienleer",
|
"machine_learning_enabled": "Aktifeer masjienleer",
|
||||||
"machine_learning_facial_recognition": "Gesigherkenning",
|
"machine_learning_facial_recognition": "Gesigsherkenning",
|
||||||
"machine_learning_facial_recognition_description": "Bespeur, identifiseer en groepeer gesigte in foto’s",
|
"machine_learning_facial_recognition_description": "Herken, identifiseer en groepeer gesigte in fotos",
|
||||||
"machine_learning_facial_recognition_model": "Gesigherkenningsmodel",
|
"machine_learning_facial_recognition_model": "Gesigsherkennings model",
|
||||||
"machine_learning_facial_recognition_setting": "Aktiveer gesigherkenning",
|
"machine_learning_facial_recognition_setting": "Aktifeer gesigsherkenning",
|
||||||
"machine_learning_max_detection_distance": "Maksimum herkenningsafstand",
|
"machine_learning_max_detection_distance": "Maksimum herkennings afstand",
|
||||||
"map_settings": "Kaart",
|
"map_settings": "Kaart",
|
||||||
"migration_job": "Migrasie",
|
"migration_job": "Migrasie",
|
||||||
"oauth_settings": "OAuth",
|
"oauth_settings": "OAuth",
|
||||||
"transcoding_acceleration_vaapi": "VAAPI",
|
"transcoding_acceleration_vaapi": "VAAPI",
|
||||||
"transcoding_preferred_hardware_device": "Voorkeurapparatuur"
|
"transcoding_preferred_hardware_device": "Verkiesde hardeware"
|
||||||
},
|
},
|
||||||
"administration": "Administrasie",
|
"administration": "Administrasie",
|
||||||
"advanced": "Gevorderd",
|
"advanced": "Gevorderde",
|
||||||
"albums": "Albums",
|
"albums": "Albums",
|
||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
"anti_clockwise": "Linksom",
|
"anti_clockwise": "Anti-kloksgewys",
|
||||||
"archive": "Argief",
|
"archive": "Argief",
|
||||||
"asset_skipped": "Oorgeslaan",
|
"asset_skipped": "Oorgeslaan",
|
||||||
"asset_uploaded": "Opgelaai",
|
"asset_uploaded": "Opgelaai",
|
||||||
"asset_uploading": "Laai tans op…",
|
"asset_uploading": "Oplaai…",
|
||||||
"assets": "Items",
|
"assets": "Bates",
|
||||||
"back": "Terug",
|
"back": "Terug",
|
||||||
"backward": "Agteruit",
|
"backward": "Agteruit",
|
||||||
"build": "Bou",
|
"build": "Bou",
|
||||||
"camera": "Kamera",
|
"camera": "Kamera",
|
||||||
"cancel": "Kanselleer",
|
"cancel": "Kanselleer",
|
||||||
"city": "Stad",
|
"city": "Stad",
|
||||||
"clockwise": "Regsom",
|
"clockwise": "Kloksgewys",
|
||||||
"close": "Sluit",
|
"close": "Maak toe",
|
||||||
"color": "Kleur",
|
"color": "Kleur",
|
||||||
"confirm": "Bevestig",
|
"confirm": "Bevestig",
|
||||||
"contain": "Bevat",
|
"contain": "Bevat",
|
||||||
@@ -154,153 +154,54 @@
|
|||||||
"created": "Geskep",
|
"created": "Geskep",
|
||||||
"dark": "Donker",
|
"dark": "Donker",
|
||||||
"day": "Dag",
|
"day": "Dag",
|
||||||
"delete": "Skrap",
|
"delete": "Verwyder",
|
||||||
"description": "Beskrywing",
|
"description": "Beskrywing",
|
||||||
"details": "Besonderhede",
|
"details": "Besonderhede",
|
||||||
"direction": "Rigting",
|
"direction": "Rigting",
|
||||||
"discover": "Ontdek",
|
"discover": "Ontdek",
|
||||||
"documentation": "Dokumentasie",
|
"documentation": "Dokumentasie",
|
||||||
"done": "Gereed",
|
"done": "Klaar",
|
||||||
"download": "Laai af",
|
"download": "Aflaai",
|
||||||
"download_settings": "Laai af",
|
"download_settings": "Aflaai",
|
||||||
"duplicates": "Duplikate",
|
"duplicates": "Duplikate",
|
||||||
"duration": "Duur",
|
"duration": "Duur",
|
||||||
"edit": "Wysig",
|
"edit": "Wysig",
|
||||||
"search_by_description": "Soek op beskrywing",
|
"search_by_description": "Soek by beskrywing",
|
||||||
"search_by_description_example": "Stapdag in Sapa",
|
"search_by_description_example": "Stapdag in Sapa",
|
||||||
"stacktrace": "Stapelnasporing",
|
|
||||||
"start": "Begin",
|
|
||||||
"start_date": "Begindatum",
|
|
||||||
"start_date_before_end_date": "Begindatum moet voor einddatum wees",
|
|
||||||
"state": "Staat",
|
|
||||||
"status": "Status",
|
|
||||||
"stop_casting": "Stop sending",
|
|
||||||
"stop_motion_photo": "Stop bewegingsfoto",
|
|
||||||
"stop_photo_sharing": "Staak die deel van u foto’s?",
|
|
||||||
"stop_photo_sharing_description": "{partner} sal nie meer toegang tot u foto’s hê nie.",
|
|
||||||
"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",
|
|
||||||
"up_next": "Volgende",
|
|
||||||
"update_location_action_prompt": "Werk die ligging van {count} gekose items by met:",
|
|
||||||
"updated_at": "Bygewerk",
|
|
||||||
"updated_password": "Wagwoord bygewerk",
|
|
||||||
"upload": "Laai op",
|
|
||||||
"upload_concurrency": "Aantal gelyktydige oplaaie",
|
|
||||||
"upload_details": "Oplaaidetails",
|
|
||||||
"upload_dialog_info": "Wil u ’n rugsteun maak van die gekose item(s) op die bediener?",
|
|
||||||
"upload_dialog_title": "Laai item op",
|
|
||||||
"upload_error_with_count": "Oplaaifout vir {count, plural, one {# item} other {# items}}",
|
|
||||||
"upload_errors": "Oplaai voltooi met {count, plural, one {# fout} other {# foute}}, verfris die blad om die nuwe items te sien.",
|
|
||||||
"upload_finished": "Klaar opgelaai",
|
|
||||||
"upload_progress": "Oorblywend {remaining, number} - Verwerk {processed, number}/{total, number}",
|
|
||||||
"upload_skipped_duplicates": "{count, plural, one {# duplikaat item} other {# duplikaat items}} oorgeslaan",
|
|
||||||
"upload_status_duplicates": "Duplikate",
|
|
||||||
"upload_status_errors": "Foute",
|
|
||||||
"upload_status_uploaded": "Opgelaai",
|
|
||||||
"upload_success": "Oplaai suksesvol, verfris die blad om nuut opgelaaide items te sien.",
|
|
||||||
"upload_to_immich": "Laai op na Immich ({count})",
|
|
||||||
"uploading": "Word opgelaai",
|
|
||||||
"uploading_media": "Media word opgelaai",
|
|
||||||
"url": "URL",
|
|
||||||
"usage": "Gebruik",
|
|
||||||
"use_biometric": "Gebruik biometrie",
|
|
||||||
"use_browser_locale": "Gebruik blaaier se landinstelling",
|
|
||||||
"use_browser_locale_description": "Formatteer datums, tye en getalle gebaseer op u blaaier se landinstelling",
|
|
||||||
"use_current_connection": "Gebruik huidige verbinding",
|
|
||||||
"use_custom_date_range": "Gebruik eerder pasgemaakte datumomvang",
|
|
||||||
"user": "Gebruiker",
|
|
||||||
"user_has_been_deleted": "Hierdie gebruiker is geskrap.",
|
|
||||||
"user_id": "Gebruiker ID",
|
|
||||||
"user_liked": "{user} het van {type, select, photo {hierdie foto} video {hierdie video} asset {} other {hierdie item}} gehou",
|
|
||||||
"user_pin_code_settings": "PIN-kode",
|
|
||||||
"user_pin_code_settings_description": "Bestuur u PIN-kode",
|
|
||||||
"user_privacy": "Gebruikersprivaatheid",
|
|
||||||
"user_purchase_settings": "Koop",
|
|
||||||
"user_purchase_settings_description": "Bestuur u aankoop",
|
|
||||||
"user_role_set": "Stel {user} in as {role}",
|
|
||||||
"user_usage_detail": "Gedetailleerde gebruik van gebruikers",
|
|
||||||
"user_usage_stats": "Statistieke vir rekeninggebruik",
|
|
||||||
"user_usage_stats_description": "Bekyk statistieke van rekeninggebruik",
|
|
||||||
"username": "Gebruikersnaam",
|
|
||||||
"users": "Gebruikers",
|
|
||||||
"users_added_to_album_count": "{count, plural, one {# Gebruiker} other {# Gebruikers}} tot album toegevoeg",
|
|
||||||
"utilities": "Gereedskap",
|
|
||||||
"validate": "Valideer",
|
|
||||||
"validate_endpoint_error": "Voer asb. ’n geldige bronadres in",
|
|
||||||
"validation_error": "Valideerfout",
|
|
||||||
"variables": "Veranderlikes",
|
|
||||||
"version": "Weergawe",
|
"version": "Weergawe",
|
||||||
"version_announcement_closing": "Jou friend, Alex",
|
"version_announcement_closing": "Jou friend, Alex",
|
||||||
"version_announcement_message": "Hallo! Daar is ’n nuwe weergawe van Immich beskikbaar. Neem gerus bietjie tyd om die <link>vrystellingsnotas</link> te lees en maak seker u opstelling is op datum om wanopstellings te voorkom, veral as u WatchTower of ’n ander bywerkmeganisme gebruik.",
|
|
||||||
"version_history": "Weergawegeskiedenis",
|
"version_history": "Weergawegeskiedenis",
|
||||||
"version_history_item": "{version} geïnstaleer op {date}",
|
"version_history_item": "{version} geinstaleerd op {date}",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"video_hover_setting": "Speel videoduimnael by muishang",
|
"videos": "Video's",
|
||||||
"video_hover_setting_description": "Speel videoduimnael wanneer muis oor item hang. Selfs indien gedeaktiveer kan afspeel begin deur oor die afspeelknop te hang.",
|
|
||||||
"videos": "Video’s",
|
|
||||||
"videos_count": "{count, plural, one {# video} other {# video’s}}",
|
|
||||||
"videos_only": "Slegs video’s",
|
|
||||||
"view": "Bekyk",
|
"view": "Bekyk",
|
||||||
"view_album": "Bekyk album",
|
"view_album": "Bekyk Album",
|
||||||
"view_all": "Bekyk alle",
|
"view_all": "Bekyk alle",
|
||||||
"view_all_users": "Bekyk alle gebruikers",
|
"view_all_users": "Bekyk alle gebruikers",
|
||||||
"view_asset_owners": "Bekyk itemeienaars",
|
|
||||||
"view_details": "Bekyk detail",
|
|
||||||
"view_in_timeline": "Bekyk in tydlyn",
|
"view_in_timeline": "Bekyk in tydlyn",
|
||||||
"view_link": "Bekyk skakel",
|
"view_link": "Bekyk skakel",
|
||||||
"view_links": "Bekyk skakels",
|
"view_links": "Bekyk skakels",
|
||||||
"view_name": "Bekyk",
|
"view_name": "Bekyk",
|
||||||
"view_next_asset": "Bekyk volgende item",
|
"view_next_asset": "Bekyk volgende bate",
|
||||||
"view_previous_asset": "Bekyk vorige item",
|
"view_previous_asset": "Bekyk vorige bate",
|
||||||
"view_qr_code": "Bekyk QR-kode",
|
"view_qr_code": "Bekyk QR-kode",
|
||||||
"view_similar_photos": "Bekyk soortgelyke foto’s",
|
|
||||||
"view_stack": "Bekyk stapel",
|
"view_stack": "Bekyk stapel",
|
||||||
"view_user": "Bekyk gebruiker",
|
"view_user": "Bekyk gebruiker",
|
||||||
"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 hoofbate",
|
||||||
"viewer_unstack": "Ontstapel",
|
"viewer_unstack": "Ontstapel",
|
||||||
"visibility": "Sigbaarheid",
|
"visibility_changed": "Sigbaarheid verander voor {count, plural, one {# person} other {# people}}",
|
||||||
"visibility_changed": "Sigbaarheid verander vir {count, plural, one {# mens} other {# mense}}",
|
|
||||||
"visual": "Visueel",
|
|
||||||
"visual_builder": "Visuele bouer",
|
|
||||||
"waiting": "Wag",
|
"waiting": "Wag",
|
||||||
"waiting_count": "Wagtend: {count}",
|
"warning": "Waaskuwing",
|
||||||
"warning": "Waarskuwing",
|
|
||||||
"week": "Week",
|
"week": "Week",
|
||||||
"welcome": "Welkom",
|
"welcome": "Welkom",
|
||||||
"welcome_to_immich": "Welkom by Immich",
|
"welcome_to_immich": "Welkom by Immich",
|
||||||
"width": "Breedte",
|
"wifi_name": "Wi-Fi Naam",
|
||||||
"wifi_name": "Wi-Fi-naam",
|
|
||||||
"workflow_delete_prompt": "Is u seker u wil hierdie werkvloei skrap?",
|
|
||||||
"workflow_deleted": "Werkvloei geskrap",
|
|
||||||
"workflow_description": "Werkvloeibeskrywing",
|
|
||||||
"workflow_info": "Werkvloei-inligting",
|
|
||||||
"workflow_json": "Werkvloei-JSON",
|
|
||||||
"workflow_json_help": "Wysig die werkvloei-opstelling in JSON-formaat. Veranderinge sal na die visuele bouer sinchroniseer.",
|
|
||||||
"workflow_name": "Werkvloeinaam",
|
|
||||||
"workflow_navigation_prompt": "Is u seker u wil verlaat sonder om u veranderinge te bewaar?",
|
|
||||||
"workflow_summary": "Werkvloei-opsomming",
|
|
||||||
"workflow_update_success": "Werkvloei suksesvol bygewerk",
|
|
||||||
"workflow_updated": "Werkvloei bygewerk",
|
|
||||||
"workflows": "Werkvloeie",
|
|
||||||
"workflows_help_text": "Werkvloeie outomatiseer aksies op u items gebaseer op snellers en filters",
|
|
||||||
"wrong_pin_code": "Verkeerde PIN-kode",
|
"wrong_pin_code": "Verkeerde PIN-kode",
|
||||||
"year": "Jaar",
|
"year": "Jaar",
|
||||||
"years_ago": "{years, plural, one {# jaar} other {# jaar}} gelede",
|
"years_ago": "{years, plural, one {# year} other {# years}} gelede",
|
||||||
"yes": "Ja",
|
"yes": "Ja",
|
||||||
"you_dont_have_any_shared_links": "U het geen gedeelde skakels nie",
|
"you_dont_have_any_shared_links": "Jy het geen gedeelde skakels",
|
||||||
"your_wifi_name": "U Wi-Fi-naam",
|
"your_wifi_name": "Jou Wi-Fi naam",
|
||||||
"zero_to_clear_rating": "druk 0 om itemgradering te wis",
|
"zoom_image": "Vergroot Prent"
|
||||||
"zoom_image": "Zoem in",
|
|
||||||
"zoom_to_bounds": "Zoem na rande"
|
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-50
@@ -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": "لمزيد من المعلومات حول النسخ الاحتياطي لـ <link>Immich</link>، يرجى الرجوع إلى <link>الوثائق</link>.",
|
"backup_onboarding_footer": "لمزيد من المعلومات حول النسخ الاحتياطي لـ Immich، يرجى الرجوع إلى <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": "إعدادات تفريغ قاعدة البيانات",
|
||||||
@@ -311,7 +311,7 @@
|
|||||||
"search_jobs": "البحث عن وظائف…",
|
"search_jobs": "البحث عن وظائف…",
|
||||||
"send_welcome_email": "إرسال بريد ترحيبي",
|
"send_welcome_email": "إرسال بريد ترحيبي",
|
||||||
"server_external_domain_settings": "إسم النطاق الخارجي",
|
"server_external_domain_settings": "إسم النطاق الخارجي",
|
||||||
"server_external_domain_settings_description": "النطاق مستخدم لروابط خارجية",
|
"server_external_domain_settings_description": "إسم النطاق لروابط المشاركة العامة، بما في ذلك http(s)://",
|
||||||
"server_public_users": "المستخدمون العامون",
|
"server_public_users": "المستخدمون العامون",
|
||||||
"server_public_users_description": "يتم إدراج جميع المستخدمين (الاسم والبريد الإلكتروني) عند إضافة مستخدم إلى الألبومات المشتركة. عند تعطيل هذه الميزة، ستكون قائمة المستخدمين متاحة فقط لمستخدمي الإدارة.",
|
"server_public_users_description": "يتم إدراج جميع المستخدمين (الاسم والبريد الإلكتروني) عند إضافة مستخدم إلى الألبومات المشتركة. عند تعطيل هذه الميزة، ستكون قائمة المستخدمين متاحة فقط لمستخدمي الإدارة.",
|
||||||
"server_settings": "إعدادات الخادم",
|
"server_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> و <vp9-link>VP9 codec</vp9-link>.",
|
"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_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)",
|
||||||
@@ -411,7 +411,7 @@
|
|||||||
"transcoding_tone_mapping": "رسم الخرائط النغمية",
|
"transcoding_tone_mapping": "رسم الخرائط النغمية",
|
||||||
"transcoding_tone_mapping_description": "تحاول الحفاظ على مظهر مقاطع الفيديو HDR عند تحويلها إلى SDR. يقدم كل خوارزمية تنازلات مختلفة بين اللون والتفاصيل والسطوع. Hable تحافظ على التفاصيل، Mobius تحافظ على الألوان، و Reinhard تحافظ على السطوع.",
|
"transcoding_tone_mapping_description": "تحاول الحفاظ على مظهر مقاطع الفيديو HDR عند تحويلها إلى SDR. يقدم كل خوارزمية تنازلات مختلفة بين اللون والتفاصيل والسطوع. Hable تحافظ على التفاصيل، Mobius تحافظ على الألوان، و Reinhard تحافظ على السطوع.",
|
||||||
"transcoding_transcode_policy": "سياسة الترميز",
|
"transcoding_transcode_policy": "سياسة الترميز",
|
||||||
"transcoding_transcode_policy_description": "سياسة تحديد متى يجب ترميز الفيديو. سيتم دائمًا ترميز مقاطع الفيديو HDR و مقاطع الفديو اللتي تستدخم تنسيق غير YUV 4:2:0. (ما لم يتم تعطيل الترميز).",
|
"transcoding_transcode_policy_description": "سياسة تحديد متى يجب ترميز الفيديو. سيتم دائمًا ترميز مقاطع الفيديو HDR (ما لم يتم تعطيل الترميز).",
|
||||||
"transcoding_two_pass_encoding": "الترميز بمرورين",
|
"transcoding_two_pass_encoding": "الترميز بمرورين",
|
||||||
"transcoding_two_pass_encoding_setting_description": "ترميز بمرورين لإنتاج مقاطع فيديو بترميز أفضل. عند تمكين الحد الأقصى لمعدل البت (مطلوب لكي يعمل مع H.264 و HEVC)، يستخدم هذا الوضع نطاق معدل البت استنادًا إلى الحد الأقصى لمعدل البت ويتجاهل CRF. بالنسبة لـ VP9، يمكن استخدام CRF إذا تم تعطيل الحد الأقصى لمعدل البت.",
|
"transcoding_two_pass_encoding_setting_description": "ترميز بمرورين لإنتاج مقاطع فيديو بترميز أفضل. عند تمكين الحد الأقصى لمعدل البت (مطلوب لكي يعمل مع H.264 و HEVC)، يستخدم هذا الوضع نطاق معدل البت استنادًا إلى الحد الأقصى لمعدل البت ويتجاهل CRF. بالنسبة لـ VP9، يمكن استخدام CRF إذا تم تعطيل الحد الأقصى لمعدل البت.",
|
||||||
"transcoding_video_codec": "ترميز الفيديو",
|
"transcoding_video_codec": "ترميز الفيديو",
|
||||||
@@ -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": "تعتمد ميزة التحقق من الإصدار على التواصل الدوري مع {server}",
|
"version_check_implications": "تعتمد ميزة التحقق من الإصدار على التواصل الدوري مع github.com",
|
||||||
"version_check_settings": "التحقق من الإصدار",
|
"version_check_settings": "التحقق من الإصدار",
|
||||||
"version_check_settings_description": "تفعيل/تعطيل الإشعار لإصدار جديد",
|
"version_check_settings_description": "تفعيل/تعطيل الإشعار لإصدار جديد",
|
||||||
"video_conversion_job": "تحويل أشرطة الفيديو",
|
"video_conversion_job": "تحويل أشرطة الفيديو",
|
||||||
@@ -794,11 +794,6 @@
|
|||||||
"color": "اللون",
|
"color": "اللون",
|
||||||
"color_theme": "نمط الألوان",
|
"color_theme": "نمط الألوان",
|
||||||
"command": "امر",
|
"command": "امر",
|
||||||
"command_palette_prompt": "اعثر بسرعة على الصفحات أو الإجراءات أو الأوامر",
|
|
||||||
"command_palette_to_close": "للاغلاق",
|
|
||||||
"command_palette_to_navigate": "للدخول",
|
|
||||||
"command_palette_to_select": "للاختيار",
|
|
||||||
"command_palette_to_show_all": "لعرض الكل",
|
|
||||||
"comment_deleted": "تم حذف التعليق",
|
"comment_deleted": "تم حذف التعليق",
|
||||||
"comment_options": "خيارات التعليق",
|
"comment_options": "خيارات التعليق",
|
||||||
"comments_and_likes": "التعليقات والإعجابات",
|
"comments_and_likes": "التعليقات والإعجابات",
|
||||||
@@ -849,12 +844,9 @@
|
|||||||
"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": "انشاء رابط مشترك",
|
||||||
@@ -869,14 +861,13 @@
|
|||||||
"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 الحالي",
|
||||||
"current_server_address": "عنوان الخادم الحالي",
|
"current_server_address": "عنوان الخادم الحالي",
|
||||||
"custom_date": "تاريخ مخصص",
|
"custom_date": "تاريخ مخصص",
|
||||||
"custom_locale": "لغة مخصصة",
|
"custom_locale": "لغة مخصصة",
|
||||||
"custom_locale_description": "تنسيق التواريخ, الأوقات والأرقام بناءً على اللغة والمنطقة المختاره",
|
"custom_locale_description": "تنسيق التواريخ والأرقام بناءً على اللغة والمنطقة",
|
||||||
"custom_url": "رابط مخصص",
|
"custom_url": "رابط مخصص",
|
||||||
"cutoff_date_description": "احتفظ بالصور من آخر…",
|
"cutoff_date_description": "احتفظ بالصور من آخر…",
|
||||||
"cutoff_day": "{count, plural, one {يوم} other {ايام}}",
|
"cutoff_day": "{count, plural, one {يوم} other {ايام}}",
|
||||||
@@ -884,7 +875,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": "التاريخ و الوقت",
|
||||||
@@ -895,8 +886,12 @@
|
|||||||
"day": "يوم",
|
"day": "يوم",
|
||||||
"days": "ايام",
|
"days": "ايام",
|
||||||
"deduplicate_all": "إلغاء تكرار الكل",
|
"deduplicate_all": "إلغاء تكرار الكل",
|
||||||
"default_locale": "الإعدادات المحلية الافتراضية",
|
"deduplication_criteria_1": "حجم الصورة بوحدات البايت",
|
||||||
"default_locale_description": "تنسيق التواريخ والأرقام بناءً على الإعدادات المحلية للمتصفح",
|
"deduplication_criteria_2": "عدد بيانات EXIF",
|
||||||
|
"deduplication_info": "معلومات إلغاء البيانات المكررة",
|
||||||
|
"deduplication_info_description": "لتحديد الأصول مسبقا تلقائيا وإزالة التكرارات بكميات كبيرة، ننظر إلى:",
|
||||||
|
"default_locale": "اللغة الافتراضية",
|
||||||
|
"default_locale_description": "تنسيق التواريخ والأرقام بناءً على لغة المتصفح الخاص بك",
|
||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
"delete_action_confirmation_message": "هل انت متأكد من حذف هذا الملف؟ هذا سؤدي الى نقل الملف الى سلة مهملات الخادم وسيتم اشعارك ان كنت تريد حذفه على الجهاز",
|
"delete_action_confirmation_message": "هل انت متأكد من حذف هذا الملف؟ هذا سؤدي الى نقل الملف الى سلة مهملات الخادم وسيتم اشعارك ان كنت تريد حذفه على الجهاز",
|
||||||
"delete_action_prompt": "تم حذف {count}",
|
"delete_action_prompt": "تم حذف {count}",
|
||||||
@@ -972,7 +967,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": "تعديل الألبوم",
|
||||||
@@ -1009,8 +1004,6 @@
|
|||||||
"editor_edits_applied_success": "تم تطبيق التعديلات بنجاح",
|
"editor_edits_applied_success": "تم تطبيق التعديلات بنجاح",
|
||||||
"editor_flip_horizontal": "اقلب أفقيًا",
|
"editor_flip_horizontal": "اقلب أفقيًا",
|
||||||
"editor_flip_vertical": "اقلب عموديًا",
|
"editor_flip_vertical": "اقلب عموديًا",
|
||||||
"editor_handle_corner": "{corner, select, top_left {أعلى اليسار} top_right {أعلى اليمين} bottom_left {أسفل اليسار} bottom_right {أسفل اليمين} other {أخري}} corner handle",
|
|
||||||
"editor_handle_edge": "{edge, select, top {أعلي} bottom {أسفل} left {يسار} right {يمين} other {أخري}} edge handle",
|
|
||||||
"editor_orientation": "اتجاه",
|
"editor_orientation": "اتجاه",
|
||||||
"editor_reset_all_changes": "اعادة ظبط التغييرات",
|
"editor_reset_all_changes": "اعادة ظبط التغييرات",
|
||||||
"editor_rotate_left": "أدر 90° عكس اتجاه عقارب الساعة",
|
"editor_rotate_left": "أدر 90° عكس اتجاه عقارب الساعة",
|
||||||
@@ -1076,7 +1069,6 @@
|
|||||||
"failed_to_update_notification_status": "فشل في تحديث حالة الإشعار",
|
"failed_to_update_notification_status": "فشل في تحديث حالة الإشعار",
|
||||||
"incorrect_email_or_password": "بريد أو كلمة مرور غير صحيحة",
|
"incorrect_email_or_password": "بريد أو كلمة مرور غير صحيحة",
|
||||||
"library_folder_already_exists": "مسار الاستيراد موجود بالفعل.",
|
"library_folder_already_exists": "مسار الاستيراد موجود بالفعل.",
|
||||||
"page_not_found": "الصفحة غير موجودة",
|
|
||||||
"paths_validation_failed": "فشل في التحقق من {paths, plural, one {# مسار} other {# مسارات}}",
|
"paths_validation_failed": "فشل في التحقق من {paths, plural, one {# مسار} other {# مسارات}}",
|
||||||
"profile_picture_transparent_pixels": "لا يمكن أن تحتوي صور الملف الشخصي على أجزاء/بكسلات شفافة. يرجى التكبير و/أو تحريك الصورة.",
|
"profile_picture_transparent_pixels": "لا يمكن أن تحتوي صور الملف الشخصي على أجزاء/بكسلات شفافة. يرجى التكبير و/أو تحريك الصورة.",
|
||||||
"quota_higher_than_disk_size": "لقد قمت بتعيين حصة نسبية أعلى من حجم القرص",
|
"quota_higher_than_disk_size": "لقد قمت بتعيين حصة نسبية أعلى من حجم القرص",
|
||||||
@@ -1176,7 +1168,6 @@
|
|||||||
"exif_bottom_sheet_people": "الناس",
|
"exif_bottom_sheet_people": "الناس",
|
||||||
"exif_bottom_sheet_person_add_person": "اضف اسما",
|
"exif_bottom_sheet_person_add_person": "اضف اسما",
|
||||||
"exit_slideshow": "خروج من العرض التقديمي",
|
"exit_slideshow": "خروج من العرض التقديمي",
|
||||||
"expand": "توسعة",
|
|
||||||
"expand_all": "توسيع الكل",
|
"expand_all": "توسيع الكل",
|
||||||
"experimental_settings_new_asset_list_subtitle": "أعمال جارية",
|
"experimental_settings_new_asset_list_subtitle": "أعمال جارية",
|
||||||
"experimental_settings_new_asset_list_title": "تمكين شبكة الصور التجريبية",
|
"experimental_settings_new_asset_list_title": "تمكين شبكة الصور التجريبية",
|
||||||
@@ -1221,7 +1212,6 @@
|
|||||||
"filter_description": "شروط تصفية الأصول المستهدفة",
|
"filter_description": "شروط تصفية الأصول المستهدفة",
|
||||||
"filter_people": "تصفية الاشخاص",
|
"filter_people": "تصفية الاشخاص",
|
||||||
"filter_places": "تصفية الاماكن",
|
"filter_places": "تصفية الاماكن",
|
||||||
"filter_tags": "تصفية العلامات",
|
|
||||||
"filters": "التصفيات",
|
"filters": "التصفيات",
|
||||||
"find_them_fast": "يمكنك العثور عليها بسرعة بالاسم من خلال البحث",
|
"find_them_fast": "يمكنك العثور عليها بسرعة بالاسم من خلال البحث",
|
||||||
"first": "الاول",
|
"first": "الاول",
|
||||||
@@ -1389,11 +1379,9 @@
|
|||||||
"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": "قائمة",
|
||||||
@@ -1654,8 +1642,6 @@
|
|||||||
"online": "متصل",
|
"online": "متصل",
|
||||||
"only_favorites": "المفضلة فقط",
|
"only_favorites": "المفضلة فقط",
|
||||||
"open": "فتح",
|
"open": "فتح",
|
||||||
"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": "افتح مرشحات البحث",
|
||||||
@@ -1815,8 +1801,9 @@
|
|||||||
"rate_asset": "تقييم الاصل",
|
"rate_asset": "تقييم الاصل",
|
||||||
"rating": "تقييم نجمي",
|
"rating": "تقييم نجمي",
|
||||||
"rating_clear": "مسح التقييم",
|
"rating_clear": "مسح التقييم",
|
||||||
"rating_count": "{count, plural, =0 {Unrated} one {# نجمة} other {# نجوم}}",
|
"rating_count": "{count, plural, one {# نجمة} other {# نجوم}}",
|
||||||
"rating_description": "اعرض تقييم EXIF في لوحة المعلومات",
|
"rating_description": "اعرض تقييم EXIF في لوحة المعلومات",
|
||||||
|
"rating_set": "تم تحديد التصنيف {rating, plural, one {# نجمة} other {# نجوم}}",
|
||||||
"reaction_options": "خيارات رد الفعل",
|
"reaction_options": "خيارات رد الفعل",
|
||||||
"read_changelog": "قراءة سجل التغيير",
|
"read_changelog": "قراءة سجل التغيير",
|
||||||
"readonly_mode_disabled": "تم تعطيل وضع القراءة فقط",
|
"readonly_mode_disabled": "تم تعطيل وضع القراءة فقط",
|
||||||
@@ -1888,10 +1875,7 @@
|
|||||||
"reset_pin_code_success": "تم اعادة تعيين رمز الPIN بنجاح",
|
"reset_pin_code_success": "تم اعادة تعيين رمز الPIN بنجاح",
|
||||||
"reset_pin_code_with_password": "يمكنك دائما اعادة تعيين رمز الPIN الخاص بك عن طريق كلمة المرور الخاصة بك",
|
"reset_pin_code_with_password": "يمكنك دائما اعادة تعيين رمز الPIN الخاص بك عن طريق كلمة المرور الخاصة بك",
|
||||||
"reset_sqlite": "إعادة تعيين قاعدة بيانات SQLite",
|
"reset_sqlite": "إعادة تعيين قاعدة بيانات SQLite",
|
||||||
"reset_sqlite_clear_app_data": "مسح البيانات",
|
"reset_sqlite_confirmation": "هل أنت متأكد من رغبتك في إعادة ضبط قاعدة بيانات SQLite؟ ستحتاج إلى تسجيل الخروج ثم تسجيل الدخول مرة أخرى لإعادة مزامنة البيانات",
|
||||||
"reset_sqlite_confirmation": "هل أنت متأكد من رغبتك في حذف ضبط بيانات التطبيق؟ سيؤدي هذا إلى إزالة جميع الإعدادات وتسجيل خروجك.",
|
|
||||||
"reset_sqlite_confirmation_note": "ملاحظة: سيتعين عليك إعادة تشغيل التطبيق بعد المسح.",
|
|
||||||
"reset_sqlite_done": "تم مسح بيانات التطبيق. يرجى إعادة تشغيل تطبيق Immich وتسجيل الدخول مرة أخرى.",
|
|
||||||
"reset_sqlite_success": "تم إعادة تعيين قاعدة بيانات SQLite بنجاح",
|
"reset_sqlite_success": "تم إعادة تعيين قاعدة بيانات SQLite بنجاح",
|
||||||
"reset_to_default": "إعادة التعيين إلى الافتراضي",
|
"reset_to_default": "إعادة التعيين إلى الافتراضي",
|
||||||
"resolution": "دقة",
|
"resolution": "دقة",
|
||||||
@@ -1919,7 +1903,6 @@
|
|||||||
"saved_settings": "تم حفظ الإعدادات",
|
"saved_settings": "تم حفظ الإعدادات",
|
||||||
"say_something": "قل شيئًا",
|
"say_something": "قل شيئًا",
|
||||||
"scaffold_body_error_occurred": "حدث خطأ",
|
"scaffold_body_error_occurred": "حدث خطأ",
|
||||||
"scaffold_body_error_unrecoverable": "حدث خطأ لا يمكن إصلاحه. يرجى مشاركة تفاصيل الخطأ وتسلسل الأخطاء على Discord أو GitHub حتى نتمكن من مساعدتك. إذا طُلب منك ذلك، يمكنك مسح بيانات التطبيق أدناه.",
|
|
||||||
"scan": "بحث",
|
"scan": "بحث",
|
||||||
"scan_all_libraries": "فحص كل المكتبات",
|
"scan_all_libraries": "فحص كل المكتبات",
|
||||||
"scan_library": "مسح",
|
"scan_library": "مسح",
|
||||||
@@ -1955,7 +1938,6 @@
|
|||||||
"search_filter_ocr": "البحث عن طريق التعرف البصري على الحروف",
|
"search_filter_ocr": "البحث عن طريق التعرف البصري على الحروف",
|
||||||
"search_filter_people_title": "اختر الاشخاص",
|
"search_filter_people_title": "اختر الاشخاص",
|
||||||
"search_filter_star_rating": "تقييم النجوم",
|
"search_filter_star_rating": "تقييم النجوم",
|
||||||
"search_filter_tags_title": "تحديد العلامات",
|
|
||||||
"search_for": "البحث عن",
|
"search_for": "البحث عن",
|
||||||
"search_for_existing_person": "البحث عن شخص موجود",
|
"search_for_existing_person": "البحث عن شخص موجود",
|
||||||
"search_no_more_result": "لا توجد نتائج اضافية",
|
"search_no_more_result": "لا توجد نتائج اضافية",
|
||||||
@@ -2035,9 +2017,6 @@
|
|||||||
"set_profile_picture": "تحديد صورة الملف الشخصي",
|
"set_profile_picture": "تحديد صورة الملف الشخصي",
|
||||||
"set_slideshow_to_fullscreen": "تحديد عرض الشرائح على وضع ملء الشاشة",
|
"set_slideshow_to_fullscreen": "تحديد عرض الشرائح على وضع ملء الشاشة",
|
||||||
"set_stack_primary_asset": "تعيين كأصل اساسي",
|
"set_stack_primary_asset": "تعيين كأصل اساسي",
|
||||||
"setting_image_navigation_enable_subtitle": "في حال تم التفعيل، يمكنك الانتقال إلى الصورة السابقة أو التالية عن طريق النقر على الربع الأيسر أو الربع الأيمن من الشاشة.",
|
|
||||||
"setting_image_navigation_enable_title": "النقر للتنقل",
|
|
||||||
"setting_image_navigation_title": "التنقل بين الصور",
|
|
||||||
"setting_image_viewer_help": "يقوم عارض التفاصيل بتحميل الصورة المصغرة الصغيرة أولاً ، ثم يقوم بتحميل المعاينة متوسطة الحجم (إذا تم تمكينها) ، ويقوم أخيرًا بتحميل الأصل (إذا تم تمكينه).",
|
"setting_image_viewer_help": "يقوم عارض التفاصيل بتحميل الصورة المصغرة الصغيرة أولاً ، ثم يقوم بتحميل المعاينة متوسطة الحجم (إذا تم تمكينها) ، ويقوم أخيرًا بتحميل الأصل (إذا تم تمكينه).",
|
||||||
"setting_image_viewer_original_subtitle": "تمكين تحميل الصورة الكاملة الدقة الأصلية (كبيرة!).تعطيل لتقليل استخدام البيانات (كل من الشبكة وعلى ذاكرة التخزين المؤقت للجهاز).",
|
"setting_image_viewer_original_subtitle": "تمكين تحميل الصورة الكاملة الدقة الأصلية (كبيرة!).تعطيل لتقليل استخدام البيانات (كل من الشبكة وعلى ذاكرة التخزين المؤقت للجهاز).",
|
||||||
"setting_image_viewer_original_title": "تحميل الصورة الأصلية",
|
"setting_image_viewer_original_title": "تحميل الصورة الأصلية",
|
||||||
@@ -2204,7 +2183,6 @@
|
|||||||
"support": "الدعم",
|
"support": "الدعم",
|
||||||
"support_and_feedback": "الدعم والتعليقات",
|
"support_and_feedback": "الدعم والتعليقات",
|
||||||
"support_third_party_description": "تم حزم تثبيت immich الخاص بك بواسطة جهة خارجية. قد تكون المشكلات التي تواجهها ناجمة عن هذه الحزمة، لذا يرجى طرح المشكلات معهم في المقام الأول باستخدام الروابط أدناه.",
|
"support_third_party_description": "تم حزم تثبيت immich الخاص بك بواسطة جهة خارجية. قد تكون المشكلات التي تواجهها ناجمة عن هذه الحزمة، لذا يرجى طرح المشكلات معهم في المقام الأول باستخدام الروابط أدناه.",
|
||||||
"supporter": "داعم",
|
|
||||||
"swap_merge_direction": "تبديل اتجاه الدمج",
|
"swap_merge_direction": "تبديل اتجاه الدمج",
|
||||||
"sync": "مزامنة",
|
"sync": "مزامنة",
|
||||||
"sync_albums": "مزامنة الالبومات",
|
"sync_albums": "مزامنة الالبومات",
|
||||||
@@ -2217,7 +2195,6 @@
|
|||||||
"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": "علِّم الأشخاص",
|
||||||
@@ -2317,7 +2294,6 @@
|
|||||||
"unstack_action_prompt": "تم ازالة تكديس {count}",
|
"unstack_action_prompt": "تم ازالة تكديس {count}",
|
||||||
"unstacked_assets_count": "تم إخراج {count, plural, one {# الأصل} other {# الأصول}} من التكديس",
|
"unstacked_assets_count": "تم إخراج {count, plural, one {# الأصل} other {# الأصول}} من التكديس",
|
||||||
"unsupported_field_type": "نوع حقل غير مدعوم",
|
"unsupported_field_type": "نوع حقل غير مدعوم",
|
||||||
"unsupported_file_type": "لا يمكن رفع الملف {file} لأن نوع الملف {type} غير مدعوم.",
|
|
||||||
"untagged": "غير مُعَلَّم",
|
"untagged": "غير مُعَلَّم",
|
||||||
"untitled_workflow": "خطة سير عمل بدون عنوان",
|
"untitled_workflow": "خطة سير عمل بدون عنوان",
|
||||||
"up_next": "التالي",
|
"up_next": "التالي",
|
||||||
@@ -2344,8 +2320,6 @@
|
|||||||
"url": "عنوان URL",
|
"url": "عنوان URL",
|
||||||
"usage": "الاستخدام",
|
"usage": "الاستخدام",
|
||||||
"use_biometric": "استخدم البايومتري",
|
"use_biometric": "استخدم البايومتري",
|
||||||
"use_browser_locale": "استخدم لغه للمتصفح",
|
|
||||||
"use_browser_locale_description": "تنسيق التواريخ والأوقات والأرقام وفقًا لإعدادات اللغة في متصفحك",
|
|
||||||
"use_current_connection": "استخدم الاتصال الحالي",
|
"use_current_connection": "استخدم الاتصال الحالي",
|
||||||
"use_custom_date_range": "استخدم النطاق الزمني المخصص بدلاً من ذلك",
|
"use_custom_date_range": "استخدم النطاق الزمني المخصص بدلاً من ذلك",
|
||||||
"user": "مستخدم",
|
"user": "مستخدم",
|
||||||
@@ -2392,14 +2366,13 @@
|
|||||||
"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": "اداة نشاء مرئية",
|
||||||
@@ -2411,14 +2384,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
-24
@@ -104,8 +104,6 @@
|
|||||||
"image_preview_description": "Відарыс сярэдняга памеру з выдаленымі метаданымі, выкарыстоўваецца пры праглядзе асобнага рэсурсу і для машыннага навучання",
|
"image_preview_description": "Відарыс сярэдняга памеру з выдаленымі метаданымі, выкарыстоўваецца пры праглядзе асобнага рэсурсу і для машыннага навучання",
|
||||||
"image_preview_quality_description": "Якасць праявы ад 1 да 100. Чым вышэй, тым лепш, але пры гэтым ствараюцца файлы большага памеру і можа знізіцца хуткасць водгуку прыкладання. Ўстаноўка нізкага значэння можа паўплываць на якасць машыннага навучання.",
|
"image_preview_quality_description": "Якасць праявы ад 1 да 100. Чым вышэй, тым лепш, але пры гэтым ствараюцца файлы большага памеру і можа знізіцца хуткасць водгуку прыкладання. Ўстаноўка нізкага значэння можа паўплываць на якасць машыннага навучання.",
|
||||||
"image_preview_title": "Налады папярэдняга прагляду",
|
"image_preview_title": "Налады папярэдняга прагляду",
|
||||||
"image_progressive": "Прагрэсіўны",
|
|
||||||
"image_progressive_description": "Выявы з прагрэсіўным кодаваннем загружаюцца хутчэй, паступова паляпшаецца якасць. Налада не ўплывае на выяву ў фармаце WebP.",
|
|
||||||
"image_quality": "Якасць",
|
"image_quality": "Якасць",
|
||||||
"image_resolution": "Раздзяляльнасць",
|
"image_resolution": "Раздзяляльнасць",
|
||||||
"image_resolution_description": "Больш высокая раздзяляльнасць дазваляе захаваць больш дэталяў, але патрабуе больш часу для кадавання, прыводзіць да павялічвання памеру файлаў і можа знізіць хуткасць водгуку дадатку.",
|
"image_resolution_description": "Больш высокая раздзяляльнасць дазваляе захаваць больш дэталяў, але патрабуе больш часу для кадавання, прыводзіць да павялічвання памеру файлаў і можа знізіць хуткасць водгуку дадатку.",
|
||||||
@@ -122,7 +120,6 @@
|
|||||||
"job_settings_description": "Кіраваць наладамі паралельнага выканання заданняў",
|
"job_settings_description": "Кіраваць наладамі паралельнага выканання заданняў",
|
||||||
"jobs_delayed": "{jobCount, plural, other {# адкладзена}}",
|
"jobs_delayed": "{jobCount, plural, other {# адкладзена}}",
|
||||||
"jobs_failed": "{jobCount, plural, other {# не выканалася}}",
|
"jobs_failed": "{jobCount, plural, other {# не выканалася}}",
|
||||||
"jobs_over_time": "Графік апрацоўкі",
|
|
||||||
"library_created": "Створана бібліятэка: {library}",
|
"library_created": "Створана бібліятэка: {library}",
|
||||||
"library_deleted": "Бібліятэка выдалена",
|
"library_deleted": "Бібліятэка выдалена",
|
||||||
"library_details": "Параметры бібліятэкі",
|
"library_details": "Параметры бібліятэкі",
|
||||||
@@ -163,27 +160,8 @@
|
|||||||
"machine_learning_facial_recognition_model_description": "Мадэлі пералічаны ў парадку ўбывання іх памеру. Большыя мадэлі павольней і выкарыстоўваюць больш памяці, але даюць лепшыя вынікі. Звярніце увагу, што пасля змены мадэлі трэба зноў запусціць заданне распазнавання твараў для ўсіх відарысаў.",
|
"machine_learning_facial_recognition_model_description": "Мадэлі пералічаны ў парадку ўбывання іх памеру. Большыя мадэлі павольней і выкарыстоўваюць больш памяці, але даюць лепшыя вынікі. Звярніце увагу, што пасля змены мадэлі трэба зноў запусціць заданне распазнавання твараў для ўсіх відарысаў.",
|
||||||
"machine_learning_facial_recognition_setting": "Уключыць распазнаванне твараў",
|
"machine_learning_facial_recognition_setting": "Уключыць распазнаванне твараў",
|
||||||
"machine_learning_facial_recognition_setting_description": "Калі адключана, відарысы не будуць кадавацца для распазнавання твараў, і не будзе запаўняцца раздзел \"Людзі\" на старонцы \"Агляд\".",
|
"machine_learning_facial_recognition_setting_description": "Калі адключана, відарысы не будуць кадавацца для распазнавання твараў, і не будзе запаўняцца раздзел \"Людзі\" на старонцы \"Агляд\".",
|
||||||
"machine_learning_max_detection_distance": "Максімальная адлегласць выяўлення",
|
|
||||||
"machine_learning_max_detection_distance_description": "Максімальная розніца паміж двума выявамі, якія лічацца дублікатамі, складае ад 0,001 да 0,1. Больш высокія значэнні дазволяць выявіць больш дублікатаў, але могуць прывесці да няправільных выяўленняў.",
|
|
||||||
"machine_learning_max_recognition_distance": "Парог разпазнавання",
|
|
||||||
"machine_learning_max_recognition_distance_description": "Максімальнае адрозненне паміж двума асобамі, якія можна лічыць адным чалавекам (у дыяпазоне ад 0 да 2).Зніжэнне гэтага параметру можа прадухіліць распазнанне двух людзей як аднаго і таго ж чалавека, а павышэнне - як двух розных людзей. Майце на ўвазе, што прасцей аб'яднаць двух людзей, чым падзяліць аднаго чалавека на дваіх, таму па магчымасці выбірайце меншы парог.",
|
|
||||||
"machine_learning_min_detection_score": "Мінімальны парог разпазнавання",
|
|
||||||
"machine_learning_min_detection_score_description": "Мінімальны парог для выяўлення асобы (ад 0 да 1). Ніжэйшае значэнне дазволіць знаходзіць больш асоб, але можа прывесці да ілжывых спрацоўванняў.",
|
|
||||||
"machine_learning_min_recognized_faces": "Мінімум разпазнаных твараў",
|
|
||||||
"machine_learning_min_recognized_faces_description": "Мінімальная колькасць распазнаных твараў для стварэння асобы. Павялічэнне гэтага параметра робіць распазнанне асоб больш дакладным, але пры гэтым павялічваецца верагоднасць таго, што твар не будзе прысвоены асобе.",
|
|
||||||
"machine_learning_ocr": "Разпазнаванне тэксту (OCR)",
|
|
||||||
"machine_learning_ocr_description": "Выкарыстоўвайце машыннае навучанне для распазнавання тэксту на малюнках",
|
|
||||||
"machine_learning_ocr_enabled": "Дадаць OCR",
|
|
||||||
"machine_learning_ocr_enabled_description": "Калі адключана, выявы не будуць распазнавацца з выкарыстаннем тэксту.",
|
|
||||||
"machine_learning_ocr_max_resolution": "Максімальная раздзяляльнасць",
|
"machine_learning_ocr_max_resolution": "Максімальная раздзяляльнасць",
|
||||||
"machine_learning_ocr_max_resolution_description": "Відарысы з раздзяляльнасцю больш гэтай будуць паменшаны з захаваннем суадносіны бакоў. Больш высокія значэнні павышаюць дакладнасць распазнавання, але патрабуюць больш часу на апрацоўку і выкарыстоўваюць больш памяці.",
|
"machine_learning_ocr_max_resolution_description": "Відарысы з раздзяляльнасцю больш гэтай будуць паменшаны з захаваннем суадносіны бакоў. Больш высокія значэнні павышаюць дакладнасць распазнавання, але патрабуюць больш часу на апрацоўку і выкарыстоўваюць больш памяці.",
|
||||||
"machine_learning_ocr_min_detection_score": "Мінімальны бал выяўлення",
|
|
||||||
"machine_learning_ocr_min_detection_score_description": "Мінімальны бал даверу для выяўлення тэксту складае ад 0 да 1. Больш нізкія значэнні дазволяць выявіць больш тэксту, але могуць прывесці да хібных спрацоўванняў.",
|
|
||||||
"machine_learning_ocr_min_recognition_score": "Мінімальны бал распазнавання",
|
|
||||||
"machine_learning_ocr_min_score_recognition_description": "Мінімальны бал даверу для распазнавання выяўленага тэксту складае ад 0 да 1. Больш нізкія значэнні распазнаюць больш тэксту, але могуць прывесці да хібных спрацоўванняў.",
|
|
||||||
"machine_learning_ocr_model": "Мадэль машыннага навучання (OCR)",
|
|
||||||
"machine_learning_ocr_model_description": "Серверныя мадэлі больш дакладныя, чым мабільныя, але апрацоўваюць дадзеныя даўжэй і выкарыстоўваюць больш памяці.",
|
|
||||||
"machine_learning_settings": "Налады машыннага навучання",
|
|
||||||
"map_dark_style": "Цёмны стыль",
|
"map_dark_style": "Цёмны стыль",
|
||||||
"map_enable_description": "Уключыць функцыі карты",
|
"map_enable_description": "Уключыць функцыі карты",
|
||||||
"map_gps_settings": "Налады карты і GPS",
|
"map_gps_settings": "Налады карты і GPS",
|
||||||
@@ -193,7 +171,6 @@
|
|||||||
"map_style_description": "URL-адрас style.json тэмы карты",
|
"map_style_description": "URL-адрас style.json тэмы карты",
|
||||||
"metadata_extraction_job_description": "Выняць метаданыя з файлаў, такія як месцазнаходжанне, твары і раздзяляльнасць",
|
"metadata_extraction_job_description": "Выняць метаданыя з файлаў, такія як месцазнаходжанне, твары і раздзяляльнасць",
|
||||||
"metadata_settings": "Налады метаданых",
|
"metadata_settings": "Налады метаданых",
|
||||||
"notification_email_port_description": "Порт паштовага сервера (напрыклад, 25, 465 або 587)",
|
|
||||||
"oauth_button_text": "Тэкст кнопкі",
|
"oauth_button_text": "Тэкст кнопкі",
|
||||||
"oauth_settings": "OAuth",
|
"oauth_settings": "OAuth",
|
||||||
"refreshing_all_libraries": "Абнаўленне ўсіх бібліятэк",
|
"refreshing_all_libraries": "Абнаўленне ўсіх бібліятэк",
|
||||||
@@ -239,7 +216,7 @@
|
|||||||
"user_settings": "Налады карыстальніка",
|
"user_settings": "Налады карыстальніка",
|
||||||
"user_settings_description": "Кіраванне наладамі карыстальніка",
|
"user_settings_description": "Кіраванне наладамі карыстальніка",
|
||||||
"version_check_enabled_description": "Уключыць праверку версіі",
|
"version_check_enabled_description": "Уключыць праверку версіі",
|
||||||
"version_check_implications": "Функцыя праверкі версіі перыядычна звяртаецца да {server}",
|
"version_check_implications": "Функцыя праверкі версіі перыядычна звяртаецца да github.com",
|
||||||
"version_check_settings": "Праверка версіі",
|
"version_check_settings": "Праверка версіі",
|
||||||
"version_check_settings_description": "Уключыць/адключыць апавяшчэнні аб новай версіі"
|
"version_check_settings_description": "Уключыць/адключыць апавяшчэнні аб новай версіі"
|
||||||
},
|
},
|
||||||
|
|||||||
+25
-52
@@ -61,7 +61,7 @@
|
|||||||
"backup_onboarding_1_description": "копие на облака или друго физическо място.",
|
"backup_onboarding_1_description": "копие на облака или друго физическо място.",
|
||||||
"backup_onboarding_2_description": "локални копия на различни устройства. Това включва основните файлове и локални архиви на тези файлове.",
|
"backup_onboarding_2_description": "локални копия на различни устройства. Това включва основните файлове и локални архиви на тези файлове.",
|
||||||
"backup_onboarding_3_description": "общо копия на вашите данни, включитено оригиналните файлове. Това включва 1 копие извън системата и 2 локални копия.",
|
"backup_onboarding_3_description": "общо копия на вашите данни, включитено оригиналните файлове. Това включва 1 копие извън системата и 2 локални копия.",
|
||||||
"backup_onboarding_description": "За надеждна защита препоръчваме <backblaze-link>стратегията 3-2-1</backblaze-link>. Правете архивни копия както на качените снимки/видеа, така и на базата данни на Immich.",
|
"backup_onboarding_description": "За надеждна защита препоръчваме стратегията <backblaze-link>3-2-1</backblaze-link>. Правете архивни копия както на качените снимки/видеа, така и на базата данни на Immich.",
|
||||||
"backup_onboarding_footer": "За подробна информация относно архивирането в Immich, моля вижте в <link>документацията</link>.",
|
"backup_onboarding_footer": "За подробна информация относно архивирането в Immich, моля вижте в <link>документацията</link>.",
|
||||||
"backup_onboarding_parts_title": "Стратегията 3-2-1 включва:",
|
"backup_onboarding_parts_title": "Стратегията 3-2-1 включва:",
|
||||||
"backup_onboarding_title": "Резервни копия",
|
"backup_onboarding_title": "Резервни копия",
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
"image_preview_description": "Среден размер на изображението с премахнати метаданни, използвано при преглед на един елемент и за машинно обучение",
|
"image_preview_description": "Среден размер на изображението с премахнати метаданни, използвано при преглед на един елемент и за машинно обучение",
|
||||||
"image_preview_quality_description": "Качество на предварителния преглед от 1 до 100. По-високата стойност е по-добра, но води до по-големи файлове и може да намали бързодействието на приложението. Задаването на ниска стойност може да повлияе на качеството на машинното обучение.",
|
"image_preview_quality_description": "Качество на предварителния преглед от 1 до 100. По-високата стойност е по-добра, но води до по-големи файлове и може да намали бързодействието на приложението. Задаването на ниска стойност може да повлияе на качеството на машинното обучение.",
|
||||||
"image_preview_title": "Настройки на прегледа",
|
"image_preview_title": "Настройки на прегледа",
|
||||||
"image_progressive": "Прогресивно",
|
"image_progressive": "Прогресивен JPEG",
|
||||||
"image_progressive_description": "Изображенията, кодирани в прогресивен JPEG формат, се зареждат по-бързо, с постепенно подобряващо се качество. Това няма влияние на кодираните като WebP изображения.",
|
"image_progressive_description": "Изображенията, кодирани в прогресивен JPEG формат, се зареждат по-бързо, с постепенно подобряващо се качество. Това няма влияние на кодираните като WebP изображения.",
|
||||||
"image_quality": "Качество",
|
"image_quality": "Качество",
|
||||||
"image_resolution": "Резолюция",
|
"image_resolution": "Резолюция",
|
||||||
@@ -311,7 +311,7 @@
|
|||||||
"search_jobs": "Търсене на задачи…",
|
"search_jobs": "Търсене на задачи…",
|
||||||
"send_welcome_email": "Изпращане на имейл за добре дошли",
|
"send_welcome_email": "Изпращане на имейл за добре дошли",
|
||||||
"server_external_domain_settings": "Външен домейн",
|
"server_external_domain_settings": "Външен домейн",
|
||||||
"server_external_domain_settings_description": "Домейн за външни връзки",
|
"server_external_domain_settings_description": "Домейн за публични споделени връзки, включително http(s)://",
|
||||||
"server_public_users": "Публични потребители",
|
"server_public_users": "Публични потребители",
|
||||||
"server_public_users_description": "Всички потребители (име и имейл) са изброени при добавяне на потребител в споделени албуми. Когато е деактивирано, списъкът с потребители ще бъде достъпен само за администраторите.",
|
"server_public_users_description": "Всички потребители (име и имейл) са изброени при добавяне на потребител в споделени албуми. Когато е деактивирано, списъкът с потребители ще бъде достъпен само за администраторите.",
|
||||||
"server_settings": "Настройки на сървъра",
|
"server_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-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": "Шаблон за съхранение",
|
||||||
@@ -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</h264-link>, <hevc-link>кодек HEVC</hevc-link> и <vp9-link>кодек VP9</vp9-link>.",
|
"transcoding_codecs_learn_more": "За да научите повече за използваната терминология, вижте документацията на FFmpeg за <h264-link>кодек H.264</h264-link>, <hevc-link>кодек HEVC</hevc-link> и <vp9-link>VP9 кодек</vp9-link>.",
|
||||||
"transcoding_constant_quality_mode": "Режим на постоянно качество",
|
"transcoding_constant_quality_mode": "Режим на постоянно качество",
|
||||||
"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)",
|
||||||
@@ -411,7 +411,7 @@
|
|||||||
"transcoding_tone_mapping": "Тонално картографиране",
|
"transcoding_tone_mapping": "Тонално картографиране",
|
||||||
"transcoding_tone_mapping_description": "Опитва се да запази външния вид на HDR видеоклипове, когато се преобразува в SDR. Всеки алгоритъм прави различни компромиси за цвят, детайлност и яркост. Hable запазва детайлите, Mobius запазва цвета, а Reinhard запазва яркостта.",
|
"transcoding_tone_mapping_description": "Опитва се да запази външния вид на HDR видеоклипове, когато се преобразува в SDR. Всеки алгоритъм прави различни компромиси за цвят, детайлност и яркост. Hable запазва детайлите, Mobius запазва цвета, а Reinhard запазва яркостта.",
|
||||||
"transcoding_transcode_policy": "Правила за транскодиране",
|
"transcoding_transcode_policy": "Правила за транскодиране",
|
||||||
"transcoding_transcode_policy_description": "Правила за това кога видеоклипът трябва да бъде транскодиран. HDR видеоклиповете и тези с формат, различен от YUV 4:2:0, ще бъдат винаги транскодирани (освен ако транскодирането е деактивирано).",
|
"transcoding_transcode_policy_description": "Правила за това кога видеоклипът трябва да бъде транскодиран. HDR видеоклиповете винаги ще бъдат транскодирани (освен ако транскодирането е деактивирано).",
|
||||||
"transcoding_two_pass_encoding": "Кодиране с двойно минаване",
|
"transcoding_two_pass_encoding": "Кодиране с двойно минаване",
|
||||||
"transcoding_two_pass_encoding_setting_description": "Транскодирането с две минавания създава по-добре кодиране видеа. Когато максималния битрейт е включен (задължително е да се работи с H.264 и HEVC), тази опция използва диапазон на битрейта базиран на максималния битрейт и игнорира CRF. За VP9, CRF може да се използва ако максималният битрейт е изключен.",
|
"transcoding_two_pass_encoding_setting_description": "Транскодирането с две минавания създава по-добре кодиране видеа. Когато максималния битрейт е включен (задължително е да се работи с H.264 и HEVC), тази опция използва диапазон на битрейта базиран на максималния битрейт и игнорира CRF. За VP9, CRF може да се използва ако максималният битрейт е изключен.",
|
||||||
"transcoding_video_codec": "Видеокодек",
|
"transcoding_video_codec": "Видеокодек",
|
||||||
@@ -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": "Функцията за проверка на версията разчита на периодична комуникация с {server}",
|
"version_check_implications": "Функцията за проверка на версията разчита на периодична комуникация с github.com",
|
||||||
"version_check_settings": "Проверка на версията",
|
"version_check_settings": "Проверка на версията",
|
||||||
"version_check_settings_description": "Активирайте/деактивирайте известието за нова версия",
|
"version_check_settings_description": "Активирайте/деактивирайте известието за нова версия",
|
||||||
"video_conversion_job": "Транскодиране на видеоклиповете",
|
"video_conversion_job": "Транскодиране на видеоклиповете",
|
||||||
@@ -794,11 +794,6 @@
|
|||||||
"color": "Цвят",
|
"color": "Цвят",
|
||||||
"color_theme": "Цветова тема",
|
"color_theme": "Цветова тема",
|
||||||
"command": "Команда",
|
"command": "Команда",
|
||||||
"command_palette_prompt": "Бързо намиране на страници, действия или команди",
|
|
||||||
"command_palette_to_close": "затвори",
|
|
||||||
"command_palette_to_navigate": "влез",
|
|
||||||
"command_palette_to_select": "избери",
|
|
||||||
"command_palette_to_show_all": "покажи всичко",
|
|
||||||
"comment_deleted": "Коментарът е изтрит",
|
"comment_deleted": "Коментарът е изтрит",
|
||||||
"comment_options": "Опции за коментар",
|
"comment_options": "Опции за коментар",
|
||||||
"comments_and_likes": "Коментари и харесвания",
|
"comments_and_likes": "Коментари и харесвания",
|
||||||
@@ -849,12 +844,9 @@
|
|||||||
"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": "Създай линк за споделяне",
|
||||||
@@ -869,14 +861,13 @@
|
|||||||
"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 код",
|
||||||
"current_server_address": "Настоящ адрес на сървъра",
|
"current_server_address": "Настоящ адрес на сървъра",
|
||||||
"custom_date": "Персонализирана дата",
|
"custom_date": "Персонализирана дата",
|
||||||
"custom_locale": "Персонализирани езикови настройки",
|
"custom_locale": "Персонализиран локал",
|
||||||
"custom_locale_description": "Форматиране на дата, време и числа в зависимост от избрания език и регион",
|
"custom_locale_description": "Форматиране на дати и числа в зависимост от езика и региона",
|
||||||
"custom_url": "Персонализиран URL адрес",
|
"custom_url": "Персонализиран URL адрес",
|
||||||
"cutoff_date_description": "Запазване на снимки от последните…",
|
"cutoff_date_description": "Запазване на снимки от последните…",
|
||||||
"cutoff_day": "{count, plural, one {ден} other {дни}}",
|
"cutoff_day": "{count, plural, one {ден} other {дни}}",
|
||||||
@@ -884,7 +875,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": "Дата и час",
|
||||||
@@ -895,8 +886,12 @@
|
|||||||
"day": "Ден",
|
"day": "Ден",
|
||||||
"days": "Дни",
|
"days": "Дни",
|
||||||
"deduplicate_all": "Дедупликиране на всички",
|
"deduplicate_all": "Дедупликиране на всички",
|
||||||
"default_locale": "Език по подразбиране",
|
"deduplication_criteria_1": "Размер на снимката в байтове",
|
||||||
"default_locale_description": "Формат на дата и числа според езиковата настройка на браузъра",
|
"deduplication_criteria_2": "Брой EXIF данни",
|
||||||
|
"deduplication_info": "Информация за дедупликацията",
|
||||||
|
"deduplication_info_description": "За автоматично предварително избиране на ресурси и премахване на дубликати на едро, разглеждаме:",
|
||||||
|
"default_locale": "Локализация по подразбиране",
|
||||||
|
"default_locale_description": "Форматиране на дати и числа в зависимост от езиковата настройка на браузъра",
|
||||||
"delete": "Изтрий",
|
"delete": "Изтрий",
|
||||||
"delete_action_confirmation_message": "Сигурни ли сте, че искате да изтриете този обект? Следва преместване на обекта в коша за отпадъци на сървъра и ще получите предложение обекта да бъде изтрит локално",
|
"delete_action_confirmation_message": "Сигурни ли сте, че искате да изтриете този обект? Следва преместване на обекта в коша за отпадъци на сървъра и ще получите предложение обекта да бъде изтрит локално",
|
||||||
"delete_action_prompt": "{count} са изтрити",
|
"delete_action_prompt": "{count} са изтрити",
|
||||||
@@ -972,7 +967,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": "Редактиране на албум",
|
||||||
@@ -1009,8 +1004,6 @@
|
|||||||
"editor_edits_applied_success": "Успешно прилагане на промените",
|
"editor_edits_applied_success": "Успешно прилагане на промените",
|
||||||
"editor_flip_horizontal": "Обърни хоризонтално",
|
"editor_flip_horizontal": "Обърни хоризонтално",
|
||||||
"editor_flip_vertical": "Обърни вертикално",
|
"editor_flip_vertical": "Обърни вертикално",
|
||||||
"editor_handle_corner": "Манипулатор {corner, select, top_left {горен ляв} top_right {горен десен} bottom_left {долен ляв} bottom_right {долен десен} other {в}} ъгъл",
|
|
||||||
"editor_handle_edge": "Манипулатор {edge, select, top {горен} bottom {долен} left {ляв} right {десен} other {по}} ръб",
|
|
||||||
"editor_orientation": "Ориентация",
|
"editor_orientation": "Ориентация",
|
||||||
"editor_reset_all_changes": "Възстанови всички промени",
|
"editor_reset_all_changes": "Възстанови всички промени",
|
||||||
"editor_rotate_left": "Завърти 90° обратно на часовниковата стрелка",
|
"editor_rotate_left": "Завърти 90° обратно на часовниковата стрелка",
|
||||||
@@ -1076,7 +1069,6 @@
|
|||||||
"failed_to_update_notification_status": "Неуспешно обновяване на състоянието на известията",
|
"failed_to_update_notification_status": "Неуспешно обновяване на състоянието на известията",
|
||||||
"incorrect_email_or_password": "Неправилен имейл или парола",
|
"incorrect_email_or_password": "Неправилен имейл или парола",
|
||||||
"library_folder_already_exists": "Тази папка вече съществува.",
|
"library_folder_already_exists": "Тази папка вече съществува.",
|
||||||
"page_not_found": "Страницата не е намерена",
|
|
||||||
"paths_validation_failed": "{paths, plural, one {# път} other {# пътища}} не преминаха валидация",
|
"paths_validation_failed": "{paths, plural, one {# път} other {# пътища}} не преминаха валидация",
|
||||||
"profile_picture_transparent_pixels": "Профилните снимки не могат да имат прозрачни пиксели. Моля, увеличете и/или преместете изображението.",
|
"profile_picture_transparent_pixels": "Профилните снимки не могат да имат прозрачни пиксели. Моля, увеличете и/или преместете изображението.",
|
||||||
"quota_higher_than_disk_size": "Зададена е квота, по-голяма от размера на диска",
|
"quota_higher_than_disk_size": "Зададена е квота, по-голяма от размера на диска",
|
||||||
@@ -1176,7 +1168,6 @@
|
|||||||
"exif_bottom_sheet_people": "ХОРА",
|
"exif_bottom_sheet_people": "ХОРА",
|
||||||
"exif_bottom_sheet_person_add_person": "Добави име",
|
"exif_bottom_sheet_person_add_person": "Добави име",
|
||||||
"exit_slideshow": "Изход от слайдшоуто",
|
"exit_slideshow": "Изход от слайдшоуто",
|
||||||
"expand": "Разгъни",
|
|
||||||
"expand_all": "Разшири всички",
|
"expand_all": "Разшири всички",
|
||||||
"experimental_settings_new_asset_list_subtitle": "В развитие",
|
"experimental_settings_new_asset_list_subtitle": "В развитие",
|
||||||
"experimental_settings_new_asset_list_title": "Включи експериментална подредба на снимки",
|
"experimental_settings_new_asset_list_title": "Включи експериментална подредба на снимки",
|
||||||
@@ -1221,7 +1212,6 @@
|
|||||||
"filter_description": "Условия за филтриране на обекти",
|
"filter_description": "Условия за филтриране на обекти",
|
||||||
"filter_people": "Филтриране на хора",
|
"filter_people": "Филтриране на хора",
|
||||||
"filter_places": "Филтър по място",
|
"filter_places": "Филтър по място",
|
||||||
"filter_tags": "Филтриране по етикети",
|
|
||||||
"filters": "Филтри",
|
"filters": "Филтри",
|
||||||
"find_them_fast": "Намерете ги бързо по име с търсене",
|
"find_them_fast": "Намерете ги бързо по име с търсене",
|
||||||
"first": "Първи",
|
"first": "Първи",
|
||||||
@@ -1321,7 +1311,7 @@
|
|||||||
"import_path": "Път за импортиране",
|
"import_path": "Път за импортиране",
|
||||||
"in_albums": "В {count, plural, one {# албум} other {# албума}}",
|
"in_albums": "В {count, plural, one {# албум} other {# албума}}",
|
||||||
"in_archive": "В архив",
|
"in_archive": "В архив",
|
||||||
"in_year": "През {year}",
|
"in_year": "{year} г.",
|
||||||
"in_year_selector": "През",
|
"in_year_selector": "През",
|
||||||
"include_archived": "Включване на архивирани",
|
"include_archived": "Включване на архивирани",
|
||||||
"include_shared_albums": "Включване на споделени албуми",
|
"include_shared_albums": "Включване на споделени албуми",
|
||||||
@@ -1389,11 +1379,9 @@
|
|||||||
"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": "Лист",
|
||||||
@@ -1654,15 +1642,13 @@
|
|||||||
"online": "Онлайн",
|
"online": "Онлайн",
|
||||||
"only_favorites": "Само любими",
|
"only_favorites": "Само любими",
|
||||||
"open": "Отвори",
|
"open": "Отвори",
|
||||||
"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": "Подредете в албуми",
|
"organize_into_albums": "Organitzar per àlbums",
|
||||||
"organize_into_albums_description": "Добавете наличните снимки в албуми, като използвате текущите настройки за синхронизиране",
|
"organize_into_albums_description": "Posar les fotos existents dins dels àlbums fent servir la configuració de sincronització",
|
||||||
"organize_your_library": "Организиране на вашата библиотека",
|
"organize_your_library": "Организиране на вашата библиотека",
|
||||||
"original": "оригинал",
|
"original": "оригинал",
|
||||||
"other": "Други",
|
"other": "Други",
|
||||||
@@ -1810,13 +1796,14 @@
|
|||||||
"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": "Търсене на елемент по ID",
|
"query_asset_id": "Buscar item per ID",
|
||||||
"queue_status": "В опашка {count} от {total}",
|
"queue_status": "В опашка {count} от {total}",
|
||||||
"rate_asset": "Задаване на рейтинг",
|
"rate_asset": "Задаване на рейтинг",
|
||||||
"rating": "Оценка със звезди",
|
"rating": "Оценка със звезди",
|
||||||
"rating_clear": "Изчисти оценката",
|
"rating_clear": "Изчисти оценката",
|
||||||
"rating_count": "{count, plural, =0 {Без рейтинг} one {# звезда} other {# звезди}}",
|
"rating_count": "{count, plural, one {# звезда} other {# звезди}}",
|
||||||
"rating_description": "Покажи EXIF оценката в панела с информация",
|
"rating_description": "Покажи EXIF оценката в панела с информация",
|
||||||
|
"rating_set": "Зададен е рейтинг {rating, plural, one {# звезда} other {# звезди}}",
|
||||||
"reaction_options": "Избор на реакция",
|
"reaction_options": "Избор на реакция",
|
||||||
"read_changelog": "Прочети промените",
|
"read_changelog": "Прочети промените",
|
||||||
"readonly_mode_disabled": "Режима само за четене е деактивиран",
|
"readonly_mode_disabled": "Режима само за четене е деактивиран",
|
||||||
@@ -1888,10 +1875,7 @@
|
|||||||
"reset_pin_code_success": "Успешно нулиран ПИН код",
|
"reset_pin_code_success": "Успешно нулиран ПИН код",
|
||||||
"reset_pin_code_with_password": "С вашата парола можете винаги да нулирате своя ПИН код",
|
"reset_pin_code_with_password": "С вашата парола можете винаги да нулирате своя ПИН код",
|
||||||
"reset_sqlite": "Нулиране на базата данни SQLite",
|
"reset_sqlite": "Нулиране на базата данни SQLite",
|
||||||
"reset_sqlite_clear_app_data": "Премахни данните",
|
"reset_sqlite_confirmation": "Наистина ли искате да нулирате базата данни SQLite? Ще трябва да излезете от системата и да се впишете отново за нова синхронизация на данните",
|
||||||
"reset_sqlite_confirmation": "Наистина ли искате да нулирате данните на приложението? Това ще премахни всички настройки и ще Ви отпише от системата.",
|
|
||||||
"reset_sqlite_confirmation_note": "Бележка: След премахване на данните ще трябва да рестартирате приложението.",
|
|
||||||
"reset_sqlite_done": "Данните на приложението са премахнати. Моля, рестартирайте Immich и се впишете отново.",
|
|
||||||
"reset_sqlite_success": "Успешно нулиране на базата данни SQLite",
|
"reset_sqlite_success": "Успешно нулиране на базата данни SQLite",
|
||||||
"reset_to_default": "Връщане на фабрични настройки",
|
"reset_to_default": "Връщане на фабрични настройки",
|
||||||
"resolution": "Резолюция",
|
"resolution": "Резолюция",
|
||||||
@@ -1919,7 +1903,6 @@
|
|||||||
"saved_settings": "Запазени настройки",
|
"saved_settings": "Запазени настройки",
|
||||||
"say_something": "Кажи нещо",
|
"say_something": "Кажи нещо",
|
||||||
"scaffold_body_error_occurred": "Възникна грешка",
|
"scaffold_body_error_occurred": "Възникна грешка",
|
||||||
"scaffold_body_error_unrecoverable": "Възникна непоправима грешка. Моля, споделете грешката и трасирането на стека в Discord или GitHub, за да можем да Ви помогнем. Ако бъдете посъветвани, може да изчистите данните на приложението.",
|
|
||||||
"scan": "Сканиранe",
|
"scan": "Сканиранe",
|
||||||
"scan_all_libraries": "Сканирай всички библиотеки",
|
"scan_all_libraries": "Сканирай всички библиотеки",
|
||||||
"scan_library": "Сканирай",
|
"scan_library": "Сканирай",
|
||||||
@@ -1955,7 +1938,6 @@
|
|||||||
"search_filter_ocr": "Търсене нa текст",
|
"search_filter_ocr": "Търсене нa текст",
|
||||||
"search_filter_people_title": "Избери хора",
|
"search_filter_people_title": "Избери хора",
|
||||||
"search_filter_star_rating": "Класация със звезди",
|
"search_filter_star_rating": "Класация със звезди",
|
||||||
"search_filter_tags_title": "Изберете етикети",
|
|
||||||
"search_for": "Търси за",
|
"search_for": "Търси за",
|
||||||
"search_for_existing_person": "Търси съществуващ човек",
|
"search_for_existing_person": "Търси съществуващ човек",
|
||||||
"search_no_more_result": "Няма други резултати",
|
"search_no_more_result": "Няма други резултати",
|
||||||
@@ -2035,9 +2017,6 @@
|
|||||||
"set_profile_picture": "Задайте профилна снимка",
|
"set_profile_picture": "Задайте профилна снимка",
|
||||||
"set_slideshow_to_fullscreen": "Задайте Слайдшоу на цял екран",
|
"set_slideshow_to_fullscreen": "Задайте Слайдшоу на цял екран",
|
||||||
"set_stack_primary_asset": "Задай като основни обекти",
|
"set_stack_primary_asset": "Задай като основни обекти",
|
||||||
"setting_image_navigation_enable_subtitle": "Ако е избрано, можете да навигирате към предишна/следваща снимка като натиснете върху лявата/дясната страна на екрана.",
|
|
||||||
"setting_image_navigation_enable_title": "Натисни за навигиране",
|
|
||||||
"setting_image_navigation_title": "Навигиране на снимка",
|
|
||||||
"setting_image_viewer_help": "При показване на обект първо се зарежда миниатюра, после изображение със средно качество (ако е разрешено) и накрая оригинала (ако е разрешено).",
|
"setting_image_viewer_help": "При показване на обект първо се зарежда миниатюра, после изображение със средно качество (ако е разрешено) и накрая оригинала (ако е разрешено).",
|
||||||
"setting_image_viewer_original_subtitle": "Разреши за да се зарежда оригиналното изображение в пълен размер (голям!). Забрани за да се намали обема на данните (по мрежата и в кеша на устройството).",
|
"setting_image_viewer_original_subtitle": "Разреши за да се зарежда оригиналното изображение в пълен размер (голям!). Забрани за да се намали обема на данните (по мрежата и в кеша на устройството).",
|
||||||
"setting_image_viewer_original_title": "Зареждане на оригинално изображение",
|
"setting_image_viewer_original_title": "Зареждане на оригинално изображение",
|
||||||
@@ -2204,7 +2183,6 @@
|
|||||||
"support": "Поддръжка",
|
"support": "Поддръжка",
|
||||||
"support_and_feedback": "Поддръжка и обратна връзка",
|
"support_and_feedback": "Поддръжка и обратна връзка",
|
||||||
"support_third_party_description": "Вашата инсталация на Immich е пакетирана от трета страна. Проблемите, които изпитвате, може да са причинени от този пакет, затова моля, първо подавайте проблемите си към тях чрез линковете по-долу.",
|
"support_third_party_description": "Вашата инсталация на Immich е пакетирана от трета страна. Проблемите, които изпитвате, може да са причинени от този пакет, затова моля, първо подавайте проблемите си към тях чрез линковете по-долу.",
|
||||||
"supporter": "Поддръжник",
|
|
||||||
"swap_merge_direction": "Размяна посоката на сливане",
|
"swap_merge_direction": "Размяна посоката на сливане",
|
||||||
"sync": "Синхронизиране",
|
"sync": "Синхронизиране",
|
||||||
"sync_albums": "Синхронизиране на албуми",
|
"sync_albums": "Синхронизиране на албуми",
|
||||||
@@ -2217,9 +2195,8 @@
|
|||||||
"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": "Отбележи Хора",
|
||||||
"tag_updated": "Обновен етикет: {tag}",
|
"tag_updated": "Обновен етикет: {tag}",
|
||||||
"tagged_assets": "Тагнати {count, plural, one {# елемент} other {# елементи}}",
|
"tagged_assets": "Тагнати {count, plural, one {# елемент} other {# елементи}}",
|
||||||
@@ -2317,7 +2294,6 @@
|
|||||||
"unstack_action_prompt": "{count} са разгрупирани",
|
"unstack_action_prompt": "{count} са разгрупирани",
|
||||||
"unstacked_assets_count": "Разкачени {count, plural, one {# елемент} other {# елементи}}",
|
"unstacked_assets_count": "Разкачени {count, plural, one {# елемент} other {# елементи}}",
|
||||||
"unsupported_field_type": "Типа на полето не се поддържа",
|
"unsupported_field_type": "Типа на полето не се поддържа",
|
||||||
"unsupported_file_type": "Файлът {file} не може да бъде зареден, защото неговият тип {type} не се поддържа.",
|
|
||||||
"untagged": "Немаркирани",
|
"untagged": "Немаркирани",
|
||||||
"untitled_workflow": "Работен процес без име",
|
"untitled_workflow": "Работен процес без име",
|
||||||
"up_next": "Следващ",
|
"up_next": "Следващ",
|
||||||
@@ -2344,8 +2320,6 @@
|
|||||||
"url": "URL",
|
"url": "URL",
|
||||||
"usage": "Потребление",
|
"usage": "Потребление",
|
||||||
"use_biometric": "Използвай биометрия",
|
"use_biometric": "Използвай биометрия",
|
||||||
"use_browser_locale": "Използвай езиковите настройки на браузъра",
|
|
||||||
"use_browser_locale_description": "Формат на дата, време и числа според езиковата настройка на браузъра",
|
|
||||||
"use_current_connection": "Използвай текущата връзка",
|
"use_current_connection": "Използвай текущата връзка",
|
||||||
"use_custom_date_range": "Използвайте собствен диапазон от дати вместо това",
|
"use_custom_date_range": "Използвайте собствен диапазон от дати вместо това",
|
||||||
"user": "Потребител",
|
"user": "Потребител",
|
||||||
@@ -2399,7 +2373,6 @@
|
|||||||
"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": "Визуален конструктор",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user