Compare commits
259
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3411a47d82 | ||
|
|
46dee161ec | ||
|
|
d3571f788e | ||
|
|
e09159b389 | ||
|
|
6cce8efed8 | ||
|
|
8b7c9d7328 | ||
|
|
499433a2c6 | ||
|
|
55df2bb2bf | ||
|
|
48cfafd1c1 | ||
|
|
03ff181af3 | ||
|
|
cca2dd7ece | ||
|
|
12b2c6ac0a | ||
|
|
8b6e4940f4 | ||
|
|
865da219c9 | ||
|
|
d68c988981 | ||
|
|
c8c6fc3837 | ||
|
|
f378567c6c | ||
|
|
c08c9d9205 | ||
|
|
6520f7820b | ||
|
|
a8786e5f17 | ||
|
|
457776898c | ||
|
|
8077424a53 | ||
|
|
4138ce749d | ||
|
|
094d65c4bc | ||
|
|
06c41fdcac | ||
|
|
d25a353d94 | ||
|
|
11eb6eaff3 | ||
|
|
57c45dbb72 | ||
|
|
a5678e0d24 | ||
|
|
b3414d7595 | ||
|
|
012eef2a80 | ||
|
|
7fb3de7bde | ||
|
|
c0170f4abf | ||
|
|
4fdc9b69fa | ||
|
|
7459863a87 | ||
|
|
2196435a9e | ||
|
|
ef96d6cb84 | ||
|
|
a3d2cf5b04 | ||
|
|
b3d8c1bc43 | ||
|
|
d569e28d01 | ||
|
|
768dbe8259 | ||
|
|
27ce131134 | ||
|
|
f16e6c753a | ||
|
|
3248d24d28 | ||
|
|
197f575673 | ||
|
|
3b157096fd | ||
|
|
062ae648d7 | ||
|
|
8e77a8c6f8 | ||
|
|
9776c0f4f4 | ||
|
|
0908d1af7b | ||
|
|
08d9ef733c | ||
|
|
6851091403 | ||
|
|
11e9a4b4da | ||
|
|
9b5e1aae5b | ||
|
|
7aba6c3f88 | ||
|
|
f727e8edb4 | ||
|
|
cfa416e612 | ||
|
|
22e7b20d3e | ||
|
|
28a52948a9 | ||
|
|
63df351cad | ||
|
|
f2f2093e41 | ||
|
|
99cda00260 | ||
|
|
a1a47d909c | ||
|
|
50cb08f35d | ||
|
|
17ad3a2e8a | ||
|
|
faaf0e410b | ||
|
|
558ed71b7d | ||
|
|
2c4d581495 | ||
|
|
b7b361bed1 | ||
|
|
25d368790f | ||
|
|
93f60cd2dc | ||
|
|
beb49e8005 | ||
|
|
a5c8af3791 | ||
|
|
fae436a03c | ||
|
|
87aed8305a | ||
|
|
c3cec81b0a | ||
|
|
6dbc3b4cb3 | ||
|
|
9ec66cbe79 | ||
|
|
1d6947b512 | ||
|
|
607a18ffff | ||
|
|
3a5d842ac1 | ||
|
|
9632838132 | ||
|
|
4c4ed38353 | ||
|
|
657f24550c | ||
|
|
4f009feec5 | ||
|
|
f3f56bb5e8 | ||
|
|
b5da1f06fb | ||
|
|
d3d5374a10 | ||
|
|
eb20a2e05a | ||
|
|
78a0982999 | ||
|
|
d8cc86ca42 | ||
|
|
94c825a92a | ||
|
|
a8032b2e6d | ||
|
|
e14fcd0a52 | ||
|
|
b10d600e23 | ||
|
|
18e5e1f75d | ||
|
|
2800192c76 | ||
|
|
916718b558 | ||
|
|
8a5ef4c96e | ||
|
|
a3a52a43ca | ||
|
|
e15bdf9af3 | ||
|
|
b3fe177869 | ||
|
|
f5f154f2d8 | ||
|
|
abfb6c0a0e | ||
|
|
91e416b104 | ||
|
|
92cf23b642 | ||
|
|
e708a80474 | ||
|
|
276d5620fd | ||
|
|
11900d5a8c | ||
|
|
5dfa7214df | ||
|
|
6808ed3239 | ||
|
|
925aaed54c | ||
|
|
132af2fc74 | ||
|
|
9e5bdda89c | ||
|
|
79ea67f6be | ||
|
|
79b175465e | ||
|
|
c76bd08f73 | ||
|
|
f2e05e9d98 | ||
|
|
c8a957a116 | ||
|
|
f7697264ed | ||
|
|
648dea2072 | ||
|
|
895b755f53 | ||
|
|
568a9d977e | ||
|
|
3c383e46d1 | ||
|
|
477fcc2338 | ||
|
|
ff52f491fb | ||
|
|
c3933169de | ||
|
|
2c1f321627 | ||
|
|
e8c1fd297d | ||
|
|
8e3e3a48ae | ||
|
|
dc5553b239 | ||
|
|
32e02a05d2 | ||
|
|
c581bce715 | ||
|
|
dd23264385 | ||
|
|
6dbe72b6a1 | ||
|
|
72f96701f0 | ||
|
|
b4ae7f0dcf | ||
|
|
09bb2a9fb5 | ||
|
|
8f2bfa099e | ||
|
|
07083aec22 | ||
|
|
64d50dc4b7 | ||
|
|
7fb7699c06 | ||
|
|
3127dbec71 | ||
|
|
f418967d55 | ||
|
|
1f422ea4c4 | ||
|
|
8119a5ee22 | ||
|
|
54881128d6 | ||
|
|
d15be0dd83 | ||
|
|
47cc38061e | ||
|
|
db411a4497 | ||
|
|
f1a09c1389 | ||
|
|
c8222bba94 | ||
|
|
af66681b99 | ||
|
|
a93bbdf3e7 | ||
|
|
1707635743 | ||
|
|
7a6123f8e6 | ||
|
|
4960eddaf3 | ||
|
|
d35a6613e6 | ||
|
|
f96816b55f | ||
|
|
eca952720a | ||
|
|
c482c1e53e | ||
|
|
75a1d7c5c8 | ||
|
|
5630e541e3 | ||
|
|
27ccbee82d | ||
|
|
3458767d66 | ||
|
|
cd28300f94 | ||
|
|
d8030f3d14 | ||
|
|
0ff6470791 | ||
|
|
717152b191 | ||
|
|
06dbcf7c84 | ||
|
|
9767ea9c02 | ||
|
|
071b8db397 | ||
|
|
1c9813e130 | ||
|
|
6569aad605 | ||
|
|
a2794f0986 | ||
|
|
6ffc365b75 | ||
|
|
dc19d3a259 | ||
|
|
f2a502448d | ||
|
|
a3ffe3b3c3 | ||
|
|
8125af4859 | ||
|
|
e5b4dd1041 | ||
|
|
621504fcda | ||
|
|
919a4fd9ef | ||
|
|
2ed9207c3f | ||
|
|
c48394aa6e | ||
|
|
86c8b9e04d | ||
|
|
1190d0cc80 | ||
|
|
9669f7cc6e | ||
|
|
a5b2dcb3c2 | ||
|
|
f94fbfe14e | ||
|
|
70ef2884f0 | ||
|
|
918d66fbdb | ||
|
|
5d1167a36c | ||
|
|
e3bf54e2ba | ||
|
|
d17640a283 | ||
|
|
9e889bd4dc | ||
|
|
ca4d8d5a7a | ||
|
|
5f54700344 | ||
|
|
0991f506bb | ||
|
|
fa77f168a0 | ||
|
|
3aa65809b1 | ||
|
|
9a66f16102 | ||
|
|
493870ad7b | ||
|
|
1e20bdd74e | ||
|
|
99a7830f6c | ||
|
|
e04e70db2e | ||
|
|
75517057d0 | ||
|
|
3efc91ff9a | ||
|
|
7c07c001cd | ||
|
|
f1f7f82e91 | ||
|
|
b3bd5d1fd1 | ||
|
|
ad9b368cb8 | ||
|
|
7c78b00df6 | ||
|
|
4dfb6e88af | ||
|
|
744797e59d | ||
|
|
689105c949 | ||
|
|
1332a30630 | ||
|
|
648e5608b7 | ||
|
|
42dffaa0c8 | ||
|
|
ec3359f1cb | ||
|
|
235e57bf77 | ||
|
|
630240971f | ||
|
|
44e312cfba | ||
|
|
67c158a0ab | ||
|
|
1554f0d439 | ||
|
|
3e250d7878 | ||
|
|
a6d63285b7 | ||
|
|
c1552140b0 | ||
|
|
238617e566 | ||
|
|
3f3671fbe5 | ||
|
|
01ba579766 | ||
|
|
e2864b6664 | ||
|
|
1756d70fda | ||
|
|
b12be9119f | ||
|
|
e66d5a70e7 | ||
|
|
1a81902a36 | ||
|
|
ec5b4eb939 | ||
|
|
3457823b5b | ||
|
|
ffbe3d1681 | ||
|
|
5c0f0b498a | ||
|
|
c46f748412 | ||
|
|
59be6583b8 | ||
|
|
aeefb962d4 | ||
|
|
f80be1030b | ||
|
|
7df744b684 | ||
|
|
c66634fcfd | ||
|
|
d92248c664 | ||
|
|
67152772d2 | ||
|
|
564b720bce | ||
|
|
6aaebd167d | ||
|
|
2d236024bc | ||
|
|
e09b352067 | ||
|
|
4c89b0f7df | ||
|
|
b020f12f41 | ||
|
|
43848a7d68 | ||
|
|
9e96fdc627 | ||
|
|
434b7efd66 | ||
|
|
814cc14f88 | ||
|
|
59bf69f1d5 |
No files matched your search
@@ -7,6 +7,7 @@ log "Preparing Immich Nest API Server"
|
||||
log ""
|
||||
export CI=1
|
||||
run_cmd pnpm --filter immich install
|
||||
SHARP_FORCE_GLOBAL_LIBVIPS=true run_cmd pnpm --config.verify-deps-before-run=false --dir "${IMMICH_WORKSPACE}/server/node_modules/sharp" run build
|
||||
|
||||
log "Starting Nest API Server"
|
||||
log ""
|
||||
|
||||
@@ -9,6 +9,8 @@ body:
|
||||
Stick to only a single feature per request. If you list multiple different features at once,
|
||||
your request will be closed.
|
||||
|
||||
Please read our [contributing guidelines](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md) before opening a feature request.
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: I have searched the existing feature requests, both open and closed, to make sure this is not a duplicate request.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
name: Report an issue with Immich
|
||||
description: Report an issue with Immich
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please read our [contributing guidelines](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md) before opening an issue.
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: I have searched the existing issues, both open and closed, to make sure this is not a duplicate report.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"name": "immich-github",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different ."
|
||||
|
||||
@@ -26,10 +26,17 @@ jobs:
|
||||
env:
|
||||
BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
BODY=$(printf '%s\n' "$BODY" | tr -d '\r' | perl -0pe 's/<!--.*?-->//gs')
|
||||
section() { printf '%s\n' "$BODY" | awk -v h="$1" 'index($0, h) == 1 { f = 1; next } /^## / { f = 0 } f'; }
|
||||
|
||||
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 "^## ")
|
||||
done < <(perl -0pe 's/<!--.*?-->//gs' .github/pull_request_template.md | grep "^## ")
|
||||
|
||||
section "## Checklist" | grep -qE '^\s*- \[[xX]\]' || OK=false
|
||||
[[ "$(section "## Please describe to which degree" | tr -d '[:space:]')" =~ ^(\.\.\.)?$ ]] && OK=false
|
||||
|
||||
echo "uses_template=$OK" | tee --append "$GITHUB_OUTPUT"
|
||||
|
||||
close_template:
|
||||
@@ -51,7 +58,7 @@ jobs:
|
||||
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 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). Please make sure every section is present and filled in. After you edit it to match the template, the PR will automatically be reopened." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
contents: read
|
||||
# Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload)
|
||||
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||
runs-on: macos-26
|
||||
runs-on: xcode-27
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
@@ -214,8 +214,8 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
permission-contents: read
|
||||
|
||||
- name: Select Xcode 26
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
|
||||
- name: Select Xcode 27.1
|
||||
run: sudo xcode-select -s /Applications/Xcode_27.1.app/Contents/Developer
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -230,7 +230,7 @@ jobs:
|
||||
working_directory: ./mobile
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
|
||||
uses: ruby/setup-ruby@7f797815d8adceb1c412e7abefdd1ab4abc47a5f # v1.335.0
|
||||
with:
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true # zizmor: ignore[cache-poisoning] release branches cannot read caches written by pull requests
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@5e81b5c380accc6b523f9d32a637ca630e33620b # v0.1.17
|
||||
uses: oasdiff/oasdiff-action/breaking@9e56cf8b3aff7edb326d5767d3d23f96127e20c3 # v0.1.21
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
registry: ghcr.io
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
needs: [get_body, should_run]
|
||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||
container:
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:ef9eab1055b07aed9111b6dd3f0d70e1b2e08c862d982d4f4560d6353c9e2b9d
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:7ed8dd09ce9f35b6aca5c098a184ab7be3fc8292ee193ccecd9c9ca4cd267da7
|
||||
outputs:
|
||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||
steps:
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -71,10 +71,10 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/autobuild@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
|
||||
# ℹ️ 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
|
||||
# в„№пёЏ 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
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
runner-mapping: '{"linux/amd64": "pokedex-large"}'
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -217,14 +217,14 @@ jobs:
|
||||
SUFFIX: ${{ matrix.suffixes }}
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -35,6 +35,7 @@ jobs:
|
||||
filters: |
|
||||
docs:
|
||||
- 'docs/**'
|
||||
- 'deployment/**'
|
||||
open-api:
|
||||
- 'open-api/immich-openapi-specs.json'
|
||||
force-filters: |
|
||||
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -154,8 +154,8 @@ jobs:
|
||||
previous=$(grep -A1 -xF "${TAG}" <<< "${tags}" | tail -1)
|
||||
echo "generating notes from ${previous}"
|
||||
|
||||
mise //:notes --tag "${TAG}" --previous "${previous}" > "${RUNNER_TEMP}/notes.md"
|
||||
cat "${RUNNER_TEMP}/notes.md"
|
||||
mise //:notes --tag "${TAG}" --previous "${previous}"
|
||||
cat misc/release/notes.md
|
||||
|
||||
- name: Create draft release
|
||||
env:
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
--draft \
|
||||
--verify-tag \
|
||||
--prerelease="${PRERELEASE}" \
|
||||
--notes-file "${RUNNER_TEMP}/notes.md" \
|
||||
--notes-file misc/release/notes.md \
|
||||
docker/docker-compose.yml \
|
||||
docker/docker-compose.rootless.yml \
|
||||
docker/example.env \
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Generate a token
|
||||
id: generate_token
|
||||
if: ${{ inputs.skip != true }}
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
@@ -22,3 +22,4 @@ jobs:
|
||||
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
sync-labels: true
|
||||
+106
-33
@@ -17,7 +17,7 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -141,7 +141,7 @@ jobs:
|
||||
working-directory: ./packages/cli
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
working-directory: ./packages/cli
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -218,7 +218,7 @@ jobs:
|
||||
working-directory: ./web
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
working-directory: ./web
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -277,6 +277,47 @@ jobs:
|
||||
- name: Run ci-unit
|
||||
run: mise run ci-unit
|
||||
|
||||
web-medium-tests:
|
||||
name: Test Web (Medium)
|
||||
needs: pre-job
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).web == true }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./web
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@caac7f7e4298458a14238959504f411fc6cc7f06 # use-mise-action-v3.2.3
|
||||
with:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Run ci-medium
|
||||
run: mise run ci-medium
|
||||
|
||||
- name: Archive medium test results
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
if: failure()
|
||||
with:
|
||||
name: web-medium-test-results
|
||||
path: |
|
||||
web/playwright-report/
|
||||
web/test-results/
|
||||
|
||||
i18n-tests:
|
||||
name: Test i18n
|
||||
needs: pre-job
|
||||
@@ -286,7 +327,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -337,7 +378,7 @@ jobs:
|
||||
working-directory: ./e2e
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -365,16 +406,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
permission-contents: read
|
||||
permission-packages: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -388,6 +431,13 @@ jobs:
|
||||
with:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Run ci-medium
|
||||
run: mise run ci-medium
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -399,6 +449,7 @@ jobs:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
@@ -407,11 +458,12 @@ jobs:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
permission-contents: read
|
||||
permission-packages: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -442,9 +494,23 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Start Docker Compose
|
||||
id: docker
|
||||
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
shell: bash
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5; do
|
||||
docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300 2>&1 | tee "${RUNNER_TEMP}/docker-compose-up.log" && exit 0
|
||||
grep -qiE 'toomanyrequests|retry-after' "${RUNNER_TEMP}/docker-compose-up.log" || exit 1
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
exit 1
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run e2e tests (api & cli)
|
||||
@@ -478,6 +544,7 @@ jobs:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
@@ -486,11 +553,12 @@ jobs:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
permission-contents: read
|
||||
permission-packages: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -521,9 +589,23 @@ jobs:
|
||||
run: pnpm exec playwright install chromium --only-shell
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Docker build
|
||||
id: docker
|
||||
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
shell: bash
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5; do
|
||||
docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300 2>&1 | tee "${RUNNER_TEMP}/docker-compose-up.log" && exit 0
|
||||
grep -qiE 'toomanyrequests|retry-after' "${RUNNER_TEMP}/docker-compose-up.log" || exit 1
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
exit 1
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run e2e tests (web)
|
||||
@@ -539,19 +621,6 @@ jobs:
|
||||
name: e2e-web-test-results-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/
|
||||
|
||||
- name: Run ui tests (web)
|
||||
env:
|
||||
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
||||
run: pnpm test:web:ui
|
||||
if: ${{ !cancelled() && steps.docker.outcome == 'success' }}
|
||||
|
||||
- name: Archive ui test (web) results
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: e2e-ui-test-results-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/
|
||||
|
||||
- name: Run maintenance tests
|
||||
env:
|
||||
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
||||
@@ -596,7 +665,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -634,7 +703,7 @@ jobs:
|
||||
working-directory: ./machine-learning
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -665,7 +734,7 @@ jobs:
|
||||
working-directory: ./.github
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -696,7 +765,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -718,7 +787,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -763,9 +832,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
services:
|
||||
postgres:
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3@sha256:dbf18b3ffea4a81434c65b71e20d27203baf903a0275f4341e4c16dfd901fd67
|
||||
credentials:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
@@ -776,7 +849,7 @@ jobs:
|
||||
- 5432:5432
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
@@ -28,3 +28,4 @@ vite.config.js.timestamp-*
|
||||
.devcontainer/.env*
|
||||
*.tsbuildinfo
|
||||
*.tsbuildInfo
|
||||
misc/release/notes.md
|
||||
Vendored
+2
-2
@@ -13,7 +13,7 @@
|
||||
"editor.wordBasedSuggestions": "off"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[json]": {
|
||||
@@ -30,7 +30,7 @@
|
||||
"tailwindCSS.lint.suggestCanonicalClasses": "ignore"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"cSpell.words": ["immich"],
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# AGENTS.md
|
||||
|
||||
Immich monorepo: `server/` (NestJS API + job workers), `web/` (SvelteKit SPA), `mobile/` (Flutter), `native/` (Rust core for mobile), `machine-learning/` (FastAPI + ONNX), `e2e/` (API + Playwright tests), `packages/` (`@immich/sdk`, `@immich/cli`, plugin SDK/core, release scripts), `open-api/`, `i18n/`, `docs/`. Before changing code in `server/`, `web/`, `mobile/`, `native/`, `machine-learning/` or `e2e/`, read that folder's `AGENTS.md`.
|
||||
|
||||
## Rules
|
||||
|
||||
- No PRs of LLM-generated code; the human contributor must understand and be able to explain every change (see `CONTRIBUTING.md`).
|
||||
- Do not generate PR titles, descriptions, issue text, review comments or discussion answers. Translating human-written text is fine.
|
||||
- Never work on issues labelled `good first issue`; those PRs are closed automatically.
|
||||
- Run every build, lint, test and codegen task through mise: `mise //<dir>:<task>`, root tasks as `mise //:<task>`. `mise tasks ls --all` lists them.
|
||||
- Never hand-edit generated files: `open-api/immich-openapi-specs.json`, `packages/sdk/src/fetch-client.ts`, `mobile/generated/openapi/`, `server/src/queries/*.sql`, and Dart codegen (`*.g.dart`, `*.drift.dart`, `*.freezed.dart`, `router.gr.dart`, `lib/generated/`, `lib/platform/*.g.dart`).
|
||||
- Only edit `i18n/en.json`; the other locale files come from Weblate.
|
||||
- Do not add code comments. Make the code self-explanatory through naming and structure instead.
|
||||
- PR titles are conventional commits. Keep a PR to one focused change.
|
||||
- Sharing/asset ownership and external libraries are feature-frozen: bug fixes only.
|
||||
- Renaming or removing a docs page requires an entry in `docs/static/_redirects`.
|
||||
|
||||
## Skills
|
||||
|
||||
- **Regenerate the API clients**: after any change to a server controller or DTO, run `mise //:open-api`. It rebuilds the server, rewrites the OpenAPI spec, regenerates the TypeScript SDK and the Dart client. The CI job "OpenAPI Clients" fails if this is stale. If a response field became required, the Dart client also needs a backward-compatibility patch in `open-api/patch`, checked by `flutter test test/openapi_patches_coverage.dart` (job "check-mobile-patches").
|
||||
- **Add a translation string**: add the key to `i18n/en.json`, then run `mise //:i18n:format-fix` so the file stays key-sorted (CI job "Test i18n" fails otherwise). For the mobile app also run `mise //mobile:translation`. Web keys are typed against `en.json`, so the key must exist before `tsc` passes.
|
||||
@@ -33,6 +33,7 @@
|
||||
<a href="readme_i18n/README_uk_UA.md">Українська</a>
|
||||
<a href="readme_i18n/README_ru_RU.md">Русский</a>
|
||||
<a href="readme_i18n/README_bg_BG.md">Български</a>
|
||||
<a href="readme_i18n/README_pl_PL.md">Polski</a>
|
||||
<a href="readme_i18n/README_pt_BR.md">Português Brasileiro</a>
|
||||
<a href="readme_i18n/README_sv_SE.md">Svenska</a>
|
||||
<a href="readme_i18n/README_ar_JO.md">العربية</a>
|
||||
|
||||
+42
-52
@@ -1,79 +1,69 @@
|
||||
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
||||
|
||||
[[tools.opentofu]]
|
||||
version = "1.12.6"
|
||||
version = "1.13.2"
|
||||
backend = "aqua:opentofu/opentofu"
|
||||
|
||||
[tools.opentofu."platforms.linux-arm64"]
|
||||
checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688"
|
||||
|
||||
[tools.opentofu."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688"
|
||||
checksum = "sha256:88e2d9e3f94f97b76ab2faf55ade87578f56aed19c5e45fa139d70e994366d1f"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742799"
|
||||
|
||||
[tools.opentofu."platforms.linux-x64"]
|
||||
checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723"
|
||||
|
||||
[tools.opentofu."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723"
|
||||
checksum = "sha256:ac05d9952662c7db28dd57e93f0a68018837bfdf9342384ec0987977c16a9645"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742636"
|
||||
|
||||
[tools.opentofu."platforms.macos-arm64"]
|
||||
checksum = "sha256:f958ec5e511063be9feb180ca015a4cb7977566a9cf6a8550bba8c2a9b5aba74"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762710"
|
||||
checksum = "sha256:0d1cdc85f38992055b48e7fb161304a2ddfe2e5ee0ff68ffec9d0349c8bd1b59"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742637"
|
||||
|
||||
[tools.opentofu."platforms.macos-x64"]
|
||||
checksum = "sha256:44bb1855f372f17f365fb94517906e78da5001da10f4c98de57a39bf982f3a92"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762655"
|
||||
checksum = "sha256:dc6e4eb1d3ed6550c2ef052596f933dbcd8c806957665ea83ae7537147cb3074"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742982"
|
||||
|
||||
[tools.opentofu."platforms.windows-x64"]
|
||||
checksum = "sha256:bcbcd9ca744af6c4053e2d5e75dae79ff33ad7dc38d45e1233e693d096284538"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_windows_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762708"
|
||||
checksum = "sha256:e74fc2bda0c81ce696b6b7016a4d5c1f557638161239e4101e9ee7d8e0faba59"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_windows_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742635"
|
||||
|
||||
[[tools.terragrunt]]
|
||||
version = "1.0.3"
|
||||
version = "1.2.1"
|
||||
backend = "aqua:gruntwork-io/terragrunt"
|
||||
|
||||
[tools.terragrunt."platforms.linux-arm64"]
|
||||
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
|
||||
|
||||
[tools.terragrunt."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
|
||||
checksum = "sha256:8b92d56a20b2848365e22ef94f963fbfe25fedeebcacffacbc286cfcf9966066"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.1/terragrunt_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/670002309"
|
||||
|
||||
[tools.terragrunt."platforms.linux-x64"]
|
||||
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
|
||||
|
||||
[tools.terragrunt."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
|
||||
checksum = "sha256:08c8844cc69e4f03451ebb8f347cb57d9bd6b8db7b16e437ae5ec6f001228ef9"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.1/terragrunt_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/670001904"
|
||||
|
||||
[tools.terragrunt."platforms.macos-arm64"]
|
||||
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654084"
|
||||
checksum = "sha256:0aded323f844ce6567a15feb26393b67eff1400dc1722203fa4dd4e23b3bb182"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.1/terragrunt_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/670001110"
|
||||
|
||||
[tools.terragrunt."platforms.macos-x64"]
|
||||
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406653970"
|
||||
checksum = "sha256:29806d1708f5c54e54bc6adfcacfb710d4bb1f6f180efd751692e6fad5985640"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.1/terragrunt_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/670000649"
|
||||
|
||||
[tools.terragrunt."platforms.windows-x64"]
|
||||
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654412"
|
||||
checksum = "sha256:4393df92d7f6a0bdfd5744f069460a4886c6371b921cd4b1be03c186195dc05c"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.1/terragrunt_windows_amd64.exe.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/670003102"
|
||||
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
terragrunt = "1.1.1"
|
||||
opentofu = "1.12.6"
|
||||
terragrunt = "1.2.1"
|
||||
opentofu = "1.13.2"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
|
||||
+28
-28
@@ -2,34 +2,34 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.52.9"
|
||||
constraints = "4.52.9"
|
||||
version = "4.52.10"
|
||||
constraints = "4.52.10"
|
||||
hashes = [
|
||||
"h1:LRBYMKjZyuld8kgVhzoceRtdtkht+w8LunrdpgqrL28=",
|
||||
"h1:Q2NeLtrjSTZwHHsMZXKpqOAbxF8DWgqpmH5j+1pUZGw=",
|
||||
"h1:Q4wiux11bDVWRmJScdjp8jLbxMRsj8k3xGtPtY4BPDo=",
|
||||
"h1:VVW111jEbRMTu7hXeGQMmZ/oSTB8LIYhjM5ztyymqAI=",
|
||||
"h1:Z7sTk8/B9XYFAEpTq1ib/WsB2QvpegGwef395tHCUSo=",
|
||||
"h1:eakzS6UsflV4gN/oQWdRPYvvmySKkhgKPyykCfPSVH0=",
|
||||
"h1:eesNMA1JUNBKCnuEvRLW2CT3tNrIejbXJG4A176nA9Q=",
|
||||
"h1:o8txgjqnDDODxQQ6ITwHsvPqHllCbMGnJkjCWNJK5ME=",
|
||||
"h1:rDTPIzab3tbGdtU3NmES08+tRlKG8jygNAFDFbheiuM=",
|
||||
"h1:sIoAt5vaxgFBrst1Esfm7ixQQ/T7/JNPZu0m4bw+Bc4=",
|
||||
"h1:sPCs3KnD/db5TMz2CNgiYix7sRS8+FP92T4ipSYT9vw=",
|
||||
"h1:uXPLsMQUYVJjRGUfmfDQXsKzYr1scvF6PYKj+3f0Nr4=",
|
||||
"h1:vQibC9lgBRQjRQ5KxFH61wd3/q2I40groLPjZ/jiikQ=",
|
||||
"zh:0826cad9b57368f06466ec3e1f766086f44a373288753e144f681ddc0e61ab49",
|
||||
"zh:15bf8d8abdcd202652466f3ef91110f143a7fad13c6173c7180e3dc0dbe9254b",
|
||||
"zh:1a60d1d2e55a17942902b1ccc91e8f6cb150c035843d3eefc074f1291a0e7d5f",
|
||||
"zh:23373a63fe06bbfa90c6c105c08429031719bedafa2458973cbdad32a384b5a7",
|
||||
"zh:256a97b79cedee2575002f77ac3bf18888ce0fc97170ae8a357db406bb0a88e3",
|
||||
"zh:3dd9f93c167cd59491ae77781032491d36a80843a1e44f9ee68b41b3f8e3ab8e",
|
||||
"zh:5182d2d61930dd83584d8feb56102614f80ddeec532b939b58085ad74b796a6e",
|
||||
"zh:5e3ca1888ba3b3bd8abd0f7dfcdd82fb5a8819affee899ed7fd891a33c17e0cb",
|
||||
"zh:7a3ce2119e39fb3a90a6a459a26863851309dbc92e0735336cbc98692539cb4d",
|
||||
"zh:c095dc680a646cc078c10bb0551e2ea8f08e7abdbcbc58cfd332c56b58788631",
|
||||
"zh:d2400e010e069c19916c99136e200cf873eba6db2b18d3eb9541b38eb1f565fa",
|
||||
"zh:dbc2f19f09aaefbd69334005bca2bcb0c9b690de1ed76f7ba97ce8e59a6e85df",
|
||||
"zh:e685225f432da3e23b8717848de8c0ad0e22363450b2ac92cd24a56739ab5e9b",
|
||||
"h1:57oeYqz+pYEPAw2GvHARjcER0QYU0teS6DJzS26kCAY=",
|
||||
"h1:6bnAgXSboppQbj79XmRtQLj/58d+b4EozydI8XrqwRE=",
|
||||
"h1:6eP/jGOz1ioH17d3pygKhvsRa4AEKvedKqd+wgyiHYk=",
|
||||
"h1:84gJm57BBSvtfwt7t9ZiU7EKKDQARjsztoDuqB5DfqU=",
|
||||
"h1:99tME9L/Ug/dXElX3Ac7Kx+KD7nhWEEwOTqaQubW18w=",
|
||||
"h1:TnMGVaPpIH5NZX324v+y6j1iW3ym0MqqNekxHlzXT3A=",
|
||||
"h1:Z1L5jI4GiJxAAsfNyZHU4MGTluWEjjKtlB8Xn6cA0Dg=",
|
||||
"h1:dB/WsY27MLla/cwjYggolJhVeHUeS59rlOObAWK2e24=",
|
||||
"h1:gpffNocq9fHK+uQehZQK6YmL1aHeCGrVM1p53dcFP/0=",
|
||||
"h1:jvfw1A2Uck4AjNmmWXwGH6P0sGQrnNY/KUVlqgyqYUM=",
|
||||
"h1:ojvrl9uI1EPtAwKRHim9eSc4Lc88thU/2BFGdJLEwrc=",
|
||||
"h1:rJZJK06NYCcSZhY5fwsRUSznSmAmRWVg4sO0auvbURo=",
|
||||
"h1:y2m6i2SnQ3ZxiJHlMWNLHJrEbNqQT6OpDGyhFP+Nmds=",
|
||||
"zh:06c09cc50d6c61fb5f1ca79e4b22d5f383c6a848f885ed542dadef44ab4bfd55",
|
||||
"zh:205bdfdac49d6b25ec2ca7ecffd39bf47126340e1943066bdbbd64d6d72577d2",
|
||||
"zh:341ed688772d8e2bd9a148c57d56d5dfbe3d625bc50296a505cbae3d29694562",
|
||||
"zh:3790619bae55558ab4d93f53686f7cf55f35138f800ea07b0b9c20cce75b9c0d",
|
||||
"zh:44284a5b67e34f1381bbc47b6e765cee581037e25cd3b459caec069493b63898",
|
||||
"zh:493a173f3d5245db82782e8c1aba63e34c6fcf6b952b9288926b519bfe9d9725",
|
||||
"zh:4eb7846381fee6eefdb7e0aaf80153ed71d35547752f96987218a9429645844f",
|
||||
"zh:850b1d1da770f48def40223d754ff65c31fabb1a7f334ed6ebb40eba09c41572",
|
||||
"zh:9a29a08a9fd40d7c1beefe8597617c2fc475d62ec6d5ca8fcc2cd1a056a67a2e",
|
||||
"zh:9e57472f0a56863204f5b2eef125d4fb1222eca793c6a5ac9f4df6c1776edc48",
|
||||
"zh:a9eff98931da596c2d335359841bfd668f4b6cde257e15546a558c7f5d72773b",
|
||||
"zh:c9293fb3390c79e4f5072e019c0a2c4c7408a55a55e78ae37f739a1da75886ef",
|
||||
"zh:c9e98abec7f7a1e2669dd9c065896217125d774640e4673c1303a942736b3eb4",
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@ terraform {
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.9"
|
||||
version = "4.52.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-28
@@ -2,34 +2,34 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.52.9"
|
||||
constraints = "4.52.9"
|
||||
version = "4.52.10"
|
||||
constraints = "4.52.10"
|
||||
hashes = [
|
||||
"h1:LRBYMKjZyuld8kgVhzoceRtdtkht+w8LunrdpgqrL28=",
|
||||
"h1:Q2NeLtrjSTZwHHsMZXKpqOAbxF8DWgqpmH5j+1pUZGw=",
|
||||
"h1:Q4wiux11bDVWRmJScdjp8jLbxMRsj8k3xGtPtY4BPDo=",
|
||||
"h1:VVW111jEbRMTu7hXeGQMmZ/oSTB8LIYhjM5ztyymqAI=",
|
||||
"h1:Z7sTk8/B9XYFAEpTq1ib/WsB2QvpegGwef395tHCUSo=",
|
||||
"h1:eakzS6UsflV4gN/oQWdRPYvvmySKkhgKPyykCfPSVH0=",
|
||||
"h1:eesNMA1JUNBKCnuEvRLW2CT3tNrIejbXJG4A176nA9Q=",
|
||||
"h1:o8txgjqnDDODxQQ6ITwHsvPqHllCbMGnJkjCWNJK5ME=",
|
||||
"h1:rDTPIzab3tbGdtU3NmES08+tRlKG8jygNAFDFbheiuM=",
|
||||
"h1:sIoAt5vaxgFBrst1Esfm7ixQQ/T7/JNPZu0m4bw+Bc4=",
|
||||
"h1:sPCs3KnD/db5TMz2CNgiYix7sRS8+FP92T4ipSYT9vw=",
|
||||
"h1:uXPLsMQUYVJjRGUfmfDQXsKzYr1scvF6PYKj+3f0Nr4=",
|
||||
"h1:vQibC9lgBRQjRQ5KxFH61wd3/q2I40groLPjZ/jiikQ=",
|
||||
"zh:0826cad9b57368f06466ec3e1f766086f44a373288753e144f681ddc0e61ab49",
|
||||
"zh:15bf8d8abdcd202652466f3ef91110f143a7fad13c6173c7180e3dc0dbe9254b",
|
||||
"zh:1a60d1d2e55a17942902b1ccc91e8f6cb150c035843d3eefc074f1291a0e7d5f",
|
||||
"zh:23373a63fe06bbfa90c6c105c08429031719bedafa2458973cbdad32a384b5a7",
|
||||
"zh:256a97b79cedee2575002f77ac3bf18888ce0fc97170ae8a357db406bb0a88e3",
|
||||
"zh:3dd9f93c167cd59491ae77781032491d36a80843a1e44f9ee68b41b3f8e3ab8e",
|
||||
"zh:5182d2d61930dd83584d8feb56102614f80ddeec532b939b58085ad74b796a6e",
|
||||
"zh:5e3ca1888ba3b3bd8abd0f7dfcdd82fb5a8819affee899ed7fd891a33c17e0cb",
|
||||
"zh:7a3ce2119e39fb3a90a6a459a26863851309dbc92e0735336cbc98692539cb4d",
|
||||
"zh:c095dc680a646cc078c10bb0551e2ea8f08e7abdbcbc58cfd332c56b58788631",
|
||||
"zh:d2400e010e069c19916c99136e200cf873eba6db2b18d3eb9541b38eb1f565fa",
|
||||
"zh:dbc2f19f09aaefbd69334005bca2bcb0c9b690de1ed76f7ba97ce8e59a6e85df",
|
||||
"zh:e685225f432da3e23b8717848de8c0ad0e22363450b2ac92cd24a56739ab5e9b",
|
||||
"h1:57oeYqz+pYEPAw2GvHARjcER0QYU0teS6DJzS26kCAY=",
|
||||
"h1:6bnAgXSboppQbj79XmRtQLj/58d+b4EozydI8XrqwRE=",
|
||||
"h1:6eP/jGOz1ioH17d3pygKhvsRa4AEKvedKqd+wgyiHYk=",
|
||||
"h1:84gJm57BBSvtfwt7t9ZiU7EKKDQARjsztoDuqB5DfqU=",
|
||||
"h1:99tME9L/Ug/dXElX3Ac7Kx+KD7nhWEEwOTqaQubW18w=",
|
||||
"h1:TnMGVaPpIH5NZX324v+y6j1iW3ym0MqqNekxHlzXT3A=",
|
||||
"h1:Z1L5jI4GiJxAAsfNyZHU4MGTluWEjjKtlB8Xn6cA0Dg=",
|
||||
"h1:dB/WsY27MLla/cwjYggolJhVeHUeS59rlOObAWK2e24=",
|
||||
"h1:gpffNocq9fHK+uQehZQK6YmL1aHeCGrVM1p53dcFP/0=",
|
||||
"h1:jvfw1A2Uck4AjNmmWXwGH6P0sGQrnNY/KUVlqgyqYUM=",
|
||||
"h1:ojvrl9uI1EPtAwKRHim9eSc4Lc88thU/2BFGdJLEwrc=",
|
||||
"h1:rJZJK06NYCcSZhY5fwsRUSznSmAmRWVg4sO0auvbURo=",
|
||||
"h1:y2m6i2SnQ3ZxiJHlMWNLHJrEbNqQT6OpDGyhFP+Nmds=",
|
||||
"zh:06c09cc50d6c61fb5f1ca79e4b22d5f383c6a848f885ed542dadef44ab4bfd55",
|
||||
"zh:205bdfdac49d6b25ec2ca7ecffd39bf47126340e1943066bdbbd64d6d72577d2",
|
||||
"zh:341ed688772d8e2bd9a148c57d56d5dfbe3d625bc50296a505cbae3d29694562",
|
||||
"zh:3790619bae55558ab4d93f53686f7cf55f35138f800ea07b0b9c20cce75b9c0d",
|
||||
"zh:44284a5b67e34f1381bbc47b6e765cee581037e25cd3b459caec069493b63898",
|
||||
"zh:493a173f3d5245db82782e8c1aba63e34c6fcf6b952b9288926b519bfe9d9725",
|
||||
"zh:4eb7846381fee6eefdb7e0aaf80153ed71d35547752f96987218a9429645844f",
|
||||
"zh:850b1d1da770f48def40223d754ff65c31fabb1a7f334ed6ebb40eba09c41572",
|
||||
"zh:9a29a08a9fd40d7c1beefe8597617c2fc475d62ec6d5ca8fcc2cd1a056a67a2e",
|
||||
"zh:9e57472f0a56863204f5b2eef125d4fb1222eca793c6a5ac9f4df6c1776edc48",
|
||||
"zh:a9eff98931da596c2d335359841bfd668f4b6cde257e15546a558c7f5d72773b",
|
||||
"zh:c9293fb3390c79e4f5072e019c0a2c4c7408a55a55e78ae37f739a1da75886ef",
|
||||
"zh:c9e98abec7f7a1e2669dd9c065896217125d774640e4673c1303a942736b3eb4",
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@ terraform {
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.9"
|
||||
version = "4.52.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:c123e3715db63d06d4ad6964884037aa0d5d4d703939b9929954112889708e1d
|
||||
image: docker.io/valkey/valkey:9@sha256:3add1a336a920ea54770a001ab6feb3da5fceea96a40373a3a2cd469b815f0b7
|
||||
healthcheck:
|
||||
test: redis-cli ping | grep -q PONG || exit 1
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:c123e3715db63d06d4ad6964884037aa0d5d4d703939b9929954112889708e1d
|
||||
image: docker.io/valkey/valkey:9@sha256:3add1a336a920ea54770a001ab6feb3da5fceea96a40373a3a2cd469b815f0b7
|
||||
healthcheck:
|
||||
test: redis-cli ping | grep -q PONG || exit 1
|
||||
restart: always
|
||||
@@ -85,7 +85,7 @@ services:
|
||||
container_name: immich_prometheus
|
||||
ports:
|
||||
- 9090:9090
|
||||
image: prom/prometheus@sha256:5ce7540c3c00ef4ab0c9d2c995c6a5b9c421f44b4a115d97a2c7af3b1c21cbb0
|
||||
image: prom/prometheus@sha256:fa3302279710be2f21a8301ec17a8b4f2301fd829bd113839b3b6b02b6f191a2
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus-data:/prometheus
|
||||
@@ -97,7 +97,7 @@ services:
|
||||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:12.4.11-ubuntu@sha256:23f2328b859c7677c7065c81b479600706ef9cc9004977af5bbd68b159b8ea60
|
||||
image: grafana/grafana:13.3.0-ubuntu@sha256:74111b957a8528c1d7f396f57ded8d08a6c7d856ef80eea3f637daeabdde3aa9
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:c123e3715db63d06d4ad6964884037aa0d5d4d703939b9929954112889708e1d
|
||||
image: docker.io/valkey/valkey:9@sha256:3add1a336a920ea54770a001ab6feb3da5fceea96a40373a3a2cd469b815f0b7
|
||||
user: '1000:1000'
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:c123e3715db63d06d4ad6964884037aa0d5d4d703939b9929954112889708e1d
|
||||
image: docker.io/valkey/valkey:9@sha256:3add1a336a920ea54770a001ab6feb3da5fceea96a40373a3a2cd469b815f0b7
|
||||
healthcheck:
|
||||
test: redis-cli ping | grep -q PONG || exit 1
|
||||
restart: always
|
||||
|
||||
@@ -34,7 +34,7 @@ The mobile app is written in [Dart](https://dart.dev/) using [Flutter](https://f
|
||||
<MobileArchitecture className="p-4 dark:bg-immich-dark-primary my-4" />
|
||||
|
||||
The diagrams shows the target architecture, the current state of the code-base is not always following the architecture yet. New code and contributions should follow this architecture.
|
||||
Currently, it uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management (providers).
|
||||
Currently, it uses [Drift](https://drift.simonbinder.eu/) (SQLite) for a local database and [Riverpod](https://riverpod.dev/) for state management (providers).
|
||||
Entities and Models are the two types of data classes used. While entities are stored in the on-device database, models are ephemeral and only kept in memory.
|
||||
The Repositories should be the only place where other data classes are used internally (such as OpenAPI DTOs). However, their interfaces must not use foreign data classes!
|
||||
|
||||
|
||||
@@ -85,7 +85,9 @@ You do not need to redo any machine learning jobs after enabling hardware accele
|
||||
- RKNPU driver V0.9.8 or later must be available in the host server
|
||||
- You may confirm this by running `cat /sys/kernel/debug/rknpu/version` to check the version
|
||||
- Optional: Configure your `.env` file, see [environment variables](/install/environment-variables) for RKNN specific settings
|
||||
- In particular, setting `MACHINE_LEARNING_RKNN_THREADS` to 2 or 3 can _dramatically_ improve performance for RK3576 and RK3588 compared to the default of 1, at the expense of multiplying the amount of RAM each model uses by that amount.
|
||||
- By default each model gets one context per NPU core (1 on RK3566/RK3568, 2 on RK3576, 3 on RK3588), so concurrent requests run on all cores at once. The contexts of a model share its weights, but each one needs its own working memory. Extra contexts therefore cost little for models that are mostly weights, such as CLIP, but for OCR detection each one can add close to the model's own memory again, more so at higher OCR resolutions. Set `MACHINE_LEARNING_RKNN_THREADS=1` to trade throughput for memory.
|
||||
- Keep `MACHINE_LEARNING_WORKERS=1` unless you have a specific reason not to: each worker process still loads its own copy of the weights.
|
||||
- Raise Immich job concurrency (Smart Search, Face Detection, OCR) a bit above the number of NPU cores, e.g. `4`–`6`, so a prepared image is always waiting when a core frees up.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -100,7 +102,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele
|
||||
|
||||
You can confirm the device is being recognized and used by checking its utilization. There are many tools to display this, such as `nvtop` for NVIDIA or Intel, `intel_gpu_top` for Intel, and `radeontop` for AMD.
|
||||
|
||||
You can also check the logs of the `immich-machine-learning` container. When a Smart Search or Face Detection job begins, or when you search with text in Immich, you should either see a log for `Available ORT providers` containing the relevant provider (e.g. `CUDAExecutionProvider` in the case of CUDA), or a `Loaded ANN model` log entry without errors in the case of ARM NN.
|
||||
You can also check the logs of the `immich-machine-learning` container. When a Smart Search or Face Detection job begins, or when you search with text in Immich, you should either see a log for `Available ORT providers` containing the relevant provider (e.g. `CUDAExecutionProvider` in the case of CUDA), a `Loaded ANN model` log entry without errors in the case of ARM NN, or a `Loaded RKNN model ... threads sharing its weights` log entry for RKNN.
|
||||
|
||||
#### Single Compose File
|
||||
|
||||
@@ -174,7 +176,7 @@ Note that you should increase job concurrencies to increase overall utilization
|
||||
- Less heat generation
|
||||
- Very slightly lower accuracy (RKNPU always uses FP16, while ARM NN by default uses higher precision FP32 unless `MACHINE_LEARNING_ANN_FP16_TURBO` is enabled)
|
||||
- Varying speed (tested on RK3588):
|
||||
- If `MACHINE_LEARNING_RKNN_THREADS` is at the default of 1, RKNPU will have substantially lower throughput for ML jobs than ARM NN in most cases, but similar latency (such as when searching)
|
||||
- If `MACHINE_LEARNING_RKNN_THREADS` is set to 3, it will be somewhat faster than ARM NN at FP32, but somewhat slower than ARM NN if `MACHINE_LEARNING_ANN_FP16_TURBO` is enabled
|
||||
- With a single context (`MACHINE_LEARNING_RKNN_THREADS=1`), RKNPU will have substantially lower throughput for ML jobs than ARM NN in most cases, but similar latency (such as when searching)
|
||||
- With three contexts (the default on RK3588), it will be somewhat faster than ARM NN at FP32, but somewhat slower than ARM NN if `MACHINE_LEARNING_ANN_FP16_TURBO` is enabled
|
||||
- When other tasks also use the GPU (like transcoding), RKNPU has a significant advantage over ARM NN as it uses the otherwise idle NPU instead of competing for GPU usage
|
||||
- Lower RAM usage if `MACHINE_LEARNING_RKNN_THREADS` is at the default of 1, but significantly higher if greater than 1 (which is necessary for it to fully utilize the NPU and hence be comparable in speed to ARM NN)
|
||||
- Lower RAM usage than earlier RKNN builds: the contexts of a model share its weights instead of each loading their own copy. Keep `MACHINE_LEARNING_WORKERS=1` and raise Immich ML job concurrency a bit above the core count so every NPU core stays busy.
|
||||
@@ -209,6 +209,11 @@ The default configuration looks like this:
|
||||
"timeout": 30000,
|
||||
"tokenEndpointAuthMethod": "client_secret_post"
|
||||
},
|
||||
"passkey": {
|
||||
"enabled": true,
|
||||
"domain": null,
|
||||
"additionalDomains": []
|
||||
},
|
||||
"passwordLogin": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
@@ -179,7 +179,7 @@ Redis (Sentinel) URL example JSON before encoding:
|
||||
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | `4` | machine learning |
|
||||
| `MACHINE_LEARNING_MAX_BATCH_SIZE__OCR` | Set the maximum number of boxes that will be processed at once by the OCR model | `6` | machine learning |
|
||||
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
|
||||
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning |
|
||||
| `MACHINE_LEARNING_RKNN_THREADS` | Number of RKNN contexts per model, which share its weights | number of NPU cores | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_REVISION` | Model revision to download; `main` selects the original exports and `v2` selects the newer exports | `main` | machine learning |
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
||||
|
||||
[[tools.wrangler]]
|
||||
version = "4.137.0"
|
||||
version = "4.155.0"
|
||||
backend = "npm:wrangler"
|
||||
|
||||
[tools.wrangler.options]
|
||||
|
||||
+1
-1
@@ -28,4 +28,4 @@ run = "prettier --write ."
|
||||
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
|
||||
|
||||
[tools]
|
||||
wrangler = "4.137.0"
|
||||
wrangler = "4.155.0"
|
||||
@@ -0,0 +1,14 @@
|
||||
# e2e/AGENTS.md
|
||||
|
||||
End-to-end tests against a real Immich stack from `docker-compose.yml` (server on port 2285): API specs with vitest, browser specs with Playwright.
|
||||
|
||||
## Rules
|
||||
|
||||
- `test-assets/` is a git submodule; initialize it before running exif or upload specs.
|
||||
- Use the shared helpers in `src/utils.ts` (`utils.adminSetup`, `utils.userSetup`, `utils.resetDatabase`, `utils.createAsset`, `utils.waitForQueueFinish`, `asBearerAuth`, `asKeyAuth`, `immichCli`) instead of re-implementing login or fixtures.
|
||||
|
||||
## Skills
|
||||
|
||||
- **Run API specs**: `src/specs/server/**/*.e2e-spec.ts`, run with `mise //e2e:test` (it builds the SDK, CLI and Docker images first). If nothing answers at `127.0.0.1:2285`, the vitest `globalSetup` starts the Docker stack itself; set `VITEST_DISABLE_DOCKER_SETUP=true` to reuse a running one. One file: `pnpm exec vitest --run src/specs/server/api/album.e2e-spec.ts`. Maintenance-mode specs use `pnpm exec vitest --run --config vitest.maintenance.config.ts`.
|
||||
- **Run browser specs**: Playwright projects are `web` (`src/specs/web/`, real backend), `maintenance` (`src/specs/maintenance/web/`), and `ui` (`src/ui/specs/`, mocked network from `src/ui/mock-network/`). `mise //e2e:test-web` runs all projects and installs browsers first. One project or file: `pnpm exec playwright test --project=web src/specs/web/album.e2e-spec.ts`; add `--ui` for the Playwright UI. Set `PLAYWRIGHT_DISABLE_WEBSERVER=1` to reuse a running stack.
|
||||
- **Write an API spec**: in `beforeAll` call `await utils.resetDatabase()` and `admin = await utils.adminSetup()`, create users with `utils.userSetup(admin.accessToken, dto)`, and call the server through `@immich/sdk` functions with `asBearerAuth(token)` or through `supertest` on `app` for raw status-code and validation checks. After uploads, `await utils.waitForQueueFinish(admin.accessToken, 'thumbnailGeneration')` before asserting on derived data.
|
||||
@@ -46,7 +46,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich-e2e-redis
|
||||
image: docker.io/valkey/valkey:9@sha256:c123e3715db63d06d4ad6964884037aa0d5d4d703939b9929954112889708e1d
|
||||
image: ghcr.io/valkey-io/valkey:9@sha256:3add1a336a920ea54770a001ab6feb3da5fceea96a40373a3a2cd469b815f0b7
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
+6
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "3.3.3",
|
||||
"version": "3.4.0-rc.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
@@ -10,10 +10,8 @@
|
||||
"test:maintenance": "vitest --run --config vitest.maintenance.config.ts",
|
||||
"test:web": "pnpm exec playwright test --project=web",
|
||||
"test:web:maintenance": "pnpm exec playwright test --project=maintenance",
|
||||
"test:web:ui": "pnpm exec playwright test --project=ui",
|
||||
"start:web": "pnpm exec playwright test --ui --project=web",
|
||||
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
|
||||
"start:web:ui": "pnpm exec playwright test --ui --project=ui",
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different .",
|
||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||
@@ -25,33 +23,32 @@
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"@futo-org/backups-orchestrator-ui": "1.13.1",
|
||||
"@immich/cli": "workspace:*",
|
||||
"@immich/e2e-auth-server": "workspace:*",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^24.19.2",
|
||||
"@types/node": "^26.6.6",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"@typescript/native": "npm:typescript@^7.0.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^75.0.0",
|
||||
"exiftool-vendored": "^35.0.0",
|
||||
"eslint-plugin-unicorn": "^78.0.0",
|
||||
"exiftool-vendored": "^39.1.0",
|
||||
"globals": "^17.0.0",
|
||||
"luxon": "^3.4.4",
|
||||
"pg": "^8.11.3",
|
||||
"pngjs": "^7.0.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"sharp": "^0.35.3",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"supertest": "^7.0.0",
|
||||
"@typescript/native": "npm:typescript@^7.0.2",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"utimes": "^5.2.1",
|
||||
|
||||
@@ -11,8 +11,6 @@ export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${pla
|
||||
export const playwriteSlowMo = Number.parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
|
||||
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
|
||||
|
||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: './src/specs/server',
|
||||
testMatch: /.*\.e2e-spec\.ts/,
|
||||
@@ -38,13 +36,6 @@ const config: PlaywrightTestConfig = {
|
||||
testDir: './src/specs/web',
|
||||
workers: 1,
|
||||
},
|
||||
{
|
||||
name: 'ui',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
testDir: './src/ui/specs',
|
||||
fullyParallel: true,
|
||||
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
||||
},
|
||||
{
|
||||
name: 'maintenance',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import * as sdk from '@futo-org/backups-orchestrator-ui/sdk';
|
||||
import { LoginResponseDto, StorageFolder } from '@immich/sdk';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { createUserDto } from 'src/fixtures';
|
||||
import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/futo-backups', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let nonAdmin: LoginResponseDto;
|
||||
let requestOpts: any;
|
||||
let filename: string;
|
||||
|
||||
let socket: Socket;
|
||||
let libraryId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
sdk.defaults.baseUrl = baseUrl;
|
||||
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
||||
|
||||
requestOpts = { headers: asBearerAuth(admin.accessToken) };
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
await sdk.futoBackupsResetOrchestrator(requestOpts);
|
||||
|
||||
socket = io(baseUrl, {
|
||||
path: '/api/futo-backups/socket.io',
|
||||
transports: ['websocket'],
|
||||
extraHeaders: asBearerAuth(admin.accessToken),
|
||||
});
|
||||
|
||||
socket.onAny(console.info);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
socket.close();
|
||||
|
||||
// trigger update for orchestrator module config
|
||||
if (libraryId) {
|
||||
await utils.deleteLibrary(admin.accessToken, libraryId);
|
||||
}
|
||||
});
|
||||
|
||||
const finishedJobStatuses = new Set(['complete', 'warn', 'failed', 'cancelled', 'incomplete']);
|
||||
|
||||
const waitForMessage = (type: string, matches: (payload: any) => boolean = () => true) => {
|
||||
return new Promise((resolve) => {
|
||||
const listener = (msg: string) => {
|
||||
const payload = JSON.parse(msg);
|
||||
if (payload.type !== type || !matches(payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(payload);
|
||||
socket.offAny(listener);
|
||||
};
|
||||
|
||||
socket.onAny(listener);
|
||||
});
|
||||
};
|
||||
|
||||
describe('Orchestration Module', async () => {
|
||||
it('works', async () => {
|
||||
await expect(sdk.futoBackupsOnboardingStatus(requestOpts)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
hasOnboardedKey: false,
|
||||
hasBackend: false,
|
||||
hasBackup: false,
|
||||
hasSchedule: false,
|
||||
hasSkippedExtraConfig: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('is inaccessible without admin', async () => {
|
||||
await expect(sdk.futoBackupsOnboardingStatus({ headers: asBearerAuth(nonAdmin.accessToken) })).rejects.toEqual(
|
||||
expect.objectContaining({ data: { message: expect.any(String) } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('is inaccessible without logging in', async () => {
|
||||
await expect(sdk.futoBackupsOnboardingStatus()).rejects.toEqual(
|
||||
expect.objectContaining({ data: { message: 'Authentication required' } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Local Backup', { concurrent: false }, async () => {
|
||||
beforeAll(async () => {
|
||||
await sdk.futoBackupsImportRecoveryKey({ recoveryKey: '0'.repeat(64) }, requestOpts);
|
||||
});
|
||||
|
||||
it('configures a local backend', { concurrent: false }, async () => {
|
||||
await utils.mkFolder('/local-backend');
|
||||
|
||||
await sdk.futoBackupsCreateLocalBackend({ path: '/local-backend' }, requestOpts);
|
||||
});
|
||||
|
||||
it('configures Immich backup', { concurrent: false }, async () => {
|
||||
const event = waitForMessage('IntegrationUpdate');
|
||||
|
||||
await sdk.futoBackupsConfigureImmichIntegration(
|
||||
{
|
||||
name: 'Immich',
|
||||
worm: false,
|
||||
cron: '0 3 * * *',
|
||||
backupConfiguration: true,
|
||||
dataFolders: [StorageFolder.Backups, StorageFolder.Upload],
|
||||
libraries: ['all'],
|
||||
},
|
||||
requestOpts,
|
||||
);
|
||||
|
||||
await event;
|
||||
|
||||
await expect(sdk.futoBackupsGetIntegrations(requestOpts)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
immichIntegration: expect.objectContaining({
|
||||
configuration: {
|
||||
backupConfiguration: true,
|
||||
dataFolders: ['backups', 'upload'],
|
||||
libraries: ['all'],
|
||||
},
|
||||
}),
|
||||
immichState: {
|
||||
dataFolders: expect.arrayContaining(Object.values(StorageFolder)),
|
||||
dataPath: '/data',
|
||||
// may have stale libraries from other test runs
|
||||
libraries: expect.any(Array),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('updates configuration', { concurrent: false }, async () => {
|
||||
await utils.mkFolder('/test');
|
||||
|
||||
({ id: libraryId } = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
name: 'My Library',
|
||||
importPaths: ['/test'],
|
||||
}));
|
||||
|
||||
await expect(sdk.futoBackupsGetIntegrations(requestOpts)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
immichIntegration: expect.any(Object),
|
||||
immichState: expect.objectContaining({
|
||||
libraries: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'My Library',
|
||||
importPaths: ['/test'],
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a snapshot', { concurrent: false }, async () => {
|
||||
const event = waitForMessage('JobUpdate', ({ job }) => finishedJobStatuses.has(job?.status));
|
||||
|
||||
const { repositories } = await sdk.futoBackupsGetRepositories(requestOpts);
|
||||
const { id } = repositories[0];
|
||||
|
||||
filename = await utils.createBackup(admin.accessToken);
|
||||
|
||||
await sdk.futoBackupsCreateBackup(id, requestOpts);
|
||||
await event;
|
||||
|
||||
const { snapshots } = await sdk.futoBackupsGetSnapshots(id, requestOpts);
|
||||
const { id: snapshotId } = snapshots[0];
|
||||
|
||||
await expect(sdk.futoBackupsGetSnapshotListing(id, snapshotId, {}, requestOpts)).resolves.toMatchInlineSnapshot(`
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"isDirectory": true,
|
||||
"path": "/data",
|
||||
},
|
||||
{
|
||||
"isDirectory": true,
|
||||
"path": "/test",
|
||||
},
|
||||
],
|
||||
"parent": "/",
|
||||
"path": "/",
|
||||
}
|
||||
`);
|
||||
|
||||
await expect(sdk.futoBackupsGetSnapshotListing(id, snapshotId, { path: '/data' }, requestOpts)).resolves
|
||||
.toMatchInlineSnapshot(`
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"isDirectory": true,
|
||||
"path": "/data/backups",
|
||||
},
|
||||
{
|
||||
"isDirectory": true,
|
||||
"path": "/data/upload",
|
||||
},
|
||||
],
|
||||
"parent": "/",
|
||||
"path": "/data",
|
||||
}
|
||||
`);
|
||||
|
||||
await expect(
|
||||
sdk.futoBackupsGetSnapshotListing(id, snapshotId, { path: '/data/backups' }, requestOpts),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
{ isDirectory: false, path: '/data/backups/.immich' },
|
||||
{ isDirectory: false, path: `/data/backups/${filename}` },
|
||||
{ isDirectory: true, path: '/data/backups/yucca' },
|
||||
]),
|
||||
parent: '/data',
|
||||
path: '/data/backups',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Restore Local Backup', { concurrent: false }, async () => {
|
||||
let cookie: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await sdk.futoBackupsResetOrchestrator(requestOpts);
|
||||
await utils.resetDatabase();
|
||||
socket.disconnect();
|
||||
await utils.disconnectDatabase();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await utils.connectDatabase();
|
||||
});
|
||||
|
||||
it('restores backup', { timeout: 60_000, concurrent: false }, async () => {
|
||||
const { status, headers } = await request(app).post('/admin/database-backups/start-restore').send();
|
||||
expect(status).toBe(201);
|
||||
cookie = headers['set-cookie'][0].split(';', 1)[0];
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
const maintenanceRequestOpts = {
|
||||
headers: {
|
||||
cookie,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(sdk.futoBackupsGetSchedules(maintenanceRequestOpts)).resolves.toEqual({ schedules: [] });
|
||||
|
||||
await sdk.futoBackupsImportRecoveryKey({ recoveryKey: '0'.repeat(64) }, maintenanceRequestOpts);
|
||||
|
||||
const {
|
||||
backend: { id: backendId },
|
||||
} = await sdk.futoBackupsCreateLocalBackend({ path: '/local-backend' }, maintenanceRequestOpts);
|
||||
|
||||
const { repositories } = await sdk.futoBackupsInspectRepositories({}, maintenanceRequestOpts);
|
||||
const { id: repositoryId, snapshots } = repositories[0];
|
||||
const { id: snapshotId } = (snapshots ?? [])[0];
|
||||
|
||||
socket = io(baseUrl, {
|
||||
path: '/api/futo-backups/socket.io',
|
||||
transports: ['websocket'],
|
||||
extraHeaders: {
|
||||
cookie,
|
||||
},
|
||||
});
|
||||
|
||||
const event = waitForMessage('JobUpdate', ({ job }) => finishedJobStatuses.has(job?.status));
|
||||
await sdk.futoBackupsRestoreFromPoint(
|
||||
repositoryId,
|
||||
snapshotId,
|
||||
backendId,
|
||||
{
|
||||
yuccaConfig: '/data/yucca',
|
||||
include: ['/data'],
|
||||
},
|
||||
maintenanceRequestOpts,
|
||||
);
|
||||
|
||||
await event;
|
||||
socket.disconnect();
|
||||
|
||||
const { status: restoreStatus } = await request(app).post('/admin/maintenance').set('Cookie', cookie).send({
|
||||
action: 'restore_database',
|
||||
restoreBackupFilename: filename,
|
||||
});
|
||||
|
||||
expect(restoreStatus).toBe(201);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
const { status: status2, body } = await request(app).get('/admin/maintenance/status');
|
||||
expect(status2).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 60_000,
|
||||
},
|
||||
)
|
||||
.toBeFalsy();
|
||||
|
||||
await expect(sdk.futoBackupsGetSchedules(requestOpts)).resolves.toEqual({
|
||||
schedules: expect.arrayContaining([expect.objectContaining({ id: expect.any(String) })]),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,7 @@ test.describe('Database Backups', () => {
|
||||
await page.waitForURL('/maintenance**');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Database Backup' }).click();
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import * as sdk from '@futo-org/backups-orchestrator-ui/sdk';
|
||||
import { LoginResponseDto, StorageFolder, getServerFeatures } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { asBearerAuth, baseUrl, utils } from 'src/utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('FUTO Backups', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let socket: Socket;
|
||||
|
||||
const finishedJobStatuses = new Set(['complete', 'warn', 'failed', 'cancelled', 'incomplete']);
|
||||
|
||||
const waitForJobEnd = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
const listener = (msg: string) => {
|
||||
try {
|
||||
const payload = JSON.parse(msg);
|
||||
if (payload.type === 'JobUpdate' && finishedJobStatuses.has(payload.job?.status)) {
|
||||
socket.offAny(listener);
|
||||
resolve();
|
||||
}
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
socket.onAny(listener);
|
||||
});
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
sdk.defaults.baseUrl = baseUrl;
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
|
||||
const headers = asBearerAuth(admin.accessToken);
|
||||
await sdk.futoBackupsResetOrchestrator({ headers });
|
||||
await sdk.futoBackupsImportRecoveryKey({ recoveryKey: '0'.repeat(64) }, { headers });
|
||||
await sdk.futoBackupsConfirmRecoveryKey({ headers });
|
||||
await sdk.futoBackupsEnableTelemetry({ headers });
|
||||
await utils.mkFolder('/local-backend');
|
||||
|
||||
socket = io(baseUrl, {
|
||||
path: '/api/futo-backups/socket.io',
|
||||
transports: ['websocket'],
|
||||
extraHeaders: headers,
|
||||
forceNew: true,
|
||||
});
|
||||
await new Promise<void>((resolve) => socket.on('connect', () => resolve()));
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
socket?.close();
|
||||
});
|
||||
|
||||
test('the backups link opens the upsell before backups are configured', async ({ context, page }) => {
|
||||
const headers = asBearerAuth(admin.accessToken);
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await expect(getServerFeatures({ headers })).resolves.toMatchObject({ backups: false });
|
||||
|
||||
await page.goto('/link?target=backups');
|
||||
await page.waitForURL('/admin/backups');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'FUTO Backups', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Get Started' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Set up Backups' })).toBeVisible();
|
||||
await expect(getServerFeatures({ headers })).resolves.toMatchObject({ backups: true });
|
||||
});
|
||||
|
||||
test('the backups link opens the dashboard once backups are configured', async ({ context, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const headers = asBearerAuth(admin.accessToken);
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await sdk.futoBackupsCreateLocalBackend({ path: '/local-backend' }, { headers });
|
||||
await sdk.futoBackupsConfigureImmichIntegration(
|
||||
{
|
||||
name: 'Immich',
|
||||
worm: false,
|
||||
cron: '0 3 * * *',
|
||||
backupConfiguration: true,
|
||||
dataFolders: [StorageFolder.Backups, StorageFolder.Upload],
|
||||
libraries: ['all'],
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
|
||||
const { repositories } = await sdk.futoBackupsGetRepositories({ headers });
|
||||
const backupEnd = waitForJobEnd();
|
||||
await sdk.futoBackupsCreateBackup(repositories[0].id, { headers });
|
||||
await backupEnd;
|
||||
|
||||
await page.goto('/link?target=backups');
|
||||
await page.waitForURL('/admin/backups');
|
||||
|
||||
await expect(page.getByText('Your library backup')).toBeVisible();
|
||||
await expect(page.getByText('Last backup successful')).toBeVisible();
|
||||
});
|
||||
|
||||
test('manually triggers a backup and waits for completion', async ({ context, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto('/admin/backups');
|
||||
await page.getByRole('button', { name: 'Back up now', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByRole('heading', { name: 'Backing up your library' })).toBeVisible();
|
||||
await expect(dialog.getByRole('heading', { name: 'Backup complete' })).toBeVisible({ timeout: 45_000 });
|
||||
});
|
||||
|
||||
test('resets immich and restores from the local FUTO Backups backup', async ({ context, page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
await utils.createBackup(admin.accessToken);
|
||||
|
||||
await sdk.futoBackupsResetOrchestrator({ headers: asBearerAuth(admin.accessToken) });
|
||||
await utils.resetDatabase();
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Restore from backup' }).click();
|
||||
|
||||
try {
|
||||
await page.waitForURL('/maintenance**');
|
||||
} catch {
|
||||
await page.goto('/maintenance');
|
||||
await page.waitForURL('/maintenance**');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'FUTO Backups' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.filter({ hasText: 'Telemetry required for closed beta' })).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(dialog.filter({ hasText: 'Import recovery key' })).toBeVisible();
|
||||
await dialog.getByLabel('Recovery Key').fill('0'.repeat(64));
|
||||
await dialog.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(dialog.filter({ hasText: 'Where would you like to restore from?' })).toBeVisible();
|
||||
await dialog.getByText('Local Storage').click();
|
||||
|
||||
await expect(dialog.filter({ hasText: 'Create local backend' })).toBeVisible();
|
||||
await dialog.getByLabel('Path').fill('/local-backend');
|
||||
await dialog.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(dialog.filter({ hasText: 'Select Backup' })).toBeVisible();
|
||||
await dialog
|
||||
.getByRole('button', { name: /Last backup/ })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(dialog.filter({ hasText: /Restore from/ })).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Restore' }).first().click();
|
||||
|
||||
await expect(dialog.filter({ hasText: 'Confirm restore from snapshot' })).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
|
||||
await expect(dialog.filter({ hasText: 'Restoring' })).toBeVisible();
|
||||
await expect(dialog.filter({ hasText: 'Restoring' })).toBeHidden({ timeout: 60_000 });
|
||||
|
||||
await page.waitForURL('/photos', { timeout: 90_000 });
|
||||
});
|
||||
});
|
||||
@@ -628,25 +628,19 @@ describe('/albums', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not be able to remove foreign asset from foreign album', async () => {
|
||||
it('should be able to remove foreign asset from shared album as an editor', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/albums/${user1Albums[0].id}/assets`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ ids: [user1Asset1.id] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([
|
||||
expect.objectContaining({
|
||||
id: user1Asset1.id,
|
||||
success: false,
|
||||
error: 'no_permission',
|
||||
}),
|
||||
]);
|
||||
expect(body).toEqual([expect.objectContaining({ id: user1Asset1.id, success: true })]);
|
||||
});
|
||||
|
||||
it('should be able to remove own asset from own album', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/albums/${user1Albums[0].id}/assets`)
|
||||
.delete(`/albums/${user1Albums[2].id}/assets`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [user1Asset1.id] });
|
||||
|
||||
|
||||
@@ -355,7 +355,7 @@ describe('/asset', () => {
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ livePhotoVideoId: asset.id });
|
||||
|
||||
expect(body).toEqual(errorDto.badRequest('Live photo video does not belong to the user'));
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no asset.update access'));
|
||||
expect(status).toEqual(400);
|
||||
});
|
||||
|
||||
|
||||
@@ -567,7 +567,7 @@ describe('/search', () => {
|
||||
'New York',
|
||||
'Shanghai',
|
||||
'State of Berlin',
|
||||
'St.-Petersburg',
|
||||
'St. Petersburg',
|
||||
'Tbilisi',
|
||||
'Tokyo',
|
||||
'Virginia',
|
||||
@@ -591,7 +591,7 @@ describe('/search', () => {
|
||||
'New York',
|
||||
'Shanghai',
|
||||
'State of Berlin',
|
||||
'St.-Petersburg',
|
||||
'St. Petersburg',
|
||||
'Tbilisi',
|
||||
'Tokyo',
|
||||
'Virginia',
|
||||
|
||||
@@ -98,6 +98,7 @@ describe('/server', () => {
|
||||
configFile: false,
|
||||
duplicateDetection: false,
|
||||
facialRecognition: false,
|
||||
backups: false,
|
||||
map: true,
|
||||
reverseGeocoding: true,
|
||||
importFaces: false,
|
||||
|
||||
@@ -206,6 +206,13 @@ describe('/shared-links', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not return album users', async () => {
|
||||
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithAlbum.key });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.album.albumUsers).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return unauthorized for incorrect shared link', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/shared-links/me')
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { LoginResponseDto, getMyUser, login } from '@immich/sdk';
|
||||
import { createUserDto } from 'src/fixtures.js';
|
||||
import { app, asBearerAuth, utils } from 'src/utils.js';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/users', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let nonAdmin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup({ onboarding: false });
|
||||
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user2);
|
||||
});
|
||||
|
||||
describe('PUT /users/me', () => {
|
||||
/** @deprecated */
|
||||
it('should allow a user to change their password (deprecated)', async () => {
|
||||
const user = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) });
|
||||
|
||||
expect(user.shouldChangePassword).toBe(true);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/users/me`)
|
||||
.send({ password: 'super-secret' })
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
email: nonAdmin.userEmail,
|
||||
shouldChangePassword: false,
|
||||
});
|
||||
|
||||
const token = await login({ loginCredentialDto: { email: nonAdmin.userEmail, password: 'super-secret' } });
|
||||
|
||||
expect(token.accessToken).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { testAssetDir, utils } from 'src/utils.js';
|
||||
|
||||
test.describe('Album', () => {
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
test(`doesn't delete album after canceling add assets`, async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto('/albums');
|
||||
await page.getByRole('button', { name: 'Create album' }).click();
|
||||
await page.getByRole('button', { name: 'Select photos' }).click();
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: 'Select photos' }).waitFor();
|
||||
});
|
||||
|
||||
test('should keep map view open after viewing an asset from the map and going back', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
const imagePath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
|
||||
const mapAsset = await utils.createAsset(admin.accessToken, {
|
||||
assetData: {
|
||||
bytes: readFileSync(imagePath),
|
||||
filename: 'thompson-springs.jpg',
|
||||
},
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||
|
||||
const mapAlbum = await utils.createAlbum(admin.accessToken, {
|
||||
albumName: 'Map Test Album',
|
||||
assetIds: [mapAsset.id],
|
||||
});
|
||||
|
||||
await page.goto(`/albums/${mapAlbum.id}`);
|
||||
const mapButton = page.getByRole('button', { name: 'Map' });
|
||||
await expect(mapButton).toBeVisible();
|
||||
await mapButton.click();
|
||||
|
||||
const mapModal = page.getByRole('dialog');
|
||||
await expect(mapModal).toBeVisible();
|
||||
|
||||
const mapMarker = mapModal.getByRole('img', { name: /Map marker/i }).first();
|
||||
await expect(mapMarker).toBeVisible();
|
||||
await mapMarker.click();
|
||||
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
await page.getByRole('button', { name: 'Go back' }).click();
|
||||
|
||||
await expect(page.locator('#immich-asset-viewer')).not.toBeVisible();
|
||||
await expect(mapModal).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
|
||||
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 { testAssetDir, utils } from 'src/utils.js';
|
||||
|
||||
test.describe('Detail Panel', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
let websocket: Socket;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
asset = await utils.createAsset(admin.accessToken);
|
||||
websocket = await utils.connectWebsocket(admin.accessToken);
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
utils.disconnectWebsocket(websocket);
|
||||
});
|
||||
|
||||
test('can be opened for shared links', async ({ page }) => {
|
||||
const sharedLink = await utils.createSharedLink(admin.accessToken, {
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [asset.id],
|
||||
});
|
||||
await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`);
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Info' })).toBeVisible();
|
||||
await page.keyboard.press('i');
|
||||
await expect(page.locator('#detail-panel')).toBeVisible();
|
||||
await page.keyboard.press('i');
|
||||
await expect(page.locator('#detail-panel')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('cannot be opened for shared links with hidden metadata', async ({ page }) => {
|
||||
const sharedLink = await utils.createSharedLink(admin.accessToken, {
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [asset.id],
|
||||
showMetadata: false,
|
||||
});
|
||||
await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`);
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Info' })).toHaveCount(0);
|
||||
await page.keyboard.press('i');
|
||||
await expect(page.locator('#detail-panel')).toHaveCount(0);
|
||||
await page.keyboard.press('i');
|
||||
await expect(page.locator('#detail-panel')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('description is visible for owner on shared links', async ({ context, page }) => {
|
||||
const sharedLink = await utils.createSharedLink(admin.accessToken, {
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [asset.id],
|
||||
});
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`);
|
||||
|
||||
const textarea = page.getByRole('textbox', { name: 'Add a description' });
|
||||
await page.getByRole('button', { name: 'Info' }).click();
|
||||
await expect(textarea).toBeVisible();
|
||||
await expect(textarea).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('description changes are visible after reopening', async ({ context, page }) => {
|
||||
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();
|
||||
const textarea = page.getByRole('textbox', { name: 'Add a description' });
|
||||
await textarea.fill('new description');
|
||||
await expect(textarea).toHaveValue('new description');
|
||||
|
||||
await page.getByRole('button', { name: 'Info' }).click();
|
||||
await expect(textarea).not.toBeVisible();
|
||||
await page.getByRole('button', { name: 'Info' }).click();
|
||||
await expect(textarea).toBeVisible();
|
||||
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
|
||||
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,65 +0,0 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils.js';
|
||||
|
||||
test.describe('Asset Viewer Navbar', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
asset = await utils.createAsset(admin.accessToken);
|
||||
});
|
||||
|
||||
test.describe('shared link without metadata', () => {
|
||||
test('visible guest actions', async ({ page }) => {
|
||||
const sharedLink = await utils.createSharedLink(admin.accessToken, {
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [asset.id],
|
||||
showMetadata: false,
|
||||
});
|
||||
await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`);
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
const expected = ['Zoom Image', 'Copy Image', 'Download'];
|
||||
const buttons = await page.getByTestId('asset-viewer-navbar-actions').getByRole('button').all();
|
||||
|
||||
for (const [i, button] of buttons.entries()) {
|
||||
await expect(button).toHaveAccessibleName(expected[i]);
|
||||
}
|
||||
});
|
||||
|
||||
test('visible owner actions', async ({ context, page }) => {
|
||||
const sharedLink = await utils.createSharedLink(admin.accessToken, {
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [asset.id],
|
||||
showMetadata: false,
|
||||
});
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`);
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
const expected = ['Share', 'Zoom Image', 'Copy Image', 'Download'];
|
||||
const buttons = await page.getByTestId('asset-viewer-navbar-actions').getByRole('button').all();
|
||||
|
||||
for (const [i, button] of buttons.entries()) {
|
||||
await expect(button).toHaveAccessibleName(expected[i]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('actions', () => {
|
||||
test('favorite asset with shortcut', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
await page.keyboard.press('f');
|
||||
await expect(page.getByText('Added to favorites')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils.js';
|
||||
|
||||
test.describe('Slideshow', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
asset = await utils.createAsset(admin.accessToken);
|
||||
});
|
||||
|
||||
const openSlideshow = async (page: Page) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
await page.getByRole('button', { name: 'More' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Slideshow' }).click();
|
||||
};
|
||||
|
||||
test('open slideshow', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await openSlideshow(page);
|
||||
await expect(page.getByRole('button', { name: 'Exit Slideshow' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('exit slideshow with button', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await openSlideshow(page);
|
||||
|
||||
const exitButton = page.getByRole('button', { name: 'Exit Slideshow' });
|
||||
await exitButton.click();
|
||||
await expect(exitButton).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('exit slideshow with shortcut', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await openSlideshow(page);
|
||||
|
||||
const exitButton = page.getByRole('button', { name: 'Exit Slideshow' });
|
||||
await expect(exitButton).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(exitButton).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('favorite shortcut is disabled', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await openSlideshow(page);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Exit Slideshow' })).toBeVisible();
|
||||
await page.keyboard.press('f');
|
||||
await expect(page.getByText('Added to favorites')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ test.describe('Registration', () => {
|
||||
await page.goto('/auth/login?autoLaunch=0');
|
||||
await page.getByLabel('Email').fill('admin@immich.app');
|
||||
await page.getByLabel('Password', { exact: true }).fill('password');
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
||||
|
||||
// onboarding
|
||||
await expect(page).toHaveURL('/auth/onboarding');
|
||||
@@ -66,11 +66,12 @@ test.describe('Registration', () => {
|
||||
await page.goto('/auth/login?autoLaunch=0');
|
||||
await page.getByLabel('Email').fill('user@immich.cloud');
|
||||
await page.getByLabel('Password').fill('password');
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
||||
|
||||
// change password
|
||||
await expect(page.getByRole('heading')).toHaveText('Change Password');
|
||||
await expect(page).toHaveURL('/auth/change-password');
|
||||
await page.getByLabel('Password', { exact: true }).fill('password');
|
||||
await page.getByLabel('New Password').fill('new-password');
|
||||
await page.getByLabel('Confirm Password').fill('new-password');
|
||||
await page.getByRole('button', { name: 'Change password' }).click();
|
||||
@@ -79,7 +80,7 @@ test.describe('Registration', () => {
|
||||
await expect(page).toHaveURL('/auth/login?autoLaunch=0');
|
||||
await page.getByLabel('Email').fill('user@immich.cloud');
|
||||
await page.getByLabel('Password').fill('new-password');
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
||||
|
||||
// onboarding
|
||||
await expect(page).toHaveURL('/auth/onboarding');
|
||||
|
||||
@@ -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.js';
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { LoginResponseDto, ManualJobName, QueueName } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils.js';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe.skip('Integrity', () => {
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
test('run integrity jobs to update stats', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityUntrackedFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
await page.goto('/admin/maintenance');
|
||||
|
||||
const count = page.getByText('Untracked Files').locator('..').locator('..').locator('div').nth(1);
|
||||
|
||||
const previousCount = Number.parseInt((await count.textContent()) ?? '');
|
||||
|
||||
await utils.mkFolder(`/data/upload/${admin.userId}`);
|
||||
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`);
|
||||
|
||||
const checkButton = page.getByText('Integrity Report').locator('..').getByRole('button', { name: 'Check All' });
|
||||
|
||||
await checkButton.click();
|
||||
await expect(checkButton).toBeEnabled();
|
||||
|
||||
await expect(count).toContainText((previousCount + 1).toString());
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import { utils } from 'src/utils.js';
|
||||
|
||||
test.describe('Photo Viewer', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
let rawAsset: AssetMediaResponseDto;
|
||||
let websocket: Socket;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
asset = await utils.createAsset(admin.accessToken);
|
||||
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
|
||||
websocket = await utils.connectWebsocket(admin.accessToken);
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
utils.disconnectWebsocket(websocket);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
// before each test, login as user
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('loads original photo when zoomed', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
|
||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||
await expect(preview).toHaveAttribute('src', /.+/);
|
||||
|
||||
const originalResponse = page.waitForResponse((response) => response.url().includes('/original'));
|
||||
|
||||
const { width, height } = page.viewportSize()!;
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
|
||||
await originalResponse;
|
||||
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
await expect(original).toHaveAttribute('src', /original/);
|
||||
});
|
||||
|
||||
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
||||
await page.goto(`/photos/${rawAsset.id}`);
|
||||
|
||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||
await expect(preview).toHaveAttribute('src', /.+/);
|
||||
|
||||
const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize'));
|
||||
|
||||
const { width, height } = page.viewportSize()!;
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
|
||||
await fullsizeResponse;
|
||||
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
await expect(original).toHaveAttribute('src', /fullsize/);
|
||||
});
|
||||
|
||||
test('right-click targets the img element', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
|
||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||
await expect(preview).toHaveAttribute('src', /.+/);
|
||||
|
||||
const box = await preview.boundingBox();
|
||||
const tagAtCenter = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.tagName, {
|
||||
x: box!.x + box!.width / 2,
|
||||
y: box!.y + box!.height / 2,
|
||||
});
|
||||
expect(tagAtCenter).toBe('IMG');
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
import {
|
||||
AlbumResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
LoginResponseDto,
|
||||
SharedLinkResponseDto,
|
||||
SharedLinkType,
|
||||
createAlbum,
|
||||
} from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { asBearerAuth, utils } from 'src/utils.js';
|
||||
|
||||
test.describe('Shared Links', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
let asset2: AssetMediaResponseDto;
|
||||
let album: AlbumResponseDto;
|
||||
let sharedLink: SharedLinkResponseDto;
|
||||
let sharedLinkPassword: SharedLinkResponseDto;
|
||||
let individualSharedLink: SharedLinkResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
asset = await utils.createAsset(admin.accessToken);
|
||||
asset2 = await utils.createAsset(admin.accessToken);
|
||||
album = await createAlbum(
|
||||
{
|
||||
createAlbumDto: {
|
||||
albumName: 'Test Album',
|
||||
assetIds: [asset.id],
|
||||
},
|
||||
},
|
||||
{ headers: asBearerAuth(admin.accessToken) },
|
||||
);
|
||||
sharedLink = await utils.createSharedLink(admin.accessToken, {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
});
|
||||
sharedLinkPassword = await utils.createSharedLink(admin.accessToken, {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
password: 'test-password',
|
||||
});
|
||||
individualSharedLink = await utils.createSharedLink(admin.accessToken, {
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [asset.id, asset2.id],
|
||||
});
|
||||
});
|
||||
|
||||
test('download from a shared link', async ({ page }) => {
|
||||
await page.goto(`/share/${sharedLink.key}`);
|
||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||
await page.locator(`[data-asset-id="${asset.id}"]`).hover();
|
||||
await page.waitForSelector(`[data-asset-id="${asset.id}"] [role="checkbox"]`);
|
||||
await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]);
|
||||
});
|
||||
|
||||
test('download all from shared link', async ({ page }) => {
|
||||
await page.goto(`/share/${sharedLink.key}`);
|
||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||
await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]);
|
||||
});
|
||||
|
||||
test('enter password for a shared link', async ({ page }) => {
|
||||
await page.goto(`/share/${sharedLinkPassword.key}`);
|
||||
await page.getByPlaceholder('Password').fill('test-password');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||
});
|
||||
|
||||
test('show-password button visible', async ({ page }) => {
|
||||
await page.goto(`/share/${sharedLinkPassword.key}`);
|
||||
await page.getByPlaceholder('Password').fill('test-password');
|
||||
await page.getByRole('button', { name: 'Show password' }).waitFor();
|
||||
});
|
||||
|
||||
test('view password for shared link', async ({ page }) => {
|
||||
await page.goto(`/share/${sharedLinkPassword.key}`);
|
||||
const input = page.getByPlaceholder('Password');
|
||||
await input.fill('test-password');
|
||||
await page.getByRole('button', { name: 'Show password' }).click();
|
||||
// await page.getByText('test-password').waitFor();
|
||||
await expect(input).toHaveAttribute('type', 'text');
|
||||
});
|
||||
|
||||
test('hide-password button visible', async ({ page }) => {
|
||||
await page.goto(`/share/${sharedLinkPassword.key}`);
|
||||
const input = page.getByPlaceholder('Password');
|
||||
await input.fill('test-password');
|
||||
await page.getByRole('button', { name: 'Show password' }).click();
|
||||
await page.getByRole('button', { name: 'Hide password' }).waitFor();
|
||||
});
|
||||
|
||||
test('hide password for shared link', async ({ page }) => {
|
||||
await page.goto(`/share/${sharedLinkPassword.key}`);
|
||||
const input = page.getByPlaceholder('Password');
|
||||
await input.fill('test-password');
|
||||
await page.getByRole('button', { name: 'Show password' }).click();
|
||||
await page.getByRole('button', { name: 'Hide password' }).click();
|
||||
await expect(input).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
test('show error for invalid shared link', async ({ page }) => {
|
||||
await page.goto('/share/invalid');
|
||||
await page.getByRole('heading', { name: 'Invalid share key' }).waitFor();
|
||||
});
|
||||
|
||||
test('auth on navigation from shared link to timeline', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto(`/share/${sharedLink.key}`);
|
||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||
|
||||
await page.locator('a[href="/"]').click();
|
||||
await page.waitForURL('/photos');
|
||||
await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
|
||||
});
|
||||
|
||||
test('owner can remove assets from an individual shared link', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto(`/share/${individualSharedLink.key}`);
|
||||
await page.locator(`[data-asset="${asset.id}"]`).waitFor();
|
||||
await expect(page.locator(`[data-asset]`)).toHaveCount(2);
|
||||
|
||||
await page.locator(`[data-asset="${asset.id}"]`).hover();
|
||||
await page.locator(`[data-asset="${asset.id}"] [role="checkbox"]`).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Remove from shared link' }).click();
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click();
|
||||
|
||||
await expect(page.locator(`[data-asset="${asset.id}"]`)).toHaveCount(0);
|
||||
await expect(page.locator(`[data-asset="${asset2.id}"]`)).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import { getUserAdmin } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { asBearerAuth, utils } from 'src/utils.js';
|
||||
|
||||
test.describe('User Administration', () => {
|
||||
test.beforeAll(() => {
|
||||
utils.initSdk();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await utils.resetDatabase();
|
||||
});
|
||||
|
||||
test('validate admin/users link', async ({ context, page }) => {
|
||||
const admin = await utils.adminSetup();
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
// Navigate to user management page and verify title and header
|
||||
await page.goto(`/admin/users`);
|
||||
await expect(page).toHaveTitle(/User Management/);
|
||||
await expect(page.getByText('User Management')).toBeVisible();
|
||||
});
|
||||
|
||||
test('create user', async ({ context, page }) => {
|
||||
const admin = await utils.adminSetup();
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
// Create a new user
|
||||
await page.goto('/admin/users');
|
||||
await page.getByRole('button', { name: 'Create user' }).click();
|
||||
await page.getByLabel('Email').fill('user@immich.cloud');
|
||||
await page.getByLabel('Password', { exact: true }).fill('password');
|
||||
await page.getByLabel('Confirm Password').fill('password');
|
||||
await page.getByLabel('Name').fill('Immich User');
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
|
||||
// Verify the user exists in the user list
|
||||
await page.getByRole('row', { name: 'user@immich.cloud' });
|
||||
});
|
||||
|
||||
test('promote to admin', async ({ context, page }) => {
|
||||
const admin = await utils.adminSetup();
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
const user = await utils.userSetup(admin.accessToken, {
|
||||
name: 'Admin 2',
|
||||
email: 'admin2@immich.cloud',
|
||||
password: 'password',
|
||||
});
|
||||
|
||||
expect(user.isAdmin).toBe(false);
|
||||
|
||||
await page.goto(`/admin/users/${user.userId}`);
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||
await page.getByLabel('Admin User').click();
|
||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||
return userAdmin.isAdmin;
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('revoke admin access', async ({ context, page }) => {
|
||||
const admin = await utils.adminSetup();
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
const user = await utils.userSetup(admin.accessToken, {
|
||||
name: 'Admin 2',
|
||||
email: 'admin2@immich.cloud',
|
||||
password: 'password',
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
expect(user.isAdmin).toBe(true);
|
||||
|
||||
await page.goto(`/admin/users/${user.userId}`);
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||
await page.getByLabel('Admin User').click();
|
||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||
return userAdmin.isAdmin;
|
||||
})
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
export { generateMemoriesFromTimeline, generateMemory } from './memory/model-objects';
|
||||
export type { MemoryConfig, MemoryYearConfig } from './memory/model-objects';
|
||||
@@ -1,86 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { MemoryType, type MemoryResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { toAssetResponseDto } from 'src/ui/generators/timeline/rest-response.js';
|
||||
import type { MockTimelineAsset } from 'src/ui/generators/timeline/timeline-config.js';
|
||||
import { SeededRandom, selectRandomMultiple } from 'src/ui/generators/timeline/utils.js';
|
||||
|
||||
export type MemoryConfig = {
|
||||
id?: string;
|
||||
ownerId: string;
|
||||
year: number;
|
||||
memoryAt: string;
|
||||
isSaved?: boolean;
|
||||
};
|
||||
|
||||
export type MemoryYearConfig = {
|
||||
year: number;
|
||||
assetCount: number;
|
||||
};
|
||||
|
||||
export function generateMemory(config: MemoryConfig, assets: MockTimelineAsset[]): MemoryResponseDto {
|
||||
const now = new Date().toISOString();
|
||||
const memoryId = config.id ?? faker.string.uuid();
|
||||
|
||||
return {
|
||||
id: memoryId,
|
||||
assets: assets.map((asset) => toAssetResponseDto(asset)),
|
||||
data: { year: config.year },
|
||||
memoryAt: config.memoryAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isSaved: config.isSaved ?? false,
|
||||
ownerId: config.ownerId,
|
||||
type: MemoryType.OnThisDay,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateMemoriesFromTimeline(
|
||||
timelineAssets: MockTimelineAsset[],
|
||||
ownerId: string,
|
||||
memoryConfigs: MemoryYearConfig[],
|
||||
seed: number = 42,
|
||||
): MemoryResponseDto[] {
|
||||
const rng = new SeededRandom(seed);
|
||||
const memories: MemoryResponseDto[] = [];
|
||||
const usedAssetIds = new Set<string>();
|
||||
|
||||
for (const config of memoryConfigs) {
|
||||
const yearAssets = timelineAssets.filter((asset) => {
|
||||
const assetYear = DateTime.fromISO(asset.fileCreatedAt).year;
|
||||
return assetYear === config.year && !usedAssetIds.has(asset.id);
|
||||
});
|
||||
|
||||
if (yearAssets.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const countToSelect = Math.min(config.assetCount, yearAssets.length);
|
||||
const selectedAssets = selectRandomMultiple(yearAssets, countToSelect, rng);
|
||||
|
||||
for (const asset of selectedAssets) {
|
||||
usedAssetIds.add(asset.id);
|
||||
}
|
||||
|
||||
selectedAssets.sort(
|
||||
(a, b) => DateTime.fromISO(b.fileCreatedAt).diff(DateTime.fromISO(a.fileCreatedAt)).milliseconds,
|
||||
);
|
||||
|
||||
const memoryAt = DateTime.now().set({ year: config.year }).toISO()!;
|
||||
|
||||
memories.push(
|
||||
generateMemory(
|
||||
{
|
||||
ownerId,
|
||||
year: config.year,
|
||||
memoryAt,
|
||||
},
|
||||
selectedAssets,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
memories.sort((a, b) => new Date(b.memoryAt).getMilliseconds() - new Date(a.memoryAt).getMilliseconds());
|
||||
|
||||
return memories;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
export { generateTimelineData } from './timeline/model-objects';
|
||||
|
||||
export { createDefaultTimelineConfig, validateTimelineConfig } from './timeline/timeline-config';
|
||||
|
||||
export type {
|
||||
MockAlbum,
|
||||
MonthSpec,
|
||||
SerializedTimelineData,
|
||||
MockTimelineAsset as TimelineAssetConfig,
|
||||
TimelineConfig,
|
||||
MockTimelineData as TimelineData,
|
||||
} from './timeline/timeline-config';
|
||||
|
||||
export {
|
||||
getAlbum,
|
||||
getAsset,
|
||||
getTimeBucket,
|
||||
getTimeBuckets,
|
||||
toAssetResponseDto,
|
||||
toColumnarFormat,
|
||||
} from './timeline/rest-response';
|
||||
|
||||
export type { Changes } from './timeline/rest-response';
|
||||
|
||||
export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images';
|
||||
|
||||
export {
|
||||
SeededRandom,
|
||||
getMockAsset,
|
||||
parseTimeBucketKey,
|
||||
selectRandom,
|
||||
selectRandomDays,
|
||||
selectRandomMultiple,
|
||||
} from './timeline/utils';
|
||||
|
||||
export { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './timeline/distribution-patterns';
|
||||
export type { DayPattern, MonthDistribution } from './timeline/distribution-patterns';
|
||||
@@ -1,183 +0,0 @@
|
||||
import { generateConsecutiveDays, generateDayAssets } from 'src/ui/generators/timeline/model-objects.js';
|
||||
import { SeededRandom, selectRandomDays } from 'src/ui/generators/timeline/utils.js';
|
||||
import type { MockTimelineAsset } from './timeline-config';
|
||||
import { GENERATION_CONSTANTS } from './timeline-config';
|
||||
|
||||
type AssetDistributionStrategy = (rng: SeededRandom) => number;
|
||||
|
||||
type DayDistributionStrategy = (
|
||||
year: number,
|
||||
month: number,
|
||||
daysInMonth: number,
|
||||
totalAssets: number,
|
||||
ownerId: string,
|
||||
rng: SeededRandom,
|
||||
) => MockTimelineAsset[];
|
||||
|
||||
/**
|
||||
* Strategies for determining total asset count per month
|
||||
*/
|
||||
export const ASSET_DISTRIBUTION: Record<MonthDistribution, AssetDistributionStrategy | null> = {
|
||||
empty: null, // Special case - handled separately
|
||||
sparse: (rng) => rng.nextInt(3, 9), // 3-8 assets
|
||||
medium: (rng) => rng.nextInt(15, 31), // 15-30 assets
|
||||
dense: (rng) => rng.nextInt(50, 81), // 50-80 assets
|
||||
'very-dense': (rng) => rng.nextInt(80, 151), // 80-150 assets
|
||||
};
|
||||
|
||||
/**
|
||||
* Strategies for distributing assets across days within a month
|
||||
*/
|
||||
export const DAY_DISTRIBUTION: Record<DayPattern, DayDistributionStrategy> = {
|
||||
'single-day': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// All assets on one day in the middle of the month
|
||||
const day = Math.floor(daysInMonth / 2);
|
||||
return generateDayAssets(year, month, day, totalAssets, ownerId, rng);
|
||||
},
|
||||
|
||||
'consecutive-large': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// 3-5 consecutive days with evenly distributed assets
|
||||
const numDays = Math.min(5, Math.floor(totalAssets / 15));
|
||||
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
|
||||
return generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
|
||||
},
|
||||
|
||||
'consecutive-small': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Multiple consecutive days with 1-3 assets each (side-by-side layout)
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const numDays = Math.min(totalAssets, Math.floor(daysInMonth / 2));
|
||||
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
|
||||
let assetIndex = 0;
|
||||
|
||||
for (let i = 0; i < numDays && assetIndex < totalAssets; i++) {
|
||||
const dayAssets = Math.min(3, rng.nextInt(1, 4));
|
||||
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
|
||||
// Create a new RNG for this day
|
||||
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, startDay + i, actualAssets, ownerId, dayRng));
|
||||
assetIndex += actualAssets;
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
|
||||
alternating: (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Alternate between large (15-25) and small (1-3) days
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
let day = 1;
|
||||
let isLarge = true;
|
||||
let assetIndex = 0;
|
||||
|
||||
while (assetIndex < totalAssets && day <= daysInMonth) {
|
||||
const dayAssets = isLarge ? Math.min(25, rng.nextInt(15, 26)) : rng.nextInt(1, 4);
|
||||
|
||||
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
|
||||
// Create a new RNG for this day
|
||||
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, day, actualAssets, ownerId, dayRng));
|
||||
assetIndex += actualAssets;
|
||||
|
||||
day += isLarge ? 1 : 1; // Could add gaps here
|
||||
isLarge = !isLarge;
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
|
||||
'sparse-scattered': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Spread assets across random days with gaps
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const numDays = Math.min(totalAssets, Math.floor(daysInMonth * GENERATION_CONSTANTS.SPARSE_DAY_COVERAGE));
|
||||
const daysWithPhotos = selectRandomDays(daysInMonth, numDays, rng);
|
||||
let assetIndex = 0;
|
||||
|
||||
for (let i = 0; i < daysWithPhotos.length && assetIndex < totalAssets; i++) {
|
||||
const dayAssets =
|
||||
Math.floor(totalAssets / numDays) + (i === daysWithPhotos.length - 1 ? totalAssets % numDays : 0);
|
||||
// Create a new RNG for this day
|
||||
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, daysWithPhotos[i], dayAssets, ownerId, dayRng));
|
||||
assetIndex += dayAssets;
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
|
||||
'start-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Most assets in first week
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const firstWeekAssets = Math.floor(totalAssets * 0.7);
|
||||
const remainingAssets = totalAssets - firstWeekAssets;
|
||||
|
||||
// First 7 days
|
||||
assets.push(...generateConsecutiveDays(year, month, 1, 7, firstWeekAssets, ownerId, rng));
|
||||
|
||||
// Remaining scattered
|
||||
if (remainingAssets > 0) {
|
||||
const midDay = Math.floor(daysInMonth / 2);
|
||||
// Create a new RNG for the remaining assets
|
||||
const remainingRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, midDay, remainingAssets, ownerId, remainingRng));
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
|
||||
'end-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Most assets in last week
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const lastWeekAssets = Math.floor(totalAssets * 0.7);
|
||||
const remainingAssets = totalAssets - lastWeekAssets;
|
||||
|
||||
// Remaining at start
|
||||
if (remainingAssets > 0) {
|
||||
// Create a new RNG for the start assets
|
||||
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, 2, remainingAssets, ownerId, startRng));
|
||||
}
|
||||
|
||||
// Last 7 days
|
||||
const startDay = daysInMonth - 6;
|
||||
assets.push(...generateConsecutiveDays(year, month, startDay, 7, lastWeekAssets, ownerId, rng));
|
||||
return assets;
|
||||
},
|
||||
|
||||
'mid-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Most assets in middle of month
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const midAssets = Math.floor(totalAssets * 0.7);
|
||||
const sideAssets = Math.floor((totalAssets - midAssets) / 2);
|
||||
|
||||
// Start
|
||||
if (sideAssets > 0) {
|
||||
// Create a new RNG for the start assets
|
||||
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, 2, sideAssets, ownerId, startRng));
|
||||
}
|
||||
|
||||
// Middle
|
||||
const midStart = Math.floor(daysInMonth / 2) - 3;
|
||||
assets.push(...generateConsecutiveDays(year, month, midStart, 7, midAssets, ownerId, rng));
|
||||
|
||||
// End
|
||||
const endAssets = totalAssets - midAssets - sideAssets;
|
||||
if (endAssets > 0) {
|
||||
// Create a new RNG for the end assets
|
||||
const endRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, daysInMonth - 1, endAssets, ownerId, endRng));
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
};
|
||||
export type MonthDistribution =
|
||||
| 'empty' // 0 assets
|
||||
| 'sparse' // 3-8 assets
|
||||
| 'medium' // 15-30 assets
|
||||
| 'dense' // 50-80 assets
|
||||
| 'very-dense'; // 80-150 assets
|
||||
|
||||
export type DayPattern =
|
||||
| 'single-day' // All images in one day
|
||||
| 'consecutive-large' // Multiple days with 15-25 images each
|
||||
| 'consecutive-small' // Multiple days with 1-3 images each (side-by-side)
|
||||
| 'alternating' // Alternating large/small days
|
||||
| 'sparse-scattered' // Few images scattered across month
|
||||
| 'start-heavy' // Most images at start of month
|
||||
| 'end-heavy' // Most images at end of month
|
||||
| 'mid-heavy'; // Most images in middle of month
|
||||
@@ -1,111 +0,0 @@
|
||||
import sharp from 'sharp';
|
||||
import { SeededRandom } from 'src/ui/generators/timeline/utils.js';
|
||||
|
||||
export const randomThumbnail = async (seed: string, ratio: number) => {
|
||||
const height = 235;
|
||||
const width = Math.round(height * ratio);
|
||||
return randomImageFromString(seed, { width, height });
|
||||
};
|
||||
|
||||
export const randomPreview = async (seed: string, ratio: number) => {
|
||||
const height = 500;
|
||||
const width = Math.round(height * ratio);
|
||||
return randomImageFromString(seed, { width, height });
|
||||
};
|
||||
|
||||
export const randomImageFromString = async (
|
||||
seed: string = '',
|
||||
{ width = 100, height = 100 }: { width: number; height: number },
|
||||
) => {
|
||||
// Convert string to number for seeding
|
||||
let seedNumber = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
seedNumber = (seedNumber << 5) - seedNumber + (seed.codePointAt(i) ?? 0);
|
||||
seedNumber &= seedNumber; // Convert to 32bit integer
|
||||
}
|
||||
return randomImage(new SeededRandom(Math.abs(seedNumber)), { width, height });
|
||||
};
|
||||
|
||||
export const randomImage = async (rng: SeededRandom, { width, height }: { width: number; height: number }) => {
|
||||
const r1 = rng.nextInt(0, 256);
|
||||
const g1 = rng.nextInt(0, 256);
|
||||
const b1 = rng.nextInt(0, 256);
|
||||
const r2 = rng.nextInt(0, 256);
|
||||
const g2 = rng.nextInt(0, 256);
|
||||
const b2 = rng.nextInt(0, 256);
|
||||
const patternType = rng.nextInt(0, 5);
|
||||
|
||||
let svgPattern = '';
|
||||
|
||||
switch (patternType) {
|
||||
case 0: {
|
||||
// Solid color
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
<rect x="0" y="0" width="${width}" height="${height}" fill="rgb(${r1},${g1},${b1})"/>
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 1: {
|
||||
// Horizontal stripes
|
||||
const stripeHeight = 10;
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
${Array.from(
|
||||
{ length: height / stripeHeight },
|
||||
(_, i) =>
|
||||
`<rect x="0" y="${i * stripeHeight}" width="${width}" height="${stripeHeight}"
|
||||
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
|
||||
).join('')}
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 2: {
|
||||
// Vertical stripes
|
||||
const stripeWidth = 10;
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
${Array.from(
|
||||
{ length: width / stripeWidth },
|
||||
(_, i) =>
|
||||
`<rect x="${i * stripeWidth}" y="0" width="${stripeWidth}" height="${height}"
|
||||
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
|
||||
).join('')}
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 3: {
|
||||
// Checkerboard
|
||||
const squareSize = 10;
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
${Array.from({ length: height / squareSize }, (_, row) =>
|
||||
Array.from({ length: width / squareSize }, (_, col) => {
|
||||
const isEven = (row + col) % 2 === 0;
|
||||
return `<rect x="${col * squareSize}" y="${row * squareSize}"
|
||||
width="${squareSize}" height="${squareSize}"
|
||||
fill="rgb(${isEven ? r1 : r2},${isEven ? g1 : g2},${isEven ? b1 : b2})"/>`;
|
||||
}).join(''),
|
||||
).join('')}
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 4: {
|
||||
// Diagonal stripes
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
<defs>
|
||||
<pattern id="diagonal" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<rect x="0" y="0" width="10" height="20" fill="rgb(${r1},${g1},${b1})"/>
|
||||
<rect x="10" y="0" width="10" height="20" fill="rgb(${r2},${g2},${b2})"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="${width}" height="${height}" fill="url(#diagonal)" transform="rotate(45 50 50)"/>
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const svgBuffer = Buffer.from(svgPattern);
|
||||
const jpegData = await sharp(svgBuffer).jpeg({ quality: 50 }).toBuffer();
|
||||
return jpegData;
|
||||
};
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* Generator functions for timeline model objects
|
||||
*/
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { SeededRandom } from 'src/ui/generators/timeline/utils.js';
|
||||
import type { DayPattern, MonthDistribution } from './distribution-patterns';
|
||||
import { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './distribution-patterns';
|
||||
import type { MockTimelineAsset, MockTimelineData, SerializedTimelineData, TimelineConfig } from './timeline-config';
|
||||
import { ASPECT_RATIO_WEIGHTS, GENERATION_CONSTANTS, validateTimelineConfig } from './timeline-config';
|
||||
|
||||
/**
|
||||
* Generate a random aspect ratio based on weighted probabilities
|
||||
*/
|
||||
export function generateAspectRatio(rng: SeededRandom): string {
|
||||
const random = rng.next();
|
||||
let cumulative = 0;
|
||||
|
||||
for (const [ratio, weight] of Object.entries(ASPECT_RATIO_WEIGHTS)) {
|
||||
cumulative += weight;
|
||||
if (random < cumulative) {
|
||||
return ratio;
|
||||
}
|
||||
}
|
||||
return '16:9'; // Default fallback
|
||||
}
|
||||
|
||||
export function generateThumbhash(rng: SeededRandom): string {
|
||||
return Array.from({ length: 10 }, () => rng.nextInt(0, 256).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export function generateDuration(rng: SeededRandom): number {
|
||||
return (
|
||||
rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS) *
|
||||
1000 +
|
||||
rng.nextInt(0, 1000)
|
||||
);
|
||||
}
|
||||
|
||||
export function generateUUID(): string {
|
||||
return faker.string.uuid();
|
||||
}
|
||||
|
||||
export function generateAsset(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
ownerId: string,
|
||||
rng: SeededRandom,
|
||||
): MockTimelineAsset {
|
||||
const from = DateTime.fromObject({ year, month, day }).setZone('UTC');
|
||||
const to = from.endOf('day');
|
||||
const date = faker.date.between({ from: from.toJSDate(), to: to.toJSDate() });
|
||||
const isVideo = rng.next() < GENERATION_CONSTANTS.VIDEO_PROBABILITY;
|
||||
|
||||
const assetId = generateUUID();
|
||||
const hasGPS = rng.next() < GENERATION_CONSTANTS.GPS_PERCENTAGE;
|
||||
|
||||
const ratio = generateAspectRatio(rng);
|
||||
|
||||
const asset: MockTimelineAsset = {
|
||||
id: assetId,
|
||||
ownerId,
|
||||
ratio: Number(ratio.split(':', 1)[0]) / Number(ratio.split(':', 2)[1]),
|
||||
thumbhash: generateThumbhash(rng),
|
||||
localDateTime: date.toISOString(),
|
||||
fileCreatedAt: date.toISOString(),
|
||||
isFavorite: rng.next() < GENERATION_CONSTANTS.FAVORITE_PROBABILITY,
|
||||
isTrashed: false,
|
||||
isVideo,
|
||||
isImage: !isVideo,
|
||||
duration: isVideo ? generateDuration(rng) : null,
|
||||
projectionType: null,
|
||||
livePhotoVideoId: null,
|
||||
city: hasGPS ? faker.location.city() : null,
|
||||
country: hasGPS ? faker.location.country() : null,
|
||||
people: null,
|
||||
latitude: hasGPS ? faker.location.latitude() : null,
|
||||
longitude: hasGPS ? faker.location.longitude() : null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
stack: null,
|
||||
fileSizeInByte: faker.number.int({ min: 510, max: 5_000_000 }),
|
||||
checksum: faker.string.alphanumeric({ length: 5 }),
|
||||
};
|
||||
|
||||
return asset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate assets for a specific day
|
||||
*/
|
||||
export function generateDayAssets(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
assetCount: number,
|
||||
ownerId: string,
|
||||
rng: SeededRandom,
|
||||
): MockTimelineAsset[] {
|
||||
return Array.from({ length: assetCount }, () => generateAsset(year, month, day, ownerId, rng));
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute assets evenly across consecutive days
|
||||
*
|
||||
* @returns Array of generated timeline assets
|
||||
*/
|
||||
export function generateConsecutiveDays(
|
||||
year: number,
|
||||
month: number,
|
||||
startDay: number,
|
||||
numDays: number,
|
||||
totalAssets: number,
|
||||
ownerId: string,
|
||||
rng: SeededRandom,
|
||||
): MockTimelineAsset[] {
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const assetsPerDay = Math.floor(totalAssets / numDays);
|
||||
|
||||
for (let i = 0; i < numDays; i++) {
|
||||
const dayAssets =
|
||||
i === numDays - 1
|
||||
? totalAssets - assetsPerDay * (numDays - 1) // Remainder on last day
|
||||
: assetsPerDay;
|
||||
// Create a new RNG with a different seed for each day
|
||||
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000) + i * 100);
|
||||
assets.push(...generateDayAssets(year, month, startDay + i, dayAssets, ownerId, dayRng));
|
||||
}
|
||||
|
||||
return assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate assets for a month with specified distribution pattern
|
||||
*/
|
||||
export function generateMonthAssets(
|
||||
year: number,
|
||||
month: number,
|
||||
ownerId: string,
|
||||
distribution: MonthDistribution = 'medium',
|
||||
pattern: DayPattern = 'consecutive-large',
|
||||
rng: SeededRandom,
|
||||
): MockTimelineAsset[] {
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
|
||||
if (distribution === 'empty') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const distributionStrategy = ASSET_DISTRIBUTION[distribution];
|
||||
if (!distributionStrategy) {
|
||||
console.warn(`Unknown distribution: ${distribution}, defaulting to medium`);
|
||||
return [];
|
||||
}
|
||||
const totalAssets = distributionStrategy(rng);
|
||||
|
||||
const dayStrategy = DAY_DISTRIBUTION[pattern];
|
||||
if (!dayStrategy) {
|
||||
console.warn(`Unknown pattern: ${pattern}, defaulting to consecutive-large`);
|
||||
// Fallback to consecutive-large pattern
|
||||
const numDays = Math.min(5, Math.floor(totalAssets / 15));
|
||||
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
|
||||
const assets = generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
|
||||
assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
|
||||
return assets;
|
||||
}
|
||||
|
||||
const assets = dayStrategy(year, month, daysInMonth, totalAssets, ownerId, rng);
|
||||
assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
|
||||
return assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main generator function for timeline data
|
||||
*/
|
||||
export function generateTimelineData(config: TimelineConfig): MockTimelineData {
|
||||
validateTimelineConfig(config);
|
||||
|
||||
const buckets = new Map<string, MockTimelineAsset[]>();
|
||||
const monthStats: Record<string, { count: number; distribution: MonthDistribution; pattern: DayPattern }> = {};
|
||||
|
||||
const globalRng = new SeededRandom(config.seed || GENERATION_CONSTANTS.DEFAULT_SEED);
|
||||
faker.seed(globalRng.nextInt(0, 1_000_000));
|
||||
for (const monthConfig of config.months) {
|
||||
const { year, month, distribution, pattern } = monthConfig;
|
||||
|
||||
const monthSeed = globalRng.nextInt(0, 1_000_000);
|
||||
const monthRng = new SeededRandom(monthSeed);
|
||||
|
||||
const monthAssets = generateMonthAssets(
|
||||
year,
|
||||
month,
|
||||
config.ownerId || generateUUID(),
|
||||
distribution,
|
||||
pattern,
|
||||
monthRng,
|
||||
);
|
||||
|
||||
if (monthAssets.length > 0) {
|
||||
const monthKey = `${year}-${month.toString().padStart(2, '0')}`;
|
||||
monthStats[monthKey] = {
|
||||
count: monthAssets.length,
|
||||
distribution,
|
||||
pattern,
|
||||
};
|
||||
|
||||
// Create bucket key (YYYY-MM-01)
|
||||
const bucketKey = `${year}-${month.toString().padStart(2, '0')}-01`;
|
||||
buckets.set(bucketKey, monthAssets);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a mock album from random assets
|
||||
const allAssets = buckets.values().toArray().flat();
|
||||
|
||||
// Select 10-30 random assets for the album (or all assets if less than 10)
|
||||
const albumSize = Math.min(allAssets.length, globalRng.nextInt(10, 31));
|
||||
const selectedAssetConfigs: MockTimelineAsset[] = [];
|
||||
const usedIndices = new Set<number>();
|
||||
|
||||
while (selectedAssetConfigs.length < albumSize && usedIndices.size < allAssets.length) {
|
||||
const randomIndex = globalRng.nextInt(0, allAssets.length);
|
||||
if (!usedIndices.has(randomIndex)) {
|
||||
usedIndices.add(randomIndex);
|
||||
selectedAssetConfigs.push(allAssets[randomIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort selected assets by date (newest first)
|
||||
selectedAssetConfigs.sort(
|
||||
(a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds,
|
||||
);
|
||||
|
||||
const selectedAssets = selectedAssetConfigs.map((asset) => asset.id);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const album = {
|
||||
id: generateUUID(),
|
||||
albumName: 'Test Album',
|
||||
description: 'A mock album for testing',
|
||||
assetIds: selectedAssets,
|
||||
thumbnailAssetId: selectedAssets.length > 0 ? selectedAssets[0] : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
// Write to file if configured
|
||||
if (config.writeToFile) {
|
||||
const outputPath = config.outputPath || '/tmp/timeline-data.json';
|
||||
|
||||
// Convert Map to object for serialization
|
||||
const serializedData: SerializedTimelineData = {
|
||||
buckets: Object.fromEntries(buckets),
|
||||
album,
|
||||
};
|
||||
|
||||
try {
|
||||
writeFileSync(outputPath, JSON.stringify(serializedData, null, 2));
|
||||
console.log(`Timeline data written to ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to write timeline data to ${outputPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return { buckets, album };
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
/**
|
||||
* REST API output functions for converting timeline data to API response formats
|
||||
*/
|
||||
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetTypeEnum,
|
||||
AssetVisibility,
|
||||
UserAvatarColor,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
type ExifResponseDto,
|
||||
type TimeBucketAssetResponseDto,
|
||||
type TimeBucketsResponseDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { signupDto } from 'src/fixtures.js';
|
||||
import { parseTimeBucketKey } from 'src/ui/generators/timeline/utils.js';
|
||||
import type { MockTimelineAsset, MockTimelineData } from './timeline-config';
|
||||
|
||||
/**
|
||||
* Convert timeline/asset models to columnar format (parallel arrays)
|
||||
*/
|
||||
export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetResponseDto {
|
||||
const result: TimeBucketAssetResponseDto = {
|
||||
id: [],
|
||||
ownerId: [],
|
||||
ratio: [],
|
||||
thumbhash: [],
|
||||
createdAt: [],
|
||||
fileCreatedAt: [],
|
||||
localOffsetHours: [],
|
||||
isFavorite: [],
|
||||
isTrashed: [],
|
||||
isImage: [],
|
||||
duration: [],
|
||||
projectionType: [],
|
||||
livePhotoVideoId: [],
|
||||
city: [],
|
||||
country: [],
|
||||
visibility: [],
|
||||
};
|
||||
|
||||
for (const asset of assets) {
|
||||
result.id.push(asset.id);
|
||||
result.ownerId.push(asset.ownerId);
|
||||
result.ratio.push(asset.ratio);
|
||||
result.thumbhash.push(asset.thumbhash);
|
||||
result.fileCreatedAt.push(asset.fileCreatedAt);
|
||||
result.localOffsetHours.push(0); // Assuming UTC for mocks
|
||||
result.isFavorite.push(asset.isFavorite);
|
||||
result.isTrashed.push(asset.isTrashed);
|
||||
result.isImage.push(asset.isImage);
|
||||
result.duration.push(asset.duration);
|
||||
result.projectionType.push(asset.projectionType);
|
||||
result.livePhotoVideoId.push(asset.livePhotoVideoId);
|
||||
result.city?.push(asset.city);
|
||||
result.country?.push(asset.country);
|
||||
result.visibility.push(asset.visibility);
|
||||
}
|
||||
|
||||
if (assets.some((a) => a.latitude !== null || a.longitude !== null)) {
|
||||
result.latitude = assets.map((a) => a.latitude);
|
||||
result.longitude = assets.map((a) => a.longitude);
|
||||
}
|
||||
|
||||
result.stack = assets.map(() => null);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a single bucket from timeline data (mimics getTimeBucket API)
|
||||
* Automatically handles both ISO timestamp and simple month formats
|
||||
* Returns data in columnar format matching the actual API
|
||||
* When albumId is provided, only returns assets from that album
|
||||
*/
|
||||
export function getTimeBucket(
|
||||
timelineData: MockTimelineData,
|
||||
timeBucket: string,
|
||||
isTrashed: boolean | undefined,
|
||||
isArchived: boolean | undefined,
|
||||
isFavorite: boolean | undefined,
|
||||
albumId: string | undefined,
|
||||
changes: Changes,
|
||||
): TimeBucketAssetResponseDto {
|
||||
const bucketKey = parseTimeBucketKey(timeBucket);
|
||||
let assets = timelineData.buckets.get(bucketKey);
|
||||
|
||||
if (!assets) {
|
||||
return toColumnarFormat([]);
|
||||
}
|
||||
|
||||
// Create sets for quick lookups
|
||||
const deletedAssetIds = new Set(changes.assetDeletions);
|
||||
const archivedAssetIds = new Set(changes.assetArchivals);
|
||||
const favoritedAssetIds = new Set(changes.assetFavorites);
|
||||
|
||||
// Filter assets based on trashed/archived status
|
||||
assets = assets.filter((asset) =>
|
||||
shouldIncludeAsset(asset, isTrashed, isArchived, isFavorite, deletedAssetIds, archivedAssetIds, favoritedAssetIds),
|
||||
);
|
||||
|
||||
// Filter to only include assets from the specified album
|
||||
if (albumId) {
|
||||
const album = timelineData.album;
|
||||
if (!album || album.id !== albumId) {
|
||||
return toColumnarFormat([]);
|
||||
}
|
||||
|
||||
// Create a Set for faster lookup
|
||||
const albumAssetIds = new Set([...album.assetIds, ...changes.albumAdditions]);
|
||||
assets = assets.filter((asset) => albumAssetIds.has(asset.id));
|
||||
}
|
||||
|
||||
// Override properties for assets in changes arrays
|
||||
const assetsWithOverrides = assets.map((asset) => {
|
||||
if (deletedAssetIds.has(asset.id) || archivedAssetIds.has(asset.id) || favoritedAssetIds.has(asset.id)) {
|
||||
return {
|
||||
...asset,
|
||||
isFavorite: favoritedAssetIds.has(asset.id) ? true : asset.isFavorite,
|
||||
isTrashed: deletedAssetIds.has(asset.id) ? true : asset.isTrashed,
|
||||
visibility: archivedAssetIds.has(asset.id) ? AssetVisibility.Archive : asset.visibility,
|
||||
};
|
||||
}
|
||||
return asset;
|
||||
});
|
||||
|
||||
return toColumnarFormat(assetsWithOverrides);
|
||||
}
|
||||
|
||||
export type Changes = {
|
||||
// ids of assets that are newly added to the album
|
||||
albumAdditions: string[];
|
||||
// ids of assets that are newly deleted
|
||||
assetDeletions: string[];
|
||||
// ids of assets that are newly archived
|
||||
assetArchivals: string[];
|
||||
// ids of assets that are newly favorited
|
||||
assetFavorites: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to determine if an asset should be included based on filter criteria
|
||||
* @param asset - The asset to check
|
||||
* @param isTrashed - Filter for trashed status (undefined means no filter)
|
||||
* @param isArchived - Filter for archived status (undefined means no filter)
|
||||
* @param isFavorite - Filter for favorite status (undefined means no filter)
|
||||
* @param deletedAssetIds - Set of IDs for assets that have been deleted
|
||||
* @param archivedAssetIds - Set of IDs for assets that have been archived
|
||||
* @param favoritedAssetIds - Set of IDs for assets that have been favorited
|
||||
* @returns true if the asset matches all filter criteria
|
||||
*/
|
||||
function shouldIncludeAsset(
|
||||
asset: MockTimelineAsset,
|
||||
isTrashed: boolean | undefined,
|
||||
isArchived: boolean | undefined,
|
||||
isFavorite: boolean | undefined,
|
||||
deletedAssetIds: Set<string>,
|
||||
archivedAssetIds: Set<string>,
|
||||
favoritedAssetIds: Set<string>,
|
||||
): boolean {
|
||||
// Determine actual status (property or in changes)
|
||||
const actuallyTrashed = asset.isTrashed || deletedAssetIds.has(asset.id);
|
||||
const actuallyArchived = asset.visibility === 'archive' || archivedAssetIds.has(asset.id);
|
||||
const actuallyFavorited = asset.isFavorite || favoritedAssetIds.has(asset.id);
|
||||
|
||||
// Apply filters
|
||||
if (isTrashed !== undefined && actuallyTrashed !== isTrashed) {
|
||||
return false;
|
||||
}
|
||||
if (isArchived !== undefined && actuallyArchived !== isArchived) {
|
||||
return false;
|
||||
}
|
||||
return isFavorite === undefined || actuallyFavorited === isFavorite;
|
||||
}
|
||||
/**
|
||||
* Get summary for all buckets (mimics getTimeBuckets API)
|
||||
* When albumId is provided, only includes buckets that contain assets from that album
|
||||
*/
|
||||
export function getTimeBuckets(
|
||||
timelineData: MockTimelineData,
|
||||
isTrashed: boolean | undefined,
|
||||
isArchived: boolean | undefined,
|
||||
isFavorite: boolean | undefined,
|
||||
albumId: string | undefined,
|
||||
changes: Changes,
|
||||
): TimeBucketsResponseDto[] {
|
||||
const summary: TimeBucketsResponseDto[] = [];
|
||||
|
||||
// Create sets for quick lookups
|
||||
const deletedAssetIds = new Set(changes.assetDeletions);
|
||||
const archivedAssetIds = new Set(changes.assetArchivals);
|
||||
const favoritedAssetIds = new Set(changes.assetFavorites);
|
||||
|
||||
// If no albumId is specified, return summary for all assets
|
||||
if (albumId) {
|
||||
// Filter to only include buckets with assets from the specified album
|
||||
const album = timelineData.album;
|
||||
if (!album || album.id !== albumId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create a Set for faster lookup
|
||||
const albumAssetIds = new Set([...album.assetIds, ...changes.albumAdditions]);
|
||||
for (const removed of changes.assetDeletions) {
|
||||
albumAssetIds.delete(removed);
|
||||
}
|
||||
for (const [bucketKey, assets] of timelineData.buckets) {
|
||||
// Count how many assets in this bucket are in the album and match trashed/archived filters
|
||||
const albumAssetsInBucket = assets.filter((asset) => {
|
||||
// Must be in the album
|
||||
if (!albumAssetIds.has(asset.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return shouldIncludeAsset(
|
||||
asset,
|
||||
isTrashed,
|
||||
isArchived,
|
||||
isFavorite,
|
||||
deletedAssetIds,
|
||||
archivedAssetIds,
|
||||
favoritedAssetIds,
|
||||
);
|
||||
});
|
||||
|
||||
if (albumAssetsInBucket.length > 0) {
|
||||
summary.push({
|
||||
timeBucket: bucketKey,
|
||||
count: albumAssetsInBucket.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const [bucketKey, assets] of timelineData.buckets) {
|
||||
// Filter assets based on trashed/archived status
|
||||
const filteredAssets = assets.filter((asset) =>
|
||||
shouldIncludeAsset(
|
||||
asset,
|
||||
isTrashed,
|
||||
isArchived,
|
||||
isFavorite,
|
||||
deletedAssetIds,
|
||||
archivedAssetIds,
|
||||
favoritedAssetIds,
|
||||
),
|
||||
);
|
||||
|
||||
if (filteredAssets.length > 0) {
|
||||
summary.push({
|
||||
timeBucket: bucketKey,
|
||||
count: filteredAssets.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort summary by date (newest first) using luxon
|
||||
summary.sort((a, b) => {
|
||||
const dateA = DateTime.fromISO(a.timeBucket);
|
||||
const dateB = DateTime.fromISO(b.timeBucket);
|
||||
return dateB.diff(dateA).milliseconds;
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
const createDefaultOwner = (ownerId: string) => {
|
||||
const defaultOwner: UserResponseDto = {
|
||||
id: ownerId,
|
||||
email: signupDto.admin.email,
|
||||
name: signupDto.admin.name,
|
||||
profileImagePath: '',
|
||||
profileChangedAt: new Date().toISOString(),
|
||||
avatarColor: UserAvatarColor.Blue,
|
||||
};
|
||||
return defaultOwner;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a TimelineAssetConfig to a full AssetResponseDto
|
||||
* This matches the response from GET /api/assets/:id
|
||||
*/
|
||||
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Default owner if not provided
|
||||
const defaultOwner = createDefaultOwner(asset.ownerId);
|
||||
|
||||
const exifInfo: ExifResponseDto = {
|
||||
make: null,
|
||||
model: null,
|
||||
exifImageWidth: asset.ratio > 1 ? 4000 : 3000,
|
||||
exifImageHeight: asset.ratio > 1 ? Math.round(4000 / asset.ratio) : Math.round(3000 * asset.ratio),
|
||||
fileSizeInByte: asset.fileSizeInByte,
|
||||
orientation: '1',
|
||||
dateTimeOriginal: asset.fileCreatedAt,
|
||||
modifyDate: asset.fileCreatedAt,
|
||||
timeZone: asset.latitude === null ? null : 'UTC',
|
||||
lensModel: null,
|
||||
fNumber: null,
|
||||
focalLength: null,
|
||||
iso: null,
|
||||
exposureTime: null,
|
||||
latitude: asset.latitude,
|
||||
longitude: asset.longitude,
|
||||
city: asset.city,
|
||||
country: asset.country,
|
||||
state: null,
|
||||
description: null,
|
||||
};
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
ownerId: asset.ownerId,
|
||||
owner: owner || defaultOwner,
|
||||
libraryId: `library-${asset.ownerId}`,
|
||||
type: asset.isVideo ? AssetTypeEnum.Video : AssetTypeEnum.Image,
|
||||
originalPath: `/original/${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
|
||||
originalFileName: `${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
|
||||
originalMimeType: asset.isVideo ? 'video/mp4' : 'image/jpeg',
|
||||
thumbhash: asset.thumbhash,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileCreatedAt,
|
||||
localDateTime: asset.localDateTime,
|
||||
updatedAt: now,
|
||||
createdAt: asset.fileCreatedAt,
|
||||
isFavorite: asset.isFavorite,
|
||||
isArchived: false,
|
||||
isTrashed: asset.isTrashed,
|
||||
visibility: asset.visibility,
|
||||
duration: asset.duration,
|
||||
exifInfo,
|
||||
livePhotoVideoId: asset.livePhotoVideoId,
|
||||
tags: [],
|
||||
people: [],
|
||||
stack: asset.stack,
|
||||
isOffline: false,
|
||||
hasMetadata: true,
|
||||
duplicateId: null,
|
||||
resized: true,
|
||||
checksum: asset.checksum,
|
||||
width: exifInfo.exifImageWidth ?? 1,
|
||||
height: exifInfo.exifImageHeight ?? 1,
|
||||
isEdited: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single asset by ID from timeline data
|
||||
* This matches the response from GET /api/assets/:id
|
||||
*/
|
||||
export function getAsset(
|
||||
timelineData: MockTimelineData,
|
||||
assetId: string,
|
||||
owner?: UserResponseDto,
|
||||
): AssetResponseDto | undefined {
|
||||
// Search through all buckets for the asset
|
||||
const buckets = timelineData.buckets.values().toArray();
|
||||
for (const assets of buckets) {
|
||||
const asset = assets.find((a) => a.id === assetId);
|
||||
if (asset) {
|
||||
return toAssetResponseDto(asset, owner);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a mock album from timeline data
|
||||
* This matches the response from GET /api/albums/:id
|
||||
*/
|
||||
export function getAlbum(
|
||||
timelineData: MockTimelineData,
|
||||
ownerId: string,
|
||||
albumId: string | undefined,
|
||||
changes: Changes,
|
||||
): AlbumResponseDto | undefined {
|
||||
if (!timelineData.album) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If albumId is provided and doesn't match, return undefined
|
||||
if (albumId && albumId !== timelineData.album.id) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const album = timelineData.album;
|
||||
const albumOwner = createDefaultOwner(ownerId);
|
||||
|
||||
// Get the actual asset objects from the timeline data
|
||||
const albumAssets: AssetResponseDto[] = [];
|
||||
const allAssets = timelineData.buckets.values().toArray().flat();
|
||||
|
||||
for (const assetId of album.assetIds) {
|
||||
const assetConfig = allAssets.find((a) => a.id === assetId);
|
||||
if (assetConfig) {
|
||||
albumAssets.push(toAssetResponseDto(assetConfig, albumOwner));
|
||||
}
|
||||
}
|
||||
for (const assetId of changes.albumAdditions ?? []) {
|
||||
const assetConfig = allAssets.find((a) => a.id === assetId);
|
||||
if (assetConfig) {
|
||||
albumAssets.push(toAssetResponseDto(assetConfig, albumOwner));
|
||||
}
|
||||
}
|
||||
|
||||
albumAssets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
|
||||
|
||||
// For a basic mock album, we don't include any albumUsers (shared users)
|
||||
// The owner is represented by the owner field, not in albumUsers
|
||||
const response: AlbumResponseDto = {
|
||||
id: album.id,
|
||||
albumName: album.albumName,
|
||||
description: album.description,
|
||||
albumThumbnailAssetId: album.thumbnailAssetId,
|
||||
createdAt: album.createdAt,
|
||||
updatedAt: album.updatedAt,
|
||||
albumUsers: [{ user: albumOwner, role: AlbumUserRole.Owner }],
|
||||
shared: false,
|
||||
hasSharedLink: false,
|
||||
isActivityEnabled: true,
|
||||
assetCount: albumAssets.length,
|
||||
startDate: albumAssets.length > 0 ? albumAssets.at(-1)?.fileCreatedAt : undefined,
|
||||
endDate: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
|
||||
lastModifiedAssetTimestamp: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import type { AssetVisibility } from '@immich/sdk';
|
||||
import { DayPattern, MonthDistribution } from 'src/ui/generators/timeline/distribution-patterns.js';
|
||||
|
||||
// Constants for generation parameters
|
||||
export const GENERATION_CONSTANTS = {
|
||||
VIDEO_PROBABILITY: 0.15, // 15% of assets are videos
|
||||
GPS_PERCENTAGE: 0.7, // 70% of assets have GPS data
|
||||
FAVORITE_PROBABILITY: 0.1, // 10% of assets are favorited
|
||||
MIN_VIDEO_DURATION_SECONDS: 5,
|
||||
MAX_VIDEO_DURATION_SECONDS: 300,
|
||||
DEFAULT_SEED: 12_345,
|
||||
DEFAULT_OWNER_ID: 'user-1',
|
||||
MAX_SELECT_ATTEMPTS: 10,
|
||||
SPARSE_DAY_COVERAGE: 0.4, // 40% of days have photos in sparse pattern
|
||||
} as const;
|
||||
|
||||
// Aspect ratio distribution weights (must sum to 1)
|
||||
export const ASPECT_RATIO_WEIGHTS = {
|
||||
'4:3': 0.35, // 35% 4:3 landscape
|
||||
'3:2': 0.25, // 25% 3:2 landscape
|
||||
'16:9': 0.2, // 20% 16:9 landscape
|
||||
'2:3': 0.1, // 10% 2:3 portrait
|
||||
'1:1': 0.09, // 9% 1:1 square
|
||||
'3:1': 0.01, // 1% 3:1 panorama
|
||||
} as const;
|
||||
|
||||
// Mock configuration for asset generation - will be transformed to API response formats
|
||||
export type MockTimelineAsset = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
ratio: number;
|
||||
thumbhash: string | null;
|
||||
localDateTime: string;
|
||||
fileCreatedAt: string;
|
||||
isFavorite: boolean;
|
||||
isTrashed: boolean;
|
||||
isVideo: boolean;
|
||||
isImage: boolean;
|
||||
duration: number | null;
|
||||
projectionType: string | null;
|
||||
livePhotoVideoId: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
people: string[] | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
visibility: AssetVisibility;
|
||||
stack: null;
|
||||
checksum: string;
|
||||
fileSizeInByte: number;
|
||||
};
|
||||
|
||||
export type MonthSpec = {
|
||||
year: number;
|
||||
month: number; // 1-12
|
||||
distribution: MonthDistribution;
|
||||
pattern: DayPattern;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for timeline data generation
|
||||
*/
|
||||
export type TimelineConfig = {
|
||||
ownerId?: string;
|
||||
months: MonthSpec[];
|
||||
seed?: number;
|
||||
writeToFile?: boolean;
|
||||
outputPath?: string;
|
||||
};
|
||||
|
||||
export type MockAlbum = {
|
||||
id: string;
|
||||
albumName: string;
|
||||
description: string;
|
||||
assetIds: string[]; // IDs of assets in the album
|
||||
thumbnailAssetId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type MockTimelineData = {
|
||||
buckets: Map<string, MockTimelineAsset[]>;
|
||||
album: MockAlbum; // Mock album created from random assets
|
||||
};
|
||||
|
||||
export type SerializedTimelineData = {
|
||||
buckets: Record<string, MockTimelineAsset[]>;
|
||||
album: MockAlbum;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a TimelineConfig object to ensure all values are within expected ranges
|
||||
*/
|
||||
export function validateTimelineConfig(config: TimelineConfig): void {
|
||||
if (!config.months || config.months.length === 0) {
|
||||
throw new Error('TimelineConfig must contain at least one month');
|
||||
}
|
||||
|
||||
const seenMonths = new Set<string>();
|
||||
|
||||
for (const month of config.months) {
|
||||
if (month.month < 1 || month.month > 12) {
|
||||
throw new Error(`Invalid month: ${month.month}. Must be between 1 and 12`);
|
||||
}
|
||||
|
||||
if (month.year < 1900 || month.year > 2100) {
|
||||
throw new Error(`Invalid year: ${month.year}. Must be between 1900 and 2100`);
|
||||
}
|
||||
|
||||
const monthKey = `${month.year}-${month.month}`;
|
||||
if (seenMonths.has(monthKey)) {
|
||||
throw new Error(`Duplicate month found: ${monthKey}`);
|
||||
}
|
||||
seenMonths.add(monthKey);
|
||||
|
||||
// Validate distribution if provided
|
||||
if (month.distribution && !['empty', 'sparse', 'medium', 'dense', 'very-dense'].includes(month.distribution)) {
|
||||
throw new Error(
|
||||
`Invalid distribution: ${month.distribution}. Must be one of: empty, sparse, medium, dense, very-dense`,
|
||||
);
|
||||
}
|
||||
|
||||
const validPatterns = [
|
||||
'single-day',
|
||||
'consecutive-large',
|
||||
'consecutive-small',
|
||||
'alternating',
|
||||
'sparse-scattered',
|
||||
'start-heavy',
|
||||
'end-heavy',
|
||||
'mid-heavy',
|
||||
];
|
||||
if (month.pattern && !validPatterns.includes(month.pattern)) {
|
||||
throw new Error(`Invalid pattern: ${month.pattern}. Must be one of: ${validPatterns.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate seed if provided
|
||||
if (config.seed !== undefined && (config.seed < 0 || !Number.isSafeInteger(config.seed))) {
|
||||
throw new Error('Seed must be a non-negative integer');
|
||||
}
|
||||
|
||||
// Validate ownerId if provided
|
||||
if (config.ownerId !== undefined && config.ownerId.trim() === '') {
|
||||
throw new Error('Owner ID cannot be an empty string');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a default timeline configuration
|
||||
*/
|
||||
export function createDefaultTimelineConfig(): TimelineConfig {
|
||||
const months: MonthSpec[] = [
|
||||
// 2024 - Mix of patterns
|
||||
{ year: 2024, month: 12, distribution: 'very-dense', pattern: 'alternating' },
|
||||
{ year: 2024, month: 11, distribution: 'dense', pattern: 'consecutive-large' },
|
||||
{ year: 2024, month: 10, distribution: 'medium', pattern: 'mid-heavy' },
|
||||
{ year: 2024, month: 9, distribution: 'sparse', pattern: 'consecutive-small' },
|
||||
{ year: 2024, month: 8, distribution: 'empty', pattern: 'single-day' },
|
||||
{ year: 2024, month: 7, distribution: 'dense', pattern: 'start-heavy' },
|
||||
{ year: 2024, month: 6, distribution: 'medium', pattern: 'sparse-scattered' },
|
||||
{ year: 2024, month: 5, distribution: 'sparse', pattern: 'single-day' },
|
||||
{ year: 2024, month: 4, distribution: 'very-dense', pattern: 'consecutive-large' },
|
||||
{ year: 2024, month: 3, distribution: 'empty', pattern: 'single-day' },
|
||||
{ year: 2024, month: 2, distribution: 'medium', pattern: 'end-heavy' },
|
||||
{ year: 2024, month: 1, distribution: 'dense', pattern: 'alternating' },
|
||||
|
||||
// 2023 - Testing year boundaries and more patterns
|
||||
{ year: 2023, month: 12, distribution: 'very-dense', pattern: 'end-heavy' },
|
||||
{ year: 2023, month: 11, distribution: 'sparse', pattern: 'consecutive-small' },
|
||||
{ year: 2023, month: 10, distribution: 'empty', pattern: 'single-day' },
|
||||
{ year: 2023, month: 9, distribution: 'medium', pattern: 'alternating' },
|
||||
{ year: 2023, month: 8, distribution: 'dense', pattern: 'mid-heavy' },
|
||||
{ year: 2023, month: 7, distribution: 'sparse', pattern: 'sparse-scattered' },
|
||||
{ year: 2023, month: 6, distribution: 'medium', pattern: 'consecutive-large' },
|
||||
{ year: 2023, month: 5, distribution: 'empty', pattern: 'single-day' },
|
||||
{ year: 2023, month: 4, distribution: 'sparse', pattern: 'single-day' },
|
||||
{ year: 2023, month: 3, distribution: 'dense', pattern: 'start-heavy' },
|
||||
{ year: 2023, month: 2, distribution: 'medium', pattern: 'alternating' },
|
||||
{ year: 2023, month: 1, distribution: 'very-dense', pattern: 'consecutive-large' },
|
||||
];
|
||||
|
||||
for (let year = 2022; year >= 2000; year--) {
|
||||
for (let month = 12; month >= 1; month--) {
|
||||
months.push({ year, month, distribution: 'medium', pattern: 'sparse-scattered' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
months,
|
||||
seed: 42,
|
||||
};
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { GENERATION_CONSTANTS, MockTimelineAsset } from 'src/ui/generators/timeline/timeline-config.js';
|
||||
|
||||
/**
|
||||
* Linear Congruential Generator for deterministic pseudo-random numbers
|
||||
*/
|
||||
export class SeededRandom {
|
||||
private seed: number;
|
||||
|
||||
constructor(seed: number) {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate next random number in range [0, 1)
|
||||
*/
|
||||
next(): number {
|
||||
// LCG parameters from Numerical Recipes
|
||||
this.seed = (this.seed * 1_664_525 + 1_013_904_223) % 2_147_483_647;
|
||||
return this.seed / 2_147_483_647;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random integer in range [min, max)
|
||||
*/
|
||||
nextInt(min: number, max: number): number {
|
||||
return Math.floor(this.next() * (max - min)) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random boolean with given probability
|
||||
*/
|
||||
nextBoolean(probability = 0.5): boolean {
|
||||
return this.next() < probability;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select random days using seed variation to avoid collisions.
|
||||
*
|
||||
* @param daysInMonth - Total number of days in the month
|
||||
* @param numDays - Number of days to select
|
||||
* @param rng - Random number generator instance
|
||||
* @returns Array of selected day numbers, sorted in descending order
|
||||
*/
|
||||
export function selectRandomDays(daysInMonth: number, numDays: number, rng: SeededRandom): number[] {
|
||||
const selectedDays = new Set<number>();
|
||||
const maxAttempts = numDays * GENERATION_CONSTANTS.MAX_SELECT_ATTEMPTS; // Safety limit
|
||||
let attempts = 0;
|
||||
|
||||
while (selectedDays.size < numDays && attempts < maxAttempts) {
|
||||
const day = rng.nextInt(1, daysInMonth + 1);
|
||||
selectedDays.add(day);
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// Fallback: if we couldn't select enough random days, fill with sequential days
|
||||
if (selectedDays.size < numDays) {
|
||||
for (let day = 1; day <= daysInMonth && selectedDays.size < numDays; day++) {
|
||||
selectedDays.add(day);
|
||||
}
|
||||
}
|
||||
|
||||
return [...selectedDays].toSorted((a, b) => b - a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select item from array using seeded random
|
||||
*/
|
||||
export function selectRandom<T>(arr: T[], rng: SeededRandom): T {
|
||||
if (arr.length === 0) {
|
||||
throw new Error('Cannot select from empty array');
|
||||
}
|
||||
const index = rng.nextInt(0, arr.length);
|
||||
return arr[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Select multiple random items from array using seeded random without duplicates
|
||||
*/
|
||||
export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRandom): T[] {
|
||||
if (arr.length === 0) {
|
||||
throw new Error('Cannot select from empty array');
|
||||
}
|
||||
if (count < 0) {
|
||||
throw new Error('Count must be non-negative');
|
||||
}
|
||||
if (count > arr.length) {
|
||||
throw new Error('Count cannot exceed array length');
|
||||
}
|
||||
|
||||
const result: T[] = [];
|
||||
const selectedIndices = new Set<number>();
|
||||
|
||||
while (result.length < count) {
|
||||
const index = rng.nextInt(0, arr.length);
|
||||
if (!selectedIndices.has(index)) {
|
||||
selectedIndices.add(index);
|
||||
result.push(arr[index]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse timeBucket parameter to extract year-month key
|
||||
* Handles both formats:
|
||||
* - ISO timestamp: "2024-12-01T00:00:00.000Z" -> "2024-12-01"
|
||||
* - Simple format: "2024-12-01" -> "2024-12-01"
|
||||
*/
|
||||
export function parseTimeBucketKey(timeBucket: string): string {
|
||||
if (!timeBucket) {
|
||||
throw new Error('timeBucket parameter cannot be empty');
|
||||
}
|
||||
|
||||
const dt = DateTime.fromISO(timeBucket, { zone: 'utc' });
|
||||
|
||||
if (!dt.isValid) {
|
||||
// Fallback to regex if not a valid ISO string
|
||||
const match = timeBucket.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
return match ? match[1] : timeBucket;
|
||||
}
|
||||
|
||||
// Format as YYYY-MM-01 (first day of month)
|
||||
return `${dt.year}-${String(dt.month).padStart(2, '0')}-01`;
|
||||
}
|
||||
|
||||
export function getMockAsset(
|
||||
asset: MockTimelineAsset,
|
||||
sortedDescendingAssets: MockTimelineAsset[],
|
||||
direction: 'next' | 'previous',
|
||||
unit: 'day' | 'month' | 'year' = 'day',
|
||||
): MockTimelineAsset | null {
|
||||
const currentDateTime = DateTime.fromISO(asset.localDateTime, { zone: 'utc' });
|
||||
|
||||
const currentIndex = sortedDescendingAssets.findIndex((a) => a.id === asset.id);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const step = direction === 'next' ? 1 : -1;
|
||||
const startIndex = currentIndex + step;
|
||||
|
||||
if (direction === 'next' && currentIndex >= sortedDescendingAssets.length - 1) {
|
||||
return null;
|
||||
}
|
||||
if (direction === 'previous' && currentIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
|
||||
if (unit === 'day') {
|
||||
return !date1.startOf('day').equals(date2.startOf('day'));
|
||||
}
|
||||
return unit === 'month' ? date1.year !== date2.year || date1.month !== date2.month : date1.year !== date2.year;
|
||||
};
|
||||
|
||||
if (direction === 'next') {
|
||||
// Search forward in array (backwards in time)
|
||||
for (let i = startIndex; i < sortedDescendingAssets.length; i++) {
|
||||
const nextAsset = sortedDescendingAssets[i];
|
||||
const nextDate = DateTime.fromISO(nextAsset.localDateTime, { zone: 'utc' });
|
||||
|
||||
if (isInDifferentPeriod(nextDate, currentDateTime)) {
|
||||
return nextAsset;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Search backward in array (forwards in time)
|
||||
for (let i = startIndex; i >= 0; i--) {
|
||||
const prevAsset = sortedDescendingAssets[i];
|
||||
const prevDate = DateTime.fromISO(prevAsset.localDateTime, { zone: 'utc' });
|
||||
|
||||
if (isInDifferentPeriod(prevDate, currentDateTime)) {
|
||||
return prevAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { playwrightHost } from 'src/../playwright.config.js';
|
||||
|
||||
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'immich_is_authenticated',
|
||||
value: 'true',
|
||||
domain: playwrightHost,
|
||||
path: '/',
|
||||
},
|
||||
]);
|
||||
await context.route('**/api/users/me', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
id: adminUserId,
|
||||
email: 'admin@immich.cloud',
|
||||
name: 'Immich Admin',
|
||||
profileImagePath: '',
|
||||
avatarColor: 'orange',
|
||||
profileChangedAt: '2025-01-22T21:31:23.996Z',
|
||||
storageLabel: 'admin',
|
||||
shouldChangePassword: true,
|
||||
isAdmin: true,
|
||||
createdAt: '2025-01-22T21:31:23.996Z',
|
||||
deletedAt: null,
|
||||
updatedAt: '2025-11-14T00:00:00.369Z',
|
||||
oauthId: '',
|
||||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 20_849_000_159,
|
||||
status: 'active',
|
||||
license: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/users/me/preferences', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
albums: {
|
||||
defaultAssetOrder: 'desc',
|
||||
},
|
||||
folders: {
|
||||
enabled: false,
|
||||
sidebarWeb: false,
|
||||
},
|
||||
memories: {
|
||||
enabled: true,
|
||||
duration: 5,
|
||||
},
|
||||
people: {
|
||||
enabled: true,
|
||||
sidebarWeb: false,
|
||||
},
|
||||
sharedLinks: {
|
||||
enabled: true,
|
||||
sidebarWeb: false,
|
||||
},
|
||||
ratings: {
|
||||
enabled: false,
|
||||
},
|
||||
tags: {
|
||||
enabled: false,
|
||||
sidebarWeb: false,
|
||||
},
|
||||
emailNotifications: {
|
||||
enabled: true,
|
||||
albumInvite: true,
|
||||
albumUpdate: true,
|
||||
},
|
||||
download: {
|
||||
archiveSize: 4_294_967_296,
|
||||
includeEmbeddedVideos: false,
|
||||
},
|
||||
purchase: {
|
||||
showSupportBadge: true,
|
||||
hideBuyButtonUntil: '2100-02-12T00:00:00.000Z',
|
||||
},
|
||||
cast: {
|
||||
gCastEnabled: false,
|
||||
},
|
||||
recentlyAdded: {
|
||||
sidebarWeb: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/server/about', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
version: 'v2.2.3',
|
||||
versionUrl: 'https://github.com/immich-app/immich/releases/tag/v2.2.3',
|
||||
licensed: false,
|
||||
build: '1234567890',
|
||||
buildUrl: 'https://github.com/immich-app/immich/actions/runs/1234567890',
|
||||
buildImage: 'e2e',
|
||||
buildImageUrl: 'https://github.com/immich-app/immich/pkgs/container/immich-server',
|
||||
repository: 'immich-app/immich',
|
||||
repositoryUrl: 'https://github.com/immich-app/immich',
|
||||
sourceRef: 'e2e',
|
||||
sourceCommit: 'e2eeeeeeeeeeeeeeeeee',
|
||||
sourceUrl: 'https://github.com/immich-app/immich/commit/e2eeeeeeeeeeeeeeeeee',
|
||||
nodejs: 'v22.18.0',
|
||||
exiftool: '13.41',
|
||||
ffmpeg: '7.1.1-6',
|
||||
libvips: '8.17.2',
|
||||
imagemagick: '7.1.2-2',
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/api/server/features', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
smartSearch: false,
|
||||
facialRecognition: false,
|
||||
duplicateDetection: false,
|
||||
map: true,
|
||||
reverseGeocoding: true,
|
||||
importFaces: false,
|
||||
sidecar: true,
|
||||
search: true,
|
||||
trash: true,
|
||||
oauth: false,
|
||||
oauthAutoLaunch: false,
|
||||
ocr: false,
|
||||
passwordLogin: true,
|
||||
configFile: false,
|
||||
email: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/api/server/config', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
loginPageMessage: '',
|
||||
trashDays: 30,
|
||||
userDeleteDelay: 7,
|
||||
oauthButtonText: 'Login with OAuth',
|
||||
isInitialized: true,
|
||||
isOnboarded: true,
|
||||
externalDomain: '',
|
||||
publicUsers: true,
|
||||
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
maintenanceMode: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/api/server/media-types', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
video: [
|
||||
'.3gp',
|
||||
'.3gpp',
|
||||
'.avi',
|
||||
'.flv',
|
||||
'.insv',
|
||||
'.m2t',
|
||||
'.m2ts',
|
||||
'.m4v',
|
||||
'.mkv',
|
||||
'.mov',
|
||||
'.mp4',
|
||||
'.mpe',
|
||||
'.mpeg',
|
||||
'.mpg',
|
||||
'.mts',
|
||||
'.ts',
|
||||
'.vob',
|
||||
'.webm',
|
||||
'.wmv',
|
||||
],
|
||||
image: [
|
||||
'.3fr',
|
||||
'.ari',
|
||||
'.arw',
|
||||
'.cap',
|
||||
'.cin',
|
||||
'.cr2',
|
||||
'.cr3',
|
||||
'.crw',
|
||||
'.dcr',
|
||||
'.dng',
|
||||
'.erf',
|
||||
'.fff',
|
||||
'.iiq',
|
||||
'.k25',
|
||||
'.kdc',
|
||||
'.mrw',
|
||||
'.nef',
|
||||
'.nrw',
|
||||
'.orf',
|
||||
'.ori',
|
||||
'.pef',
|
||||
'.psd',
|
||||
'.raf',
|
||||
'.raw',
|
||||
'.rw2',
|
||||
'.rwl',
|
||||
'.sr2',
|
||||
'.srf',
|
||||
'.srw',
|
||||
'.x3f',
|
||||
'.avif',
|
||||
'.gif',
|
||||
'.jpeg',
|
||||
'.jpg',
|
||||
'.png',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
'.heic',
|
||||
'.heif',
|
||||
'.hif',
|
||||
'.insp',
|
||||
'.jp2',
|
||||
'.jpe',
|
||||
'.jxl',
|
||||
'.mpo',
|
||||
'.svg',
|
||||
'.tif',
|
||||
'.tiff',
|
||||
],
|
||||
sidecar: ['.xmp'],
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/api/notifications*', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: [],
|
||||
});
|
||||
});
|
||||
await context.route('**/api/albums*', async (route, request) => {
|
||||
const url = request.url();
|
||||
if (url.endsWith('albums?isShared=true') || url.endsWith('albums?isOwned=true') || url.endsWith('albums')) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: [],
|
||||
});
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
await context.route('**/api/memories*', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: [],
|
||||
});
|
||||
});
|
||||
await context.route('**/api/server/storage', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
diskSize: '100.0 GiB',
|
||||
diskUse: '74.4 GiB',
|
||||
diskAvailable: '25.6 GiB',
|
||||
diskSizeRaw: 107_374_182_400,
|
||||
diskUseRaw: 79_891_660_800,
|
||||
diskAvailableRaw: 27_482_521_600,
|
||||
diskUsagePercentage: 74.4,
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/api/server/version-history', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: [
|
||||
{
|
||||
id: 'd1fbeadc-cb4f-4db3-8d19-8c6a921d5d8e',
|
||||
createdAt: '2025-11-15T20:14:01.935Z',
|
||||
version: '2.2.3',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,164 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { randomPreview, randomThumbnail } from 'src/ui/generators/timeline.js';
|
||||
|
||||
export type MockStack = {
|
||||
id: string;
|
||||
primaryAssetId: string;
|
||||
assets: AssetResponseDto[];
|
||||
brokenAssetIds: Set<string>;
|
||||
assetMap: Map<string, AssetResponseDto>;
|
||||
};
|
||||
|
||||
export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
|
||||
const assetId = faker.string.uuid();
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: assetId,
|
||||
ownerId,
|
||||
owner: {
|
||||
id: ownerId,
|
||||
email: 'admin@immich.cloud',
|
||||
name: 'Admin',
|
||||
profileImagePath: '',
|
||||
profileChangedAt: now,
|
||||
avatarColor: 'blue' as never,
|
||||
},
|
||||
libraryId: `library-${ownerId}`,
|
||||
type: AssetTypeEnum.Image,
|
||||
originalPath: `/original/${assetId}.jpg`,
|
||||
originalFileName: `${assetId}.jpg`,
|
||||
originalMimeType: 'image/jpeg',
|
||||
thumbhash: null,
|
||||
fileCreatedAt: now,
|
||||
fileModifiedAt: now,
|
||||
localDateTime: now,
|
||||
updatedAt: now,
|
||||
createdAt: now,
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
isTrashed: false,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
duration: null,
|
||||
exifInfo: {
|
||||
make: null,
|
||||
model: null,
|
||||
exifImageWidth: 3000,
|
||||
exifImageHeight: 4000,
|
||||
fileSizeInByte: null,
|
||||
orientation: null,
|
||||
dateTimeOriginal: now,
|
||||
modifyDate: null,
|
||||
timeZone: null,
|
||||
lensModel: null,
|
||||
fNumber: null,
|
||||
focalLength: null,
|
||||
iso: null,
|
||||
exposureTime: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
city: null,
|
||||
country: null,
|
||||
state: null,
|
||||
description: null,
|
||||
},
|
||||
livePhotoVideoId: null,
|
||||
tags: [],
|
||||
people: [],
|
||||
stack: undefined,
|
||||
isOffline: false,
|
||||
hasMetadata: true,
|
||||
duplicateId: null,
|
||||
resized: true,
|
||||
checksum: faker.string.alphanumeric({ length: 28 }),
|
||||
width: 3000,
|
||||
height: 4000,
|
||||
isEdited: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const createMockStack = (
|
||||
primaryAssetDto: AssetResponseDto,
|
||||
additionalAssets: AssetResponseDto[],
|
||||
brokenAssetIds?: Set<string>,
|
||||
): MockStack => {
|
||||
const stackId = faker.string.uuid();
|
||||
const allAssets = [primaryAssetDto, ...additionalAssets];
|
||||
const resolvedBrokenIds = brokenAssetIds ?? new Set(additionalAssets.map((a) => a.id));
|
||||
const assetMap = new Map(allAssets.map((a) => [a.id, a]));
|
||||
|
||||
primaryAssetDto.stack = {
|
||||
id: stackId,
|
||||
assetCount: allAssets.length,
|
||||
primaryAssetId: primaryAssetDto.id,
|
||||
};
|
||||
|
||||
return {
|
||||
id: stackId,
|
||||
primaryAssetId: primaryAssetDto.id,
|
||||
assets: allAssets,
|
||||
brokenAssetIds: resolvedBrokenIds,
|
||||
assetMap,
|
||||
};
|
||||
};
|
||||
|
||||
export const setupBrokenAssetMockApiRoutes = async (context: BrowserContext, mockStack: MockStack) => {
|
||||
await context.route('**/api/stacks/*', async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
const stackResponse: StackResponseDto = {
|
||||
id: mockStack.id,
|
||||
primaryAssetId: mockStack.primaryAssetId,
|
||||
assets: mockStack.assets,
|
||||
};
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: stackResponse,
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*', async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
const url = new URL(request.url());
|
||||
const segments = url.pathname.split('/');
|
||||
const assetId = segments.at(-1);
|
||||
if (assetId && mockStack.assetMap.has(assetId)) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: mockStack.assetMap.get(assetId),
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
||||
const match = request.url().match(pattern);
|
||||
if (!match?.groups || !mockStack.assetMap.has(match.groups.assetId)) {
|
||||
return route.fallback();
|
||||
}
|
||||
if (mockStack.brokenAssetIds.has(match.groups.assetId)) {
|
||||
return route.fulfill({ status: 404 });
|
||||
}
|
||||
const asset = mockStack.assetMap.get(match.groups.assetId)!;
|
||||
const ratio = (asset.exifInfo?.exifImageWidth ?? 3000) / (asset.exifInfo?.exifImageHeight ?? 4000);
|
||||
const body =
|
||||
match.groups.size === 'preview'
|
||||
? await randomPreview(match.groups.assetId, ratio)
|
||||
: await randomThumbnail(match.groups.assetId, ratio);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { randomThumbnail } from 'src/ui/generators/timeline.js';
|
||||
|
||||
// Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight
|
||||
const MINIMAL_MP4_BASE64 =
|
||||
'AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAr9tZGF0AAACoAYF//+c' +
|
||||
'3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDEyNSAtIEguMjY0L01QRUctNCBBVkMgY29kZWMg' +
|
||||
'LSBDb3B5bGVmdCAyMDAzLTIwMTIgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwg' +
|
||||
'LSBvcHRpb25zOiBjYWJhYz0xIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDM6MHgxMTMg' +
|
||||
'bWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5n' +
|
||||
'ZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEg' +
|
||||
'ZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02IGxvb2thaGVhZF90aHJl' +
|
||||
'YWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJh' +
|
||||
'eV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2Fk' +
|
||||
'YXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtl' +
|
||||
'eWludD0yNTAga2V5aW50X21pbj0yNCBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9v' +
|
||||
'a2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBt' +
|
||||
'YXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAAA9liIQAV/0TAAYdeBTX' +
|
||||
'zg8AAALvbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAACoAAQAAAQAAAAAAAAAAAAAAAAEAAAAA' +
|
||||
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAA' +
|
||||
'Ahl0cmFrAAAAXHRraGQAAAAPAAAAAAAAAAAAAAABAAAAAAAAACoAAAAAAAAAAAAAAAAAAAAAAAEAAAAA' +
|
||||
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAgAAAAIAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAA' +
|
||||
'AAEAAAAqAAAAAAABAAAAAAGRbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAwAAAAAgBVxAAAAAAA' +
|
||||
'LWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABPG1pbmYAAAAUdm1oZAAA' +
|
||||
'AAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAPxzdGJsAAAAmHN0' +
|
||||
'c2QAAAAAAAAAAQAAAIhhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAgACABIAAAASAAAAAAAAAAB' +
|
||||
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAMmF2Y0MBZAAK/+EAGWdkAAqs' +
|
||||
'2V+WXAWyAAADAAIAAAMAYB4kSywBAAZo6+PLIsAAAAAYc3R0cwAAAAAAAAABAAAAAQAAAgAAAAAcc3Rz' +
|
||||
'YwAAAAAAAAABAAAAAQAAAAEAAAABAAAAFHN0c3oAAAAAAAACtwAAAAEAAAAUc3RjbwAAAAAAAAABAAAA' +
|
||||
'MAAAAGJ1ZHRhAAAAWm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALWls' +
|
||||
'c3QAAAAlqXRvbwAAAB1kYXRhAAAAAQAAAABMYXZmNTQuNjMuMTA0';
|
||||
|
||||
export const MINIMAL_MP4_BUFFER = Buffer.from(MINIMAL_MP4_BASE64, 'base64');
|
||||
|
||||
export type MockPerson = {
|
||||
id: string;
|
||||
name: string;
|
||||
birthDate: string | null;
|
||||
isHidden: boolean;
|
||||
thumbnailPath: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export const createMockPeople = (count: number): MockPerson[] => {
|
||||
const names = [
|
||||
'Alice Johnson',
|
||||
'Bob Smith',
|
||||
'Charlie Brown',
|
||||
'Diana Prince',
|
||||
'Eve Adams',
|
||||
'Frank Castle',
|
||||
'Grace Lee',
|
||||
'Hank Pym',
|
||||
'Iris West',
|
||||
'Jack Ryan',
|
||||
];
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `person-${index}`,
|
||||
name: names[index % names.length],
|
||||
birthDate: null,
|
||||
isHidden: false,
|
||||
thumbnailPath: `/upload/thumbs/person-${index}.jpeg`,
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
}));
|
||||
};
|
||||
|
||||
export type FaceCreateCapture = {
|
||||
requests: Array<{
|
||||
assetId: string;
|
||||
personId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const setupFaceEditorMockApiRoutes = async (
|
||||
context: BrowserContext,
|
||||
mockPeople: MockPerson[],
|
||||
faceCreateCapture: FaceCreateCapture,
|
||||
) => {
|
||||
await context.route('**/api/people?*', async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
hasNextPage: false,
|
||||
hidden: 0,
|
||||
people: mockPeople,
|
||||
total: mockPeople.length,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/faces', async (route, request) => {
|
||||
if (request.method() !== 'POST') {
|
||||
return route.fallback();
|
||||
}
|
||||
|
||||
const body = request.postDataJSON();
|
||||
faceCreateCapture.requests.push(body);
|
||||
|
||||
return route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'text/plain',
|
||||
body: 'OK',
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/people/*/thumbnail', async (route) => {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: await randomThumbnail('person-thumb', 1),
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { MemoryResponseDto } from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
|
||||
export type MemoryChanges = {
|
||||
memoryDeletions: string[];
|
||||
assetRemovals: Map<string, string[]>;
|
||||
};
|
||||
|
||||
export const setupMemoryMockApiRoutes = async (
|
||||
context: BrowserContext,
|
||||
memories: MemoryResponseDto[],
|
||||
changes: MemoryChanges,
|
||||
) => {
|
||||
await context.route('**/api/memories*', async (route, request) => {
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname === '/api/memories' && request.method() === 'GET') {
|
||||
const activeMemories = memories
|
||||
.filter((memory) => !changes.memoryDeletions.includes(memory.id))
|
||||
.map((memory) => {
|
||||
const removedAssets = changes.assetRemovals.get(memory.id) ?? [];
|
||||
return {
|
||||
...memory,
|
||||
assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
|
||||
};
|
||||
})
|
||||
.filter((memory) => memory.assets.length > 0);
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: activeMemories,
|
||||
});
|
||||
}
|
||||
|
||||
const memoryMatch = pathname.match(/\/api\/memories\/([^/]+)$/);
|
||||
if (memoryMatch && request.method() === 'GET') {
|
||||
const memoryId = memoryMatch[1];
|
||||
const memory = memories.find((m) => m.id === memoryId);
|
||||
|
||||
if (!memory || changes.memoryDeletions.includes(memoryId)) {
|
||||
return route.fulfill({ status: 404 });
|
||||
}
|
||||
|
||||
const removedAssets = changes.assetRemovals.get(memoryId) ?? [];
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
...memory,
|
||||
assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (/\/api\/memories\/([^/]+)$/.test(pathname) && request.method() === 'DELETE') {
|
||||
const memoryId = pathname.split('/').pop()!;
|
||||
changes.memoryDeletions.push(memoryId);
|
||||
return route.fulfill({ status: 204 });
|
||||
}
|
||||
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await context.route('**/api/memories/statistics*', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
total: memories.length,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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,189 +0,0 @@
|
||||
import { AssetResponseDto } from '@immich/sdk';
|
||||
import { BrowserContext, Page, Request, Route } from '@playwright/test';
|
||||
import { basename } from 'node:path';
|
||||
import {
|
||||
Changes,
|
||||
getAlbum,
|
||||
getAsset,
|
||||
getTimeBucket,
|
||||
getTimeBuckets,
|
||||
randomPreview,
|
||||
randomThumbnail,
|
||||
TimelineData,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { sleep } from 'src/ui/specs/timeline/utils.js';
|
||||
import { MINIMAL_MP4_BUFFER } from './face-editor-network';
|
||||
|
||||
export class TimelineTestContext {
|
||||
slowBucket = false;
|
||||
adminId = '';
|
||||
}
|
||||
|
||||
export const setupTimelineMockApiRoutes = async (
|
||||
context: BrowserContext,
|
||||
timelineRestData: TimelineData,
|
||||
changes: Changes,
|
||||
testContext: TimelineTestContext,
|
||||
) => {
|
||||
await context.route('**/api/timeline**', async (route, request) => {
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
if (pathname === '/api/timeline/buckets') {
|
||||
const albumId = url.searchParams.get('albumId') || undefined;
|
||||
const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
|
||||
const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
|
||||
const isArchived = url.searchParams.get('visibility')
|
||||
? url.searchParams.get('visibility') === 'archive'
|
||||
: undefined;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: getTimeBuckets(timelineRestData, isTrashed, isArchived, isFavorite, albumId, changes),
|
||||
});
|
||||
}
|
||||
if (pathname === '/api/timeline/bucket') {
|
||||
const timeBucket = url.searchParams.get('timeBucket');
|
||||
if (!timeBucket) {
|
||||
return route.continue();
|
||||
}
|
||||
const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
|
||||
const isArchived = url.searchParams.get('visibility')
|
||||
? url.searchParams.get('visibility') === 'archive'
|
||||
: undefined;
|
||||
const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
|
||||
const albumId = url.searchParams.get('albumId') || undefined;
|
||||
const assets = getTimeBucket(timelineRestData, timeBucket, isTrashed, isArchived, isFavorite, albumId, changes);
|
||||
if (testContext.slowBucket) {
|
||||
await sleep(5000);
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: assets,
|
||||
});
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*', async (route, request) => {
|
||||
if (request.method() === 'GET') {
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
const assetId = basename(pathname);
|
||||
let asset = getAsset(timelineRestData, assetId);
|
||||
if (changes.assetDeletions.includes(asset!.id)) {
|
||||
asset = {
|
||||
...asset,
|
||||
isTrashed: true,
|
||||
} as AssetResponseDto;
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: asset,
|
||||
});
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets', async (route, request) => {
|
||||
if (request.method() === 'DELETE') {
|
||||
return route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/ocr', async (route) => {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', json: [] });
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
||||
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
||||
const match = request.url().match(pattern);
|
||||
if (!match?.groups) {
|
||||
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
|
||||
}
|
||||
|
||||
if (match.groups.size === 'preview') {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
const asset = getAsset(timelineRestData, match.groups.assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
|
||||
body: await randomPreview(
|
||||
match.groups.assetId,
|
||||
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (match.groups.size === 'thumbnail') {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
const asset = getAsset(timelineRestData, match.groups.assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: await randomThumbnail(
|
||||
match.groups.assetId,
|
||||
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/video/playback*', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: MINIMAL_MP4_BUFFER,
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/albums/**', async (route, request) => {
|
||||
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
|
||||
if (albumsMatch) {
|
||||
const album = getAlbum(timelineRestData, testContext.adminId, albumsMatch.groups?.albumId, changes);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: album,
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
await context.route('**/api/albums**', async (route, request) => {
|
||||
const allAlbums = request.url().match(/\/api\/albums\?assetId=(?<assetId>[^&]+)/);
|
||||
if (allAlbums) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: [],
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
};
|
||||
|
||||
export const pageRoutePromise = async (
|
||||
page: Page,
|
||||
route: string,
|
||||
callback: (route: Route, request: Request) => Promise<void>,
|
||||
) => {
|
||||
let resolveRequest: ((value: unknown | PromiseLike<unknown>) => void) | undefined;
|
||||
const deleteRequest = new Promise((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
await page.route(route, async (route, request) => {
|
||||
await callback(route, request);
|
||||
const requestJson = request.postDataJSON();
|
||||
resolveRequest?.(requestJson);
|
||||
});
|
||||
return deleteRequest;
|
||||
};
|
||||
@@ -1,269 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
SeededRandom,
|
||||
selectRandom,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network.js';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network.js';
|
||||
import { utils } from 'src/utils.js';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('asset-viewer', () => {
|
||||
const rng = new SeededRandom(529);
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const yearMonths: string[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
for (const yearMonth of timelineRestData.buckets.keys()) {
|
||||
const [year, month] = yearMonth.split('-', 2);
|
||||
yearMonths.push(`${year}-${Number(month)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
});
|
||||
|
||||
test.describe('/photos/:id', () => {
|
||||
test('Navigate to next asset via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate to previous asset via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.getByLabel('View previous asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate to next asset via keyboard (ArrowRight)', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate to previous asset via keyboard (ArrowLeft)', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate forward 5 times via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + i].id}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Navigate backward 5 times via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await page.getByLabel('View previous asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - i]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - i].id}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Navigate forward then backward via keyboard', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
|
||||
// Navigate forward 3 times
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||
}
|
||||
|
||||
// Navigate backward 3 times to return to original
|
||||
for (let i = 2; i >= 0; i--) {
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||
}
|
||||
|
||||
// Verify we're back at the original asset
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
});
|
||||
|
||||
test('Verify no next button on last asset', async ({ page }) => {
|
||||
const lastAsset = assets.at(-1)!;
|
||||
await page.goto(`/photos/${lastAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
|
||||
|
||||
// Verify next button doesn't exist
|
||||
await expect(page.getByLabel('View next asset')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Verify no previous button on first asset', async ({ page }) => {
|
||||
const firstAsset = assets[0];
|
||||
await page.goto(`/photos/${firstAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, firstAsset);
|
||||
|
||||
// Verify previous button doesn't exist
|
||||
await expect(page.getByLabel('View previous asset')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Delete photo advances to next', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
});
|
||||
test('Delete photo advances to next (2x)', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
|
||||
});
|
||||
test('Delete last photo advances to prev', async ({ page }) => {
|
||||
const asset = assets.at(-1)!;
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
});
|
||||
test('Delete last photo advances to prev (2x)', async ({ page }) => {
|
||||
const asset = assets.at(-1)!;
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]);
|
||||
});
|
||||
});
|
||||
test.describe('/trash/photos/:id', () => {
|
||||
test('Delete trashed photo advances to next', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
});
|
||||
test('Delete trashed photo advances to next 2x', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
|
||||
});
|
||||
test('Delete trashed photo advances to prev', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${assets[index + 9].id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
|
||||
});
|
||||
test('Delete trashed photo advances to prev 2x', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${assets[index + 9].id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { toAssetResponseDto } from 'src/ui/generators/timeline.js';
|
||||
import {
|
||||
createMockStack,
|
||||
createMockStackAsset,
|
||||
MockStack,
|
||||
setupBrokenAssetMockApiRoutes,
|
||||
} from 'src/ui/mock-network/broken-asset-network';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { setupAssetViewerFixture } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('broken-asset responsiveness', () => {
|
||||
const fixture = setupAssetViewerFixture(889);
|
||||
let mockStack: MockStack;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
|
||||
const brokenAssets = [
|
||||
createMockStackAsset(fixture.adminUserId),
|
||||
createMockStackAsset(fixture.adminUserId),
|
||||
createMockStackAsset(fixture.adminUserId),
|
||||
];
|
||||
|
||||
mockStack = createMockStack(primaryAssetDto, brokenAssets);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
||||
});
|
||||
|
||||
test('broken asset in stack strip hides icon at small size', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const stackSlideshow = page.locator('#stack-slideshow');
|
||||
await expect(stackSlideshow).toBeVisible();
|
||||
|
||||
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
|
||||
await expect(brokenAssets.first()).toBeVisible();
|
||||
await expect(brokenAssets).toHaveCount(mockStack.brokenAssetIds.size);
|
||||
|
||||
for (const brokenAsset of await brokenAssets.all()) {
|
||||
await expect(brokenAsset.locator('svg')).not.toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('broken asset in stack strip uses text-xs class', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const stackSlideshow = page.locator('#stack-slideshow');
|
||||
await expect(stackSlideshow).toBeVisible();
|
||||
|
||||
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
|
||||
await expect(brokenAssets.first()).toBeVisible();
|
||||
|
||||
for (const brokenAsset of await brokenAssets.all()) {
|
||||
const messageSpan = brokenAsset.locator('span');
|
||||
await expect(messageSpan).toHaveClass(/text-xs/);
|
||||
}
|
||||
});
|
||||
|
||||
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
|
||||
await context.route(
|
||||
(url) =>
|
||||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) ||
|
||||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`),
|
||||
async (route) => {
|
||||
return route.fulfill({ status: 404 });
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first();
|
||||
await expect(viewerBrokenAsset).toBeVisible();
|
||||
|
||||
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
||||
|
||||
const messageSpan = viewerBrokenAsset.locator('span');
|
||||
await expect(messageSpan).toHaveClass(/text-base/);
|
||||
});
|
||||
});
|
||||
@@ -1,285 +0,0 @@
|
||||
import { expect, Page, test } from '@playwright/test';
|
||||
import { SeededRandom, selectRandom, TimelineAssetConfig } from 'src/ui/generators/timeline.js';
|
||||
import {
|
||||
createMockPeople,
|
||||
FaceCreateCapture,
|
||||
MockPerson,
|
||||
setupFaceEditorMockApiRoutes,
|
||||
} from 'src/ui/mock-network/face-editor-network';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { setupAssetViewerFixture } from './utils';
|
||||
|
||||
const waitForSelectorTransition = async (page: Page) => {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const selector = document.querySelector('#face-selector') as HTMLElement | null;
|
||||
if (!selector) {
|
||||
return false;
|
||||
}
|
||||
return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished');
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 1000, polling: 50 },
|
||||
);
|
||||
};
|
||||
|
||||
const openFaceEditor = async (page: Page, asset: TimelineAssetConfig) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.keyboard.press('i');
|
||||
await page.locator('#detail-panel').waitFor({ state: 'visible' });
|
||||
await page.getByLabel('Tag people').click();
|
||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
||||
await waitForSelectorTransition(page);
|
||||
};
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('face-editor', () => {
|
||||
const fixture = setupAssetViewerFixture(777);
|
||||
const rng = new SeededRandom(777);
|
||||
let mockPeople: MockPerson[];
|
||||
let faceCreateCapture: FaceCreateCapture;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
mockPeople = createMockPeople(8);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
faceCreateCapture = { requests: [] };
|
||||
await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture);
|
||||
});
|
||||
|
||||
type ScreenRect = { top: number; left: number; width: number; height: number };
|
||||
|
||||
const getFaceBoxRect = async (page: Page): Promise<ScreenRect> => {
|
||||
const dataEl = page.locator('#face-editor-data');
|
||||
await expect(dataEl).toHaveAttribute('data-face-left', /^-?\d+/);
|
||||
await expect(dataEl).toHaveAttribute('data-face-top', /^-?\d+/);
|
||||
await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/);
|
||||
await expect(dataEl).toHaveAttribute('data-face-height', /^[1-9]/);
|
||||
const canvasBox = await page.locator('#face-editor').boundingBox();
|
||||
if (!canvasBox) {
|
||||
throw new Error('Canvas element not found');
|
||||
}
|
||||
const left = Number(await dataEl.getAttribute('data-face-left'));
|
||||
const top = Number(await dataEl.getAttribute('data-face-top'));
|
||||
const width = Number(await dataEl.getAttribute('data-face-width'));
|
||||
const height = Number(await dataEl.getAttribute('data-face-height'));
|
||||
return {
|
||||
top: canvasBox.y + top,
|
||||
left: canvasBox.x + left,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
|
||||
const getSelectorRect = async (page: Page): Promise<ScreenRect> => {
|
||||
const box = await page.locator('#face-selector').boundingBox();
|
||||
if (!box) {
|
||||
throw new Error('Face selector element not found');
|
||||
}
|
||||
return { top: box.y, left: box.x, width: box.width, height: box.height };
|
||||
};
|
||||
|
||||
const computeOverlapArea = (a: ScreenRect, b: ScreenRect): number => {
|
||||
const overlapX = Math.max(0, Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left));
|
||||
const overlapY = Math.max(0, Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top));
|
||||
return overlapX * overlapY;
|
||||
};
|
||||
|
||||
const dragFaceBox = async (page: Page, deltaX: number, deltaY: number) => {
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const centerX = faceBox.left + faceBox.width / 2;
|
||||
const centerY = faceBox.top + faceBox.height / 2;
|
||||
await page.mouse.move(centerX, centerY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(300);
|
||||
};
|
||||
|
||||
test('Face editor opens with person list', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeVisible();
|
||||
await expect(page.locator('#face-editor')).toBeVisible();
|
||||
|
||||
for (const person of mockPeople) {
|
||||
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Search filters people by name', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const searchInput = page.locator('#face-selector input');
|
||||
await searchInput.fill('Alice');
|
||||
|
||||
await expect(page.locator('#face-selector').getByText('Alice Johnson')).toBeVisible();
|
||||
await expect(page.locator('#face-selector').getByText('Bob Smith')).toBeHidden();
|
||||
|
||||
await searchInput.clear();
|
||||
|
||||
for (const person of mockPeople) {
|
||||
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Search with no results shows empty message', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const searchInput = page.locator('#face-selector input');
|
||||
await searchInput.fill('Nonexistent Person XYZ');
|
||||
|
||||
for (const person of mockPeople) {
|
||||
await expect(page.locator('#face-selector').getByText(person.name)).toBeHidden();
|
||||
}
|
||||
});
|
||||
|
||||
test('Selecting a person shows confirmation dialog', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const personToTag = mockPeople[0];
|
||||
await page.locator('#face-selector').getByText(personToTag.name).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Confirming tag calls createFace API and closes editor', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const personToTag = mockPeople[0];
|
||||
await page.locator('#face-selector').getByText(personToTag.name).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.getByRole('button', { name: /confirm/i }).click();
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeHidden();
|
||||
await expect(page.locator('#face-editor')).toBeHidden();
|
||||
|
||||
expect(faceCreateCapture.requests).toHaveLength(1);
|
||||
expect(faceCreateCapture.requests[0].assetId).toBe(asset.id);
|
||||
expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id);
|
||||
});
|
||||
|
||||
test('Cancel button closes face editor', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeVisible();
|
||||
await expect(page.locator('#face-editor')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeHidden();
|
||||
await expect(page.locator('#face-editor')).toBeHidden();
|
||||
});
|
||||
|
||||
test('Selector does not overlap face box on initial open', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector repositions without overlap after dragging face box down', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, 0, 150);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector repositions without overlap after dragging face box right', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, 200, 0);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector repositions without overlap after dragging face box to top-left corner', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, -300, -300);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector repositions without overlap after dragging face box to bottom-right', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, 300, 300);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector stays within viewport bounds', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const viewportSize = page.viewportSize()!;
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
|
||||
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
|
||||
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
|
||||
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
|
||||
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
|
||||
});
|
||||
|
||||
test('Selector stays within viewport after dragging to edge', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, -400, -400);
|
||||
|
||||
const viewportSize = page.viewportSize()!;
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
|
||||
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
|
||||
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
|
||||
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
|
||||
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
|
||||
});
|
||||
|
||||
test('Face box is draggable on the canvas', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const beforeDrag = await getFaceBoxRect(page);
|
||||
await dragFaceBox(page, 100, 50);
|
||||
const afterDrag = await getFaceBoxRect(page);
|
||||
|
||||
expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50);
|
||||
expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20);
|
||||
});
|
||||
});
|
||||
@@ -1,300 +0,0 @@
|
||||
import type { AssetOcrResponseDto, AssetResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { toAssetResponseDto } from 'src/ui/generators/timeline.js';
|
||||
import {
|
||||
createMockStack,
|
||||
createMockStackAsset,
|
||||
MockStack,
|
||||
setupBrokenAssetMockApiRoutes,
|
||||
} from 'src/ui/mock-network/broken-asset-network';
|
||||
import { createMockOcrData, setupOcrMockApiRoutes } from 'src/ui/mock-network/ocr-network.js';
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { toAssetResponseDto } from 'src/ui/generators/timeline.js';
|
||||
import {
|
||||
createMockStack,
|
||||
createMockStackAsset,
|
||||
MockStack,
|
||||
setupBrokenAssetMockApiRoutes,
|
||||
} from 'src/ui/mock-network/broken-asset-network';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { enableTagsPreference, ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('asset-viewer stack', () => {
|
||||
const fixture = setupAssetViewerFixture(888);
|
||||
let mockStack: MockStack;
|
||||
let primaryAssetDto: AssetResponseDto;
|
||||
let secondAssetDto: AssetResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
primaryAssetDto.tags = [
|
||||
{
|
||||
id: faker.string.uuid(),
|
||||
name: '1',
|
||||
value: 'test/1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
secondAssetDto = createMockStackAsset(fixture.adminUserId);
|
||||
secondAssetDto.tags = [
|
||||
{
|
||||
id: faker.string.uuid(),
|
||||
name: '2',
|
||||
value: 'test/2',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
||||
});
|
||||
|
||||
test('stack slideshow is visible', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const stackSlideshow = page.locator('#stack-slideshow');
|
||||
await expect(stackSlideshow).toBeVisible();
|
||||
|
||||
const stackAssets = stackSlideshow.locator('[data-asset]');
|
||||
await expect(stackAssets).toHaveCount(mockStack.assets.length);
|
||||
});
|
||||
|
||||
test('tags of primary asset are visible', async ({ context, page }) => {
|
||||
await enableTagsPreference(context);
|
||||
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||
await expect(tags.first()).toHaveText('test/1');
|
||||
});
|
||||
|
||||
test('tags of second asset are visible', async ({ context, page }) => {
|
||||
await enableTagsPreference(context);
|
||||
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
||||
await stackAssets.nth(1).click();
|
||||
|
||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||
await expect(tags.first()).toHaveText('test/2');
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { BrowserContext, Page, test } from '@playwright/test';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
SeededRandom,
|
||||
selectRandom,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
toAssetResponseDto,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network.js';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network.js';
|
||||
import { utils } from 'src/utils.js';
|
||||
|
||||
export type AssetViewerTestFixture = {
|
||||
adminUserId: string;
|
||||
timelineRestData: TimelineData;
|
||||
assets: TimelineAssetConfig[];
|
||||
testContext: TimelineTestContext;
|
||||
changes: Changes;
|
||||
primaryAsset: TimelineAssetConfig;
|
||||
primaryAssetDto: AssetResponseDto;
|
||||
};
|
||||
|
||||
export function setupAssetViewerFixture(seed: number): AssetViewerTestFixture {
|
||||
const rng = new SeededRandom(seed);
|
||||
const testContext = new TimelineTestContext();
|
||||
|
||||
const fixture: AssetViewerTestFixture = {
|
||||
adminUserId: undefined!,
|
||||
timelineRestData: undefined!,
|
||||
assets: [],
|
||||
testContext,
|
||||
changes: {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
},
|
||||
primaryAsset: undefined!,
|
||||
primaryAssetDto: undefined!,
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.fail(
|
||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
|
||||
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
|
||||
);
|
||||
utils.initSdk();
|
||||
fixture.adminUserId = faker.string.uuid();
|
||||
testContext.adminId = fixture.adminUserId;
|
||||
fixture.timelineRestData = generateTimelineData({
|
||||
...createDefaultTimelineConfig(),
|
||||
ownerId: fixture.adminUserId,
|
||||
});
|
||||
for (const timeBucket of fixture.timelineRestData.buckets.values()) {
|
||||
fixture.assets.push(...timeBucket);
|
||||
}
|
||||
|
||||
fixture.primaryAsset = selectRandom(
|
||||
fixture.assets.filter((a) => a.isImage),
|
||||
rng,
|
||||
);
|
||||
fixture.primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, fixture.adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, fixture.timelineRestData, fixture.changes, fixture.testContext);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
fixture.testContext.slowBucket = false;
|
||||
fixture.changes.albumAdditions = [];
|
||||
fixture.changes.assetDeletions = [];
|
||||
fixture.changes.assetArchivals = [];
|
||||
fixture.changes.assetFavorites = [];
|
||||
});
|
||||
|
||||
return fixture;
|
||||
}
|
||||
|
||||
export async function ensureDetailPanelVisible(page: Page) {
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
const isVisible = await page.locator('#detail-panel').isVisible();
|
||||
if (!isVisible) {
|
||||
await page.keyboard.press('i');
|
||||
await page.waitForSelector('#detail-panel');
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableTagsPreference(context: BrowserContext) {
|
||||
await context.route('**/users/me/preferences', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
albums: { defaultAssetOrder: 'desc' },
|
||||
folders: { enabled: false, sidebarWeb: false },
|
||||
memories: { enabled: true, duration: 5 },
|
||||
people: { enabled: true, sidebarWeb: false },
|
||||
sharedLinks: { enabled: true, sidebarWeb: false },
|
||||
ratings: { enabled: false },
|
||||
tags: { enabled: true, sidebarWeb: false },
|
||||
emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true },
|
||||
download: { archiveSize: 4_294_967_296, includeEmbeddedVideos: false },
|
||||
purchase: { showSupportBadge: true, hideBuyButtonUntil: '2100-02-12T00:00:00.000Z' },
|
||||
cast: { gCastEnabled: false },
|
||||
recentlyAdded: { sidebarWeb: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { MemoryResponseDto } from '@immich/sdk';
|
||||
import { test } from '@playwright/test';
|
||||
import { generateMemoriesFromTimeline } from 'src/ui/generators/memory.js';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network.js';
|
||||
import { MemoryChanges, setupMemoryMockApiRoutes } from 'src/ui/mock-network/memory-network.js';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network.js';
|
||||
import { memoryAssetViewerUtils, memoryGalleryUtils, memoryViewerUtils } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
let memories: MemoryResponseDto[];
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
const memoryChanges: MemoryChanges = {
|
||||
memoryDeletions: [],
|
||||
assetRemovals: new Map(),
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
|
||||
timelineRestData = generateTimelineData({
|
||||
...createDefaultTimelineConfig(),
|
||||
ownerId: adminUserId,
|
||||
});
|
||||
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
|
||||
memories = generateMemoriesFromTimeline(
|
||||
assets,
|
||||
adminUserId,
|
||||
[
|
||||
{ year: 2024, assetCount: 3 },
|
||||
{ year: 2023, assetCount: 2 },
|
||||
{ year: 2022, assetCount: 4 },
|
||||
],
|
||||
42,
|
||||
);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
await setupMemoryMockApiRoutes(context, memories, memoryChanges);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
memoryChanges.memoryDeletions = [];
|
||||
memoryChanges.assetRemovals.clear();
|
||||
});
|
||||
|
||||
test.describe('Asset viewer navigation from gallery', () => {
|
||||
test('shows both prev/next buttons for middle asset within a memory', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const middleAsset = firstMemory.assets[1];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, middleAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, middleAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, middleAsset);
|
||||
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
});
|
||||
|
||||
test('shows next button when at last asset of first memory (next memory exists)', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const lastAssetOfFirstMemory = firstMemory.assets.at(-1)!;
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, lastAssetOfFirstMemory.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirstMemory.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirstMemory);
|
||||
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
});
|
||||
|
||||
test('shows prev button when at first asset of last memory (prev memory exists)', async ({ page }) => {
|
||||
const lastMemory = memories.at(-1)!;
|
||||
const firstAssetOfLastMemory = lastMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, lastMemory.id, firstAssetOfLastMemory.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfLastMemory.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfLastMemory);
|
||||
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
});
|
||||
|
||||
test('can navigate from last asset of memory to first asset of next memory', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const secondMemory = memories[1];
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, lastAssetOfFirst.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||
|
||||
await memoryAssetViewerUtils.clickNextButton(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||
|
||||
await memoryAssetViewerUtils.expectCurrentAssetId(page, firstAssetOfSecond.id);
|
||||
});
|
||||
|
||||
test('can navigate from first asset of memory to last asset of previous memory', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const secondMemory = memories[1];
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, secondMemory.id, firstAssetOfSecond.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||
|
||||
await memoryAssetViewerUtils.clickPreviousButton(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||
});
|
||||
|
||||
test('hides prev button at very first asset (first memory, first asset, no prev memory)', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const veryFirstAsset = firstMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, veryFirstAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, veryFirstAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, veryFirstAsset);
|
||||
|
||||
await memoryAssetViewerUtils.expectPreviousButtonNotVisible(page);
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
});
|
||||
|
||||
test('hides next button at very last asset (last memory, last asset, no next memory)', async ({ page }) => {
|
||||
const lastMemory = memories.at(-1)!;
|
||||
const veryLastAsset = lastMemory.assets.at(-1)!;
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, lastMemory.id, veryLastAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, veryLastAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, veryLastAsset);
|
||||
|
||||
await memoryAssetViewerUtils.expectNextButtonNotVisible(page);
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Keyboard navigation', () => {
|
||||
test('ArrowLeft navigates to previous asset across memory boundary', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const secondMemory = memories[1];
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, secondMemory.id, firstAssetOfSecond.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||
});
|
||||
|
||||
test('ArrowRight navigates to next asset across memory boundary', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const secondMemory = memories[1];
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, lastAssetOfFirst.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Memory Viewer - Single Asset Memory Edge Cases', () => {
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
let memories: MemoryResponseDto[];
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
const memoryChanges: MemoryChanges = {
|
||||
memoryDeletions: [],
|
||||
assetRemovals: new Map(),
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
|
||||
timelineRestData = generateTimelineData({
|
||||
...createDefaultTimelineConfig(),
|
||||
ownerId: adminUserId,
|
||||
});
|
||||
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
|
||||
memories = generateMemoriesFromTimeline(
|
||||
assets,
|
||||
adminUserId,
|
||||
[
|
||||
{ year: 2024, assetCount: 2 },
|
||||
{ year: 2023, assetCount: 1 },
|
||||
{ year: 2022, assetCount: 2 },
|
||||
],
|
||||
123,
|
||||
);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
await setupMemoryMockApiRoutes(context, memories, memoryChanges);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
memoryChanges.memoryDeletions = [];
|
||||
memoryChanges.assetRemovals.clear();
|
||||
});
|
||||
|
||||
test('single asset memory shows both prev/next when surrounded by other memories', async ({ page }) => {
|
||||
const singleAssetMemory = memories[1];
|
||||
const singleAsset = singleAssetMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, singleAssetMemory.id, singleAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, singleAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, singleAsset);
|
||||
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
/* eslint-disable unicorn/no-this-outside-of-class */
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { expect, Page } from '@playwright/test';
|
||||
|
||||
function getAssetIdFromUrl(url: URL): string | null {
|
||||
const pathMatch = url.pathname.match(/\/memories\/[^/]+\/photos\/([^/]+)/);
|
||||
if (pathMatch) {
|
||||
return pathMatch[1];
|
||||
}
|
||||
return url.searchParams.get('assetId');
|
||||
}
|
||||
|
||||
export const memoryViewerUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#memory-viewer');
|
||||
},
|
||||
|
||||
async waitForMemoryLoad(page: Page) {
|
||||
await expect(this.locator(page)).toBeVisible();
|
||||
await expect(page.locator('#memory-viewer img').first()).toBeVisible();
|
||||
},
|
||||
|
||||
async openMemoryPageWithAsset(page: Page, memoryId: string, assetId: string) {
|
||||
await page.goto(`/memories/${memoryId}?assetId=${assetId}`);
|
||||
await this.waitForMemoryLoad(page);
|
||||
},
|
||||
};
|
||||
|
||||
export const memoryGalleryUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#gallery-memory');
|
||||
},
|
||||
|
||||
thumbnailWithAssetId(page: Page, assetId: string) {
|
||||
return page.locator(`#gallery-memory [data-thumbnail-focus-container][data-asset="${assetId}"]`);
|
||||
},
|
||||
|
||||
async scrollToGallery(page: Page) {
|
||||
const showGalleryButton = page.getByLabel('Show gallery');
|
||||
if (await showGalleryButton.isVisible()) {
|
||||
await showGalleryButton.click();
|
||||
}
|
||||
await expect(this.locator(page)).toBeInViewport();
|
||||
},
|
||||
|
||||
async clickThumbnail(page: Page, assetId: string) {
|
||||
await this.scrollToGallery(page);
|
||||
await this.thumbnailWithAssetId(page, assetId).click();
|
||||
},
|
||||
|
||||
async getAllThumbnails(page: Page) {
|
||||
await this.scrollToGallery(page);
|
||||
return page.locator('#gallery-memory [data-thumbnail-focus-container]');
|
||||
},
|
||||
};
|
||||
|
||||
export const memoryAssetViewerUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#immich-asset-viewer');
|
||||
},
|
||||
|
||||
async waitForViewerOpen(page: Page) {
|
||||
await expect(this.locator(page)).toBeVisible();
|
||||
},
|
||||
|
||||
async waitForAssetLoad(page: Page, asset: AssetResponseDto) {
|
||||
const viewer = this.locator(page);
|
||||
const imgLocator = viewer.locator(`img[draggable="false"][src*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
|
||||
const videoLocator = viewer.locator(`video[poster*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
|
||||
|
||||
await imgLocator.or(videoLocator).waitFor({ timeout: 10_000 });
|
||||
},
|
||||
|
||||
nextButton(page: Page) {
|
||||
return page.getByLabel('View next asset');
|
||||
},
|
||||
|
||||
previousButton(page: Page) {
|
||||
return page.getByLabel('View previous asset');
|
||||
},
|
||||
|
||||
async expectNextButtonVisible(page: Page) {
|
||||
await expect(this.nextButton(page)).toBeVisible();
|
||||
},
|
||||
|
||||
async expectNextButtonNotVisible(page: Page) {
|
||||
await expect(this.nextButton(page)).toHaveCount(0);
|
||||
},
|
||||
|
||||
async expectPreviousButtonVisible(page: Page) {
|
||||
await expect(this.previousButton(page)).toBeVisible();
|
||||
},
|
||||
|
||||
async expectPreviousButtonNotVisible(page: Page) {
|
||||
await expect(this.previousButton(page)).toHaveCount(0);
|
||||
},
|
||||
|
||||
async clickNextButton(page: Page) {
|
||||
await this.nextButton(page).click();
|
||||
},
|
||||
|
||||
async clickPreviousButton(page: Page) {
|
||||
await this.previousButton(page).click();
|
||||
},
|
||||
|
||||
async closeViewer(page: Page) {
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(this.locator(page)).not.toBeVisible();
|
||||
},
|
||||
|
||||
getCurrentAssetId(page: Page): string | null {
|
||||
const url = new URL(page.url());
|
||||
return getAssetIdFromUrl(url);
|
||||
},
|
||||
|
||||
async expectCurrentAssetId(page: Page, expectedAssetId: string) {
|
||||
await expect.poll(() => this.getCurrentAssetId(page)).toBe(expectedAssetId);
|
||||
},
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
toAssetResponseDto,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network.js';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network.js';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
|
||||
const buildSearchUrl = (assetId: string) => {
|
||||
const searchQuery = encodeURIComponent(JSON.stringify({ originalFileName: 'test' }));
|
||||
return `/search/photos/${assetId}?query=${searchQuery}`;
|
||||
};
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('search gallery-viewer', () => {
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
|
||||
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();
|
||||
testContext.adminId = adminUserId;
|
||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
|
||||
await context.route('**/api/search/metadata', async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
const searchAssets = assets
|
||||
.slice(0, 5)
|
||||
.filter((asset) => !changes.assetDeletions.includes(asset.id))
|
||||
.map((asset) => toAssetResponseDto(asset));
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
albums: { total: 0, count: 0, items: [], facets: [] },
|
||||
assets: {
|
||||
total: searchAssets.length,
|
||||
count: searchAssets.length,
|
||||
items: searchAssets,
|
||||
facets: [],
|
||||
nextPage: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
});
|
||||
|
||||
test.describe('/search/photos/:id', () => {
|
||||
test('Deleting a photo advances to the next photo', async ({ page }) => {
|
||||
const asset = assets[0];
|
||||
await page.goto(buildSearchUrl(asset.id));
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[1]);
|
||||
});
|
||||
|
||||
test('Deleting two photos in a row advances to the next photo each time', async ({ page }) => {
|
||||
const asset = assets[0];
|
||||
await page.goto(buildSearchUrl(asset.id));
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[1]);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[2]);
|
||||
});
|
||||
|
||||
test('Navigating backward then deleting advances to the next photo', async ({ page }) => {
|
||||
const asset = assets[1];
|
||||
await page.goto(buildSearchUrl(asset.id));
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('View previous asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[0]);
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[2]);
|
||||
});
|
||||
|
||||
test('Deleting the last photo advances to the previous photo', async ({ page }) => {
|
||||
const lastAsset = assets[4];
|
||||
await page.goto(buildSearchUrl(lastAsset.id));
|
||||
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
|
||||
await expect(page.getByLabel('View next asset')).toHaveCount(0);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[3]);
|
||||
await expect(page.getByLabel('View previous asset')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,859 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DateTime } from 'luxon';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
getAsset,
|
||||
getMockAsset,
|
||||
SeededRandom,
|
||||
selectRandom,
|
||||
selectRandomMultiple,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network.js';
|
||||
import {
|
||||
pageRoutePromise,
|
||||
setupTimelineMockApiRoutes,
|
||||
TimelineTestContext,
|
||||
} from 'src/ui/mock-network/timeline-network';
|
||||
import { utils } from 'src/utils.js';
|
||||
import { assetViewerUtils, padYearMonth, pageUtils, poll, thumbnailUtils, timelineUtils } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('Timeline', () => {
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const yearMonths: string[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.fail(
|
||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
|
||||
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
|
||||
);
|
||||
utils.initSdk();
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
for (const yearMonth of timelineRestData.buckets.keys()) {
|
||||
const [year, month] = yearMonth.split('-', 2);
|
||||
yearMonths.push(`${year}-${Number(month)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
});
|
||||
|
||||
test.describe('/photos', () => {
|
||||
test('Open /photos', async ({ page }) => {
|
||||
await page.goto(`/photos`);
|
||||
await page.waitForSelector('#asset-grid');
|
||||
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
|
||||
});
|
||||
test('Deep link to last photo', async ({ page }) => {
|
||||
const lastAsset = assets.at(-1)!;
|
||||
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
|
||||
await thumbnailUtils.expectInViewport(page, lastAsset.id);
|
||||
});
|
||||
const rng = new SeededRandom(529);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
test('Deep link to random asset ' + i, async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
await pageUtils.deepLinkPhotosPage(page, asset.id);
|
||||
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
|
||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||
});
|
||||
}
|
||||
test('Open /photos, open asset-viewer, browser back', async ({ page }) => {
|
||||
const rng = new SeededRandom(22);
|
||||
const asset = selectRandom(assets, rng);
|
||||
await pageUtils.deepLinkPhotosPage(page, asset.id);
|
||||
const scrollTopBefore = await timelineUtils.getScrollTop(page);
|
||||
await thumbnailUtils.clickAssetId(page, asset.id);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.goBack();
|
||||
await timelineUtils.locator(page).waitFor();
|
||||
const scrollTopAfter = await timelineUtils.getScrollTop(page);
|
||||
expect(scrollTopAfter).toBe(scrollTopBefore);
|
||||
});
|
||||
test('Open /photos, open asset-viewer, next photo, browser back, back', async ({ page }) => {
|
||||
const rng = new SeededRandom(49);
|
||||
const asset = selectRandom(assets, rng);
|
||||
const assetIndex = assets.indexOf(asset);
|
||||
const nextAsset = assets[assetIndex + 1];
|
||||
await pageUtils.deepLinkPhotosPage(page, asset.id);
|
||||
const scrollTopBefore = await timelineUtils.getScrollTop(page);
|
||||
await thumbnailUtils.clickAssetId(page, asset.id);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, nextAsset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${nextAsset.id}`);
|
||||
await page.goBack();
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.goBack();
|
||||
await page.waitForURL('**/photos?at=*');
|
||||
const scrollTopAfter = await timelineUtils.getScrollTop(page);
|
||||
expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThan(5);
|
||||
});
|
||||
test('Open /photos, open asset-viewer, next photo 15x, backwardsArrow', async ({ page }) => {
|
||||
await pageUtils.deepLinkPhotosPage(page, assets[0].id);
|
||||
await thumbnailUtils.clickAssetId(page, assets[0].id);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[0]);
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[i]);
|
||||
}
|
||||
await page.getByLabel('Go back').click();
|
||||
await page.waitForURL('**/photos?at=*');
|
||||
await thumbnailUtils.expectInViewport(page, assets[15].id);
|
||||
await thumbnailUtils.expectBottomIsTimelineBottom(page, assets[15]!.id);
|
||||
});
|
||||
test('Open /photos, open asset-viewer, previous photo 15x, backwardsArrow', async ({ page }) => {
|
||||
const lastAsset = assets.at(-1)!;
|
||||
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||
await thumbnailUtils.clickAssetId(page, lastAsset.id);
|
||||
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
await page.getByLabel('View previous asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets.at(-1 - i)!);
|
||||
}
|
||||
await page.getByLabel('Go back').click();
|
||||
await page.waitForURL('**/photos?at=*');
|
||||
await thumbnailUtils.expectInViewport(page, assets.at(-1 - 15)!.id);
|
||||
await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(-1 - 15)!.id);
|
||||
});
|
||||
});
|
||||
test.describe('keyboard', () => {
|
||||
/**
|
||||
* This text tests keyboard nativation, and also ensures that the scroll-to-asset behavior
|
||||
* scrolls the minimum amount. That is, if you are navigating using right arrow (auto scrolling
|
||||
* as necessary downwards), then the asset should always be at the lowest row of the grid.
|
||||
*/
|
||||
test('Next/previous asset - ArrowRight/ArrowLeft', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
|
||||
const rightKey = 'ArrowRight';
|
||||
const leftKey = 'ArrowLeft';
|
||||
for (let i = 1; i < 15; i++) {
|
||||
await page.keyboard.press(rightKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
for (let i = 15; i <= 20; i++) {
|
||||
await page.keyboard.press(rightKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
expect(await thumbnailUtils.expectBottomIsTimelineBottom(page, assets.at(i)!.id));
|
||||
}
|
||||
// now test previous asset
|
||||
for (let i = 19; i >= 15; i--) {
|
||||
await page.keyboard.press(leftKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
for (let i = 14; i > 0; i--) {
|
||||
await page.keyboard.press(leftKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
expect(await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(i)!.id));
|
||||
}
|
||||
});
|
||||
test('Next/previous asset - Tab/Shift+Tab', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
|
||||
const rightKey = 'Tab';
|
||||
const leftKey = 'Shift+Tab';
|
||||
for (let i = 1; i < 15; i++) {
|
||||
await page.keyboard.press(rightKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
for (let i = 15; i <= 20; i++) {
|
||||
await page.keyboard.press(rightKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
// now test previous asset
|
||||
for (let i = 19; i >= 15; i--) {
|
||||
await page.keyboard.press(leftKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
for (let i = 14; i > 0; i--) {
|
||||
await page.keyboard.press(leftKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
});
|
||||
test('Next/previous day - d, Shift+D', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
let asset = assets[0];
|
||||
await timelineUtils.locator(page).hover();
|
||||
await page.keyboard.press('d');
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('d');
|
||||
const next = getMockAsset(asset, assets, 'next', 'day')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
|
||||
asset = next;
|
||||
}
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('Shift+D');
|
||||
const previous = getMockAsset(asset, assets, 'previous', 'day')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
|
||||
asset = previous;
|
||||
}
|
||||
});
|
||||
test('Next/previous month - m, Shift+M', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
let asset = assets[0];
|
||||
await timelineUtils.locator(page).hover();
|
||||
await page.keyboard.press('m');
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('m');
|
||||
const next = getMockAsset(asset, assets, 'next', 'month')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
|
||||
asset = next;
|
||||
}
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('Shift+M');
|
||||
const previous = getMockAsset(asset, assets, 'previous', 'month')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
|
||||
asset = previous;
|
||||
}
|
||||
});
|
||||
test('Next/previous year - y, Shift+Y', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
let asset = assets[0];
|
||||
await timelineUtils.locator(page).hover();
|
||||
await page.keyboard.press('y');
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('y');
|
||||
const next = getMockAsset(asset, assets, 'next', 'year')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
|
||||
asset = next;
|
||||
}
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('Shift+Y');
|
||||
const previous = getMockAsset(asset, assets, 'previous', 'year')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
|
||||
asset = previous;
|
||||
}
|
||||
});
|
||||
test('Navigate to time - g', async ({ page }) => {
|
||||
const rng = new SeededRandom(4782);
|
||||
await pageUtils.openPhotosPage(page);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const asset = selectRandom(assets, rng);
|
||||
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
test.describe('selection', () => {
|
||||
test('Select day, unselect day', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
|
||||
await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
|
||||
});
|
||||
test('Select asset, click asset to select', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await thumbnailUtils.withAssetId(page, assets[1].id).hover();
|
||||
await thumbnailUtils.selectButton(page, assets[1].id).click();
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
|
||||
// no need to hover, once selection is active
|
||||
await thumbnailUtils.clickAssetId(page, assets[2].id);
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(2);
|
||||
});
|
||||
test('Select asset, click unselect asset', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await thumbnailUtils.withAssetId(page, assets[1].id).hover();
|
||||
await thumbnailUtils.selectButton(page, assets[1].id).click();
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
|
||||
await thumbnailUtils.clickAssetId(page, assets[1].id);
|
||||
// the hover uses a checked button too, so just move mouse away
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
|
||||
});
|
||||
test('Select asset, shift-hover candidates, shift-click end', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
const asset = assets[0];
|
||||
await thumbnailUtils.withAssetId(page, asset.id).hover();
|
||||
await thumbnailUtils.selectButton(page, asset.id).click();
|
||||
await page.keyboard.down('Shift');
|
||||
await thumbnailUtils.withAssetId(page, assets[2].id).hover();
|
||||
await expect(
|
||||
thumbnailUtils.locator(page).locator('.absolute.top-0.size-full.bg-immich-primary.opacity-40'),
|
||||
).toHaveCount(3);
|
||||
await thumbnailUtils.selectButton(page, assets[2].id).click();
|
||||
await page.keyboard.up('Shift');
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(3);
|
||||
});
|
||||
test('Add multiple to selection - Select day, shift-click end', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await thumbnailUtils.withAssetId(page, assets[0].id).hover();
|
||||
await thumbnailUtils.selectButton(page, assets[0].id).click();
|
||||
await thumbnailUtils.clickAssetId(page, assets[2].id);
|
||||
await page.keyboard.down('Shift');
|
||||
await thumbnailUtils.clickAssetId(page, assets[4].id);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
|
||||
});
|
||||
});
|
||||
test.describe('scroll', () => {
|
||||
test('Open /photos, random click scrubber 20x', async ({ page }) => {
|
||||
test.slow();
|
||||
await pageUtils.openPhotosPage(page);
|
||||
const rng = new SeededRandom(6637);
|
||||
const selectedMonths = selectRandomMultiple(yearMonths, 20, rng);
|
||||
for (const month of selectedMonths) {
|
||||
await page.locator(`[data-segment-year-month="${month}"]`).click({ force: true });
|
||||
const visibleMockAssetsYearMonths = await poll(page, async () => {
|
||||
const assetIds = await thumbnailUtils.getAllInViewport(
|
||||
page,
|
||||
(assetId: string) => getYearMonth(assets, assetId) === month,
|
||||
);
|
||||
const visibleMockAssetsYearMonths: string[] = [];
|
||||
for (const assetId of assetIds!) {
|
||||
const yearMonth = getYearMonth(assets, assetId);
|
||||
visibleMockAssetsYearMonths.push(yearMonth);
|
||||
if (yearMonth === month) {
|
||||
return [yearMonth];
|
||||
}
|
||||
}
|
||||
});
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
expect(visibleMockAssetsYearMonths).toContain(month);
|
||||
}
|
||||
});
|
||||
test.skip('Deep link to last photo, scroll up', async ({ page }) => {
|
||||
const lastAsset = assets.at(-1)!;
|
||||
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||
|
||||
await timelineUtils.locator(page).hover();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await page.mouse.wheel(0, -100);
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
|
||||
await thumbnailUtils.expectInViewport(page, '14e5901f-fd7f-40c0-b186-4d7e7fc67968');
|
||||
});
|
||||
test.skip('Deep link to first bucket, scroll down', async ({ page }) => {
|
||||
const lastAsset = assets.at(0)!;
|
||||
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||
await timelineUtils.locator(page).hover();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await page.mouse.wheel(0, 100);
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
await thumbnailUtils.expectInViewport(page, 'b7983a13-4b4e-4950-a731-f2962d9a1555');
|
||||
});
|
||||
test('Deep link to last photo, drag scrubber to scroll up', async ({ page }) => {
|
||||
const lastAsset = assets.at(-1)!;
|
||||
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||
const lastMonth = yearMonths.at(-1);
|
||||
const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
|
||||
const lastScrubSegment = page.locator(`[data-segment-year-month="${lastMonth}"]`);
|
||||
const sourcebox = (await lastScrubSegment.boundingBox())!;
|
||||
const targetBox = (await firstScrubSegment.boundingBox())!;
|
||||
await firstScrubSegment.hover();
|
||||
const currentY = sourcebox.y;
|
||||
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, targetBox.y, { steps: 100 });
|
||||
await page.mouse.up();
|
||||
await thumbnailUtils.expectInViewport(page, assets[0].id);
|
||||
});
|
||||
test('Deep link to first bucket, drag scrubber to scroll down', async ({ page }) => {
|
||||
await pageUtils.deepLinkPhotosPage(page, assets[0].id);
|
||||
const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
|
||||
const sourcebox = (await firstScrubSegment.boundingBox())!;
|
||||
await firstScrubSegment.hover();
|
||||
const currentY = sourcebox.y;
|
||||
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
|
||||
await page.mouse.down();
|
||||
const height = page.viewportSize()?.height;
|
||||
expect(height).toBeDefined();
|
||||
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, height! - 10, {
|
||||
steps: 100,
|
||||
});
|
||||
await page.mouse.up();
|
||||
await thumbnailUtils.expectInViewport(page, assets.at(-1)!.id);
|
||||
});
|
||||
test('Buckets cancel on scroll', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
testContext.slowBucket = true;
|
||||
const failedUris: string[] = [];
|
||||
page.on('requestfailed', (request) => {
|
||||
failedUris.push(request.url());
|
||||
});
|
||||
const offscreenSegment = page.locator(`[data-segment-year-month="${yearMonths[12]}"]`);
|
||||
await offscreenSegment.click({ force: true });
|
||||
const lastSegment = page.locator(`[data-segment-year-month="${yearMonths.at(-1)!}"]`);
|
||||
await lastSegment.click({ force: true });
|
||||
const uris = await poll(page, async () => (failedUris.length > 0 ? failedUris : null));
|
||||
expect(uris).toEqual(expect.arrayContaining([expect.stringContaining(padYearMonth(yearMonths[12]!))]));
|
||||
});
|
||||
});
|
||||
test.describe('/albums', () => {
|
||||
test('Open album', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await thumbnailUtils.expectInViewport(page, album.assetIds[0]);
|
||||
});
|
||||
test('Deep link to last photo', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
const lastAsset = album.assetIds.at(-1);
|
||||
await pageUtils.deepLinkAlbumPage(page, album.id, lastAsset!);
|
||||
await thumbnailUtils.expectInViewport(page, album.assetIds.at(-1)!);
|
||||
await thumbnailUtils.expectBottomIsTimelineBottom(page, album.assetIds.at(-1)!);
|
||||
});
|
||||
test('Add photos to album pre-selects existing', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await page.getByLabel('Add photos').click();
|
||||
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
||||
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||
await thumbnailUtils.expectSelectedDisabled(page, asset.id);
|
||||
});
|
||||
test.skip('Add photos to album', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await page.locator('nav button[aria-label="Add photos"]').click();
|
||||
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
||||
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||
await thumbnailUtils.expectSelectedDisabled(page, asset.id);
|
||||
await pageUtils.selectDay(page, 'Tue, Feb 27, 2024');
|
||||
const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: requestJson.ids.map((id: string) => ({ id, success: true })),
|
||||
});
|
||||
changes.albumAdditions.push(...requestJson.ids);
|
||||
});
|
||||
await page.getByText('Add assets').click();
|
||||
await expect(put).resolves.toEqual({
|
||||
ids: [
|
||||
'c077ea7b-cfa1-45e4-8554-f86c00ee5658',
|
||||
'040fd762-dbbc-486d-a51a-2d84115e6229',
|
||||
'86af0b5f-79d3-4f75-bab3-3b61f6c72b23',
|
||||
],
|
||||
});
|
||||
const addedAsset = getAsset(timelineRestData, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658')!;
|
||||
await pageUtils.goToAsset(page, addedAsset.fileCreatedAt);
|
||||
await thumbnailUtils.expectInViewport(page, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658');
|
||||
await thumbnailUtils.expectInViewport(page, '040fd762-dbbc-486d-a51a-2d84115e6229');
|
||||
await thumbnailUtils.expectInViewport(page, '86af0b5f-79d3-4f75-bab3-3b61f6c72b23');
|
||||
});
|
||||
});
|
||||
test.describe('/trash', () => {
|
||||
test('open /photos, trash photo, open /trash, restore', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
const assetToTrash = assets[0];
|
||||
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
|
||||
await page.getByLabel('Menu').click();
|
||||
const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
changes.assetDeletions.push(...requestJson.ids);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: requestJson.ids.map((id: string) => ({ id, success: true })),
|
||||
});
|
||||
});
|
||||
await page.getByRole('menuitem').getByText('Delete').click();
|
||||
await expect(deleteRequest).resolves.toEqual({
|
||||
force: false,
|
||||
ids: [assetToTrash.id],
|
||||
});
|
||||
await page.getByRole('link', { name: 'Trash' }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
|
||||
const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: { count: requestJson.ids.length },
|
||||
});
|
||||
});
|
||||
await page.getByText('Restore').click();
|
||||
await expect(restoreRequest).resolves.toEqual({
|
||||
ids: [assetToTrash.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
|
||||
await page.getByRole('link', { name: 'Photos' }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||
});
|
||||
test('open album, trash photo, open /trash, restore', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
const assetToTrash = getAsset(timelineRestData, album.assetIds[0])!;
|
||||
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
|
||||
await page.getByLabel('Menu').click();
|
||||
const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
changes.assetDeletions.push(...requestJson.ids);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: requestJson.ids.map((id: string) => ({ id, success: true })),
|
||||
});
|
||||
});
|
||||
await page.getByRole('menuitem').getByText('Delete').click();
|
||||
await expect(deleteRequest).resolves.toEqual({
|
||||
force: false,
|
||||
ids: [assetToTrash.id],
|
||||
});
|
||||
await page.locator('#control-bar').getByLabel('Close').click();
|
||||
await page.getByRole('link', { name: 'Trash' }).click();
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
|
||||
const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: { count: requestJson.ids.length },
|
||||
});
|
||||
});
|
||||
await page.getByText('Restore').click();
|
||||
await expect(restoreRequest).resolves.toEqual({
|
||||
ids: [assetToTrash.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||
});
|
||||
});
|
||||
test.describe('/archive', () => {
|
||||
test('open /photos, archive photo, open /archive, unarchive', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
const assetToArchive = assets[0];
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
await page.getByLabel('Menu').click();
|
||||
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'archive') {
|
||||
return await route.continue();
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
changes.assetArchivals.push(...requestJson.ids);
|
||||
});
|
||||
await page.getByRole('menuitem').getByText('Archive').click();
|
||||
await expect(archive).resolves.toEqual({
|
||||
visibility: 'archive',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await page.getByRole('link').getByText('Archive').click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'timeline') {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Unarchive').click();
|
||||
await expect(unarchiveRequest).resolves.toEqual({
|
||||
visibility: 'timeline',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await page.getByRole('link', { name: 'Photos' }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
});
|
||||
test('open /archive, favorite photo, unfavorite', async ({ page }) => {
|
||||
const assetToFavorite = assets[0];
|
||||
changes.assetArchivals.push(assetToFavorite.id);
|
||||
await pageUtils.openArchivePage(page);
|
||||
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
const isFavorite = requestJson.isFavorite;
|
||||
if (isFavorite) {
|
||||
changes.assetFavorites.push(...requestJson.ids);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
await page.getByLabel('Favorite').click();
|
||||
await expect(favorite).resolves.toEqual({
|
||||
isFavorite: true,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Remove from favorites').click();
|
||||
await expect(unFavoriteRequest).resolves.toEqual({
|
||||
isFavorite: false,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
|
||||
await thumbnailUtils.expectThumbnailIsNotFavorite(page, assetToFavorite.id);
|
||||
});
|
||||
test('open album, archive photo, open album, unarchive', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
const assetToArchive = getAsset(timelineRestData, album.assetIds[0])!;
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
await page.getByLabel('Menu').click();
|
||||
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'archive') {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetArchivals.push(...requestJson.ids);
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByRole('menuitem').getByText('Archive').click();
|
||||
await expect(archive).resolves.toEqual({
|
||||
visibility: 'archive',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
|
||||
await page.locator('#control-bar').getByLabel('Close').click();
|
||||
await page.getByRole('link').getByText('Archive').click();
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'timeline') {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Unarchive').click();
|
||||
await expect(unarchiveRequest).resolves.toEqual({
|
||||
visibility: 'timeline',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
});
|
||||
});
|
||||
test.describe('/favorite', () => {
|
||||
test('open /photos, favorite photo, open /favorites, remove favorite, open /photos', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
const assetToFavorite = assets[0];
|
||||
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
const isFavorite = requestJson.isFavorite;
|
||||
if (isFavorite) {
|
||||
changes.assetFavorites.push(...requestJson.ids);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Favorite').click();
|
||||
await expect(favorite).resolves.toEqual({
|
||||
isFavorite: true,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
// ensure thumbnail still exists and has favorite icon
|
||||
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
||||
await page.getByRole('link').getByText('Favorites').click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Remove from favorites').click();
|
||||
await expect(unFavoriteRequest).resolves.toEqual({
|
||||
isFavorite: false,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(0);
|
||||
await page.getByRole('link', { name: 'Photos' }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
});
|
||||
test.skip('open /favorites, archive photo, unarchive photo', async ({ page }) => {
|
||||
await pageUtils.openFavorites(page);
|
||||
const assetToArchive = getAsset(timelineRestData, 'ad31e29f-2069-4574-b9a9-ad86523c92cb')!;
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
await page.getByLabel('Menu').click();
|
||||
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'archive') {
|
||||
return await route.continue();
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
changes.assetArchivals.push(...requestJson.ids);
|
||||
});
|
||||
await page.getByRole('menuitem').getByText('Archive').click();
|
||||
await expect(archive).resolves.toEqual({
|
||||
visibility: 'archive',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await page.getByRole('link').getByText('Archive').click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'timeline') {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Unarchive').click();
|
||||
await expect(unarchiveRequest).resolves.toEqual({
|
||||
visibility: 'timeline',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
|
||||
});
|
||||
test('Open album, favorite photo, open /favorites, remove favorite, Open album', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
const assetToFavorite = getAsset(timelineRestData, album.assetIds[0])!;
|
||||
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
const isFavorite = requestJson.isFavorite;
|
||||
if (isFavorite) {
|
||||
changes.assetFavorites.push(...requestJson.ids);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Favorite').click();
|
||||
await expect(favorite).resolves.toEqual({
|
||||
isFavorite: true,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
// ensure thumbnail still exists and has favorite icon
|
||||
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
||||
await page.locator('#control-bar').getByLabel('Close').click();
|
||||
await page.getByRole('link').getByText('Favorites').click();
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Remove from favorites').click();
|
||||
await expect(unFavoriteRequest).resolves.toEqual({
|
||||
isFavorite: false,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(0);
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const getYearMonth = (assets: TimelineAssetConfig[], assetId: string) => {
|
||||
const mockAsset = assets.find((mockAsset) => mockAsset.id === assetId)!;
|
||||
const dateTime = DateTime.fromISO(mockAsset.fileCreatedAt!);
|
||||
return dateTime.year + '-' + dateTime.month;
|
||||
};
|
||||
@@ -1,218 +0,0 @@
|
||||
import { expect, Page } from '@playwright/test';
|
||||
import { DateTime } from 'luxon';
|
||||
import { TimelineAssetConfig } from 'src/ui/generators/timeline.js';
|
||||
|
||||
export const sleep = (ms: number) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
export const padYearMonth = (yearMonth: string) => {
|
||||
const [year, month] = yearMonth.split('-', 2);
|
||||
return `${year}-${month.padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const poll = async <T>(
|
||||
page: Page,
|
||||
query: () => Promise<T>,
|
||||
callback?: (result: Awaited<T> | undefined) => boolean,
|
||||
) => {
|
||||
let result;
|
||||
const timeout = Date.now() + 10_000;
|
||||
|
||||
const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
|
||||
while (!terminate(result) && Date.now() < timeout) {
|
||||
try {
|
||||
result = await query();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await page.waitForTimeout(50);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!result) {
|
||||
// rerun to trigger error if any
|
||||
result = await query();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const thumbnailUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('[data-thumbnail-focus-container]');
|
||||
},
|
||||
withAssetId(page: Page, assetId: string) {
|
||||
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"]`);
|
||||
},
|
||||
selectButton(page: Page, assetId: string) {
|
||||
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button[role="checkbox"]`);
|
||||
},
|
||||
selectedAsset(page: Page) {
|
||||
return page.locator('[data-thumbnail-focus-container][data-selected]');
|
||||
},
|
||||
async clickAssetId(page: Page, assetId: string) {
|
||||
await thumbnailUtils.withAssetId(page, assetId).click();
|
||||
},
|
||||
async queryThumbnailInViewport(page: Page, collector: (assetId: string) => boolean) {
|
||||
const assetIds: string[] = [];
|
||||
// eslint-disable-next-line unicorn/no-this-outside-of-class
|
||||
for (const thumb of await this.locator(page).all()) {
|
||||
const box = await thumb.boundingBox();
|
||||
if (box) {
|
||||
const assetId = await thumb.evaluate((e) => e.dataset.asset);
|
||||
if (collector?.(assetId!)) {
|
||||
return [assetId!];
|
||||
}
|
||||
assetIds.push(assetId!);
|
||||
}
|
||||
}
|
||||
return assetIds;
|
||||
},
|
||||
async getFirstInViewport(page: Page) {
|
||||
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, () => true));
|
||||
},
|
||||
async getAllInViewport(page: Page, collector: (assetId: string) => boolean) {
|
||||
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, collector));
|
||||
},
|
||||
async expectThumbnailIsFavorite(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(1);
|
||||
},
|
||||
async expectThumbnailIsNotFavorite(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(0);
|
||||
},
|
||||
async expectThumbnailIsArchive(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(1);
|
||||
},
|
||||
async expectThumbnailIsNotArchive(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
|
||||
},
|
||||
async expectSelectedDisabled(page: Page, assetId: string) {
|
||||
await expect(
|
||||
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected][data-disabled]`),
|
||||
).toBeVisible();
|
||||
},
|
||||
async expectTimelineHasOnScreenAssets(page: Page) {
|
||||
const first = await thumbnailUtils.getFirstInViewport(page);
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
expect(first).toBeTruthy();
|
||||
},
|
||||
async expectInViewport(page: Page, assetId: string) {
|
||||
const box = await poll(page, () => thumbnailUtils.withAssetId(page, assetId).boundingBox());
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
expect(box).toBeTruthy();
|
||||
},
|
||||
async expectBottomIsTimelineBottom(page: Page, assetId: string) {
|
||||
const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
|
||||
const gridBox = await timelineUtils.locator(page).boundingBox();
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
expect(box!.y + box!.height).toBeCloseTo(gridBox!.y + gridBox!.height, 0);
|
||||
},
|
||||
async expectTopIsTimelineTop(page: Page, assetId: string) {
|
||||
const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
|
||||
const gridBox = await timelineUtils.locator(page).boundingBox();
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
expect(box!.y).toBeCloseTo(gridBox!.y, 0);
|
||||
},
|
||||
};
|
||||
export const timelineUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#asset-grid');
|
||||
},
|
||||
async waitForTimelineLoad(page: Page) {
|
||||
await expect(timelineUtils.locator(page)).toHaveCount(1);
|
||||
await expect(timelineUtils.locator(page)).toBeInViewport();
|
||||
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
|
||||
},
|
||||
async getScrollTop(page: Page) {
|
||||
const queryTop = () =>
|
||||
page.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
|
||||
return document.querySelector('#asset-grid').scrollTop;
|
||||
});
|
||||
await expect.poll(queryTop).toBeGreaterThan(0);
|
||||
return await queryTop();
|
||||
},
|
||||
};
|
||||
|
||||
export const assetViewerUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#immich-asset-viewer');
|
||||
},
|
||||
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||
await page
|
||||
.locator(
|
||||
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
|
||||
)
|
||||
.or(
|
||||
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
|
||||
)
|
||||
.waitFor();
|
||||
},
|
||||
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||
const activeElement = () =>
|
||||
page.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line unicorn/no-optional-chaining-on-undeclared-variable
|
||||
return document.activeElement?.dataset?.asset;
|
||||
});
|
||||
await expect(poll(page, activeElement, (result) => result === assetId)).resolves.toBe(assetId);
|
||||
},
|
||||
};
|
||||
export const pageUtils = {
|
||||
async deepLinkPhotosPage(page: Page, assetId: string) {
|
||||
await page.goto(`/photos?at=${assetId}`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openPhotosPage(page: Page) {
|
||||
await page.goto(`/photos`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openFavorites(page: Page) {
|
||||
await page.goto(`/favorites`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openAlbumPage(page: Page, albumId: string) {
|
||||
await page.goto(`/albums/${albumId}`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openArchivePage(page: Page) {
|
||||
await page.goto(`/archive`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) {
|
||||
await page.goto(`/albums/${albumId}?at=${assetId}`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async goToAsset(page: Page, assetDate: string) {
|
||||
await timelineUtils.locator(page).hover();
|
||||
const stringDate = DateTime.fromISO(assetDate).toFormat('MMddyyyy,hh:mm:ss.SSSa');
|
||||
await page.keyboard.press('g');
|
||||
await page.locator('#datetime').pressSequentially(stringDate);
|
||||
await page.getByText('Confirm').click();
|
||||
},
|
||||
async selectDay(page: Page, day: string) {
|
||||
const section = page.getByTitle(day).locator('xpath=ancestor::section[@data-group]');
|
||||
await section.hover();
|
||||
await section.locator('.w-8').click();
|
||||
},
|
||||
async pauseTestDebug() {
|
||||
console.log('NOTE: pausing test indefinitely for debug');
|
||||
await new Promise(() => void 0);
|
||||
},
|
||||
};
|
||||
+8
-5
@@ -31,6 +31,7 @@ import {
|
||||
createUserAdmin,
|
||||
deleteAssets,
|
||||
deleteDatabaseBackup,
|
||||
deleteLibrary,
|
||||
getAssetInfo,
|
||||
getConfig,
|
||||
getConfigDefaults,
|
||||
@@ -270,13 +271,13 @@ export const utils = {
|
||||
}
|
||||
},
|
||||
|
||||
waitForWebsocketEvent: ({ event, id, total: count, timeout: ms }: WaitOptions): Promise<void> => {
|
||||
waitForWebsocketEvent: ({ event, id, total: count, timeout: ms = 10_000 }: WaitOptions): Promise<void> => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!id && !count) {
|
||||
reject(new Error('id or count must be provided for waitForWebsocketEvent'));
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 10_000);
|
||||
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms);
|
||||
const type = id ? `id=${id}` : `count=${count}`;
|
||||
console.log(`Waiting for ${event} [${type}]`);
|
||||
const set = events[event];
|
||||
@@ -466,6 +467,8 @@ export const utils = {
|
||||
updateLibrary: (accessToken: string, id: string, dto: UpdateLibraryDto) =>
|
||||
updateLibrary({ id, updateLibraryDto: dto }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
deleteLibrary: (accessToken: string, id: string) => deleteLibrary({ id }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
createPartner: (accessToken: string, id: string) =>
|
||||
createPartner({ partnerCreateDto: { sharedWithId: id } }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
@@ -550,7 +553,7 @@ export const utils = {
|
||||
{
|
||||
headers: asBearerAuth(accessToken),
|
||||
fetch: (...args: Parameters<typeof fetch>) =>
|
||||
// eslint-disable-next-line unicorn/no-invalid-argument-count, unicorn/prefer-await
|
||||
// eslint-disable-next-line unicorn/prefer-await
|
||||
fetch(...args).then((response) => {
|
||||
setCookie = response.headers.getSetCookie();
|
||||
return response;
|
||||
@@ -660,10 +663,10 @@ export const utils = {
|
||||
return !jobCounts.active && (!jobCounts.waiting || queues[queue].queueStatus.isPaused);
|
||||
},
|
||||
|
||||
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => {
|
||||
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms: number = 15_000) => {
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise<void>(async (resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 15_000);
|
||||
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms);
|
||||
|
||||
while (true) {
|
||||
const done = await utils.isQueueEmpty(accessToken, queue);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"about": "Oor",
|
||||
"accept": "Aanvaar",
|
||||
"account": "Rekening",
|
||||
"account_settings": "Rekeninginstellings",
|
||||
"acknowledge": "Neem kennis",
|
||||
@@ -29,11 +30,13 @@
|
||||
"add_to_album_bottom_sheet_added": "Tot {album} toegevoeg",
|
||||
"add_to_album_bottom_sheet_already_exists": "Reeds in {album}",
|
||||
"add_to_album_bottom_sheet_some_local_assets": "Sommige plaaslike bates kon nie toe gevoeg word tot die album nie",
|
||||
"add_to_album_item_count": "Voeg {count, plural, one {# item} other {# items}} tot album toe",
|
||||
"add_to_albums": "Voeg toe tot albums",
|
||||
"add_to_albums_count": "Voeg toe tot albums ({count})",
|
||||
"add_to_bottom_bar": "Voeg toe",
|
||||
"add_upload_to_stack": "Voeg oplaai by stapel",
|
||||
"add_url": "Voeg bronadres toe",
|
||||
"add_user": "Voeg gebruiker toe",
|
||||
"added_to_archive": "Tot argief toegevoeg",
|
||||
"added_to_favorites": "Tot gunstelinge toegevoeg",
|
||||
"added_to_favorites_count": "{count, number} tot gunstelinge toegevoeg",
|
||||
|
||||
@@ -1210,7 +1210,6 @@
|
||||
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {city}، {country} مع {person1}، {person2}، و{person3} في {date}",
|
||||
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {city}, {country} with {person1}, {person2}, مع {additionalCount, number} آخرين في {date}",
|
||||
"immich_logo": "شعار immich",
|
||||
"immich_web_interface": "واجهة ويب immich",
|
||||
"import_from_json": "استيراد من JSON",
|
||||
"import_path": "مسار الاستيراد",
|
||||
"in_albums": "في {count, plural, one {# ألبوم } other {# ألبومات}}",
|
||||
@@ -1915,8 +1914,6 @@
|
||||
"setting_image_viewer_help": "يقوم عارض التفاصيل بتحميل الصورة المصغرة الصغيرة أولاً ، ثم يقوم بتحميل المعاينة متوسطة الحجم (إذا تم تمكينها) ، ويقوم أخيرًا بتحميل الأصل (إذا تم تمكينه).",
|
||||
"setting_image_viewer_original_subtitle": "تمكين تحميل الصورة الكاملة الدقة الأصلية (كبيرة!).تعطيل لتقليل استخدام البيانات (كل من الشبكة وعلى ذاكرة التخزين المؤقت للجهاز).",
|
||||
"setting_image_viewer_original_title": "تحميل الصورة الأصلية",
|
||||
"setting_image_viewer_preview_subtitle": "تمكين تحميل صورة متوسطة الدقة.تعطيل إما لتحميل مباشرة أو استخدام الصورة المصغرة مباشرة.",
|
||||
"setting_image_viewer_preview_title": "تحميل صورة معاينة",
|
||||
"setting_languages_apply": "تغيير الإعدادات",
|
||||
"setting_languages_subtitle": "تغيير لغة التطبيق",
|
||||
"setting_notifications_notify_minutes": "{count} دقائق",
|
||||
@@ -1982,7 +1979,6 @@
|
||||
"shared_link_password_description": "طلب كلمة مرور للوصول إلى هذا الرابط المشترك",
|
||||
"shared_links": "روابط مشتركة",
|
||||
"shared_links_description": "وصف الروابط المشتركة",
|
||||
"shared_photos_and_videos_count": "{assetCount, plural, other {# الصور ومقاطع الفيديو المُشارَكة.}}",
|
||||
"shared_with_me": "تمت مشاركتها معي",
|
||||
"shared_with_partner": "تمت المشاركة مع {partner}",
|
||||
"sharing": "مشاركة",
|
||||
|
||||
@@ -1212,7 +1212,6 @@
|
||||
"image_alt_text_date_place_3_people": "{isVideo, select, true {Відэа} other {Фота}} зробленае ў {city}, {country} з {person1}, {person2} і {person3} {date}",
|
||||
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Відэа} other {Фота}} зробленае ў {city}, {country} з {person1}, {person2} і яшчэ {additionalCount, number} інш. {date}",
|
||||
"immich_logo": "Лагатып Immich",
|
||||
"immich_web_interface": "Вэб-інтэрфейс Immich",
|
||||
"import_from_json": "Імпарт з JSON",
|
||||
"import_path": "Шлях імпарту",
|
||||
"in_albums": "У {count, plural, one {# альбоме} few {# альбомах} many {# альбомах} other {# альбома}}",
|
||||
@@ -1931,8 +1930,6 @@
|
||||
"setting_image_viewer_help": "Дэталёвы прагляд спачатку запампоўвае маленькую мініяцюру, потым папярэдні прагляд сярэдняга памеру (калі ўключана), і нарэшце арыгінал (калі ўключана).",
|
||||
"setting_image_viewer_original_subtitle": "Уключыце для загрузкі зыходнага відарыса ў поўнай раздзяляльнасці (вялікі!). Адключыце, каб зменшыць выкарыстанне трафіка (як сеткі, так і кэша прылады).",
|
||||
"setting_image_viewer_original_title": "Загружаць арыгінальны відарыс",
|
||||
"setting_image_viewer_preview_subtitle": "Уключыце для запампавання відарыса сярэдняй раздзяляльнасці. Адключыце, каб загружаць толькі арыгінал ці мініяцюру.",
|
||||
"setting_image_viewer_preview_title": "Загружаць відарыс перадпрагляду",
|
||||
"setting_languages_apply": "Ужыць",
|
||||
"setting_languages_subtitle": "Змяненне мовы праграмы",
|
||||
"setting_notifications_notify_minutes": "{count} хв",
|
||||
@@ -1998,7 +1995,6 @@
|
||||
"shared_link_password_description": "Патрабаваць пароль для доступу да гэтай абагуленай спасылкі",
|
||||
"shared_links": "Абагуленыя спасылкі",
|
||||
"shared_links_description": "Абагульвайце фота і відэа праз спасылку",
|
||||
"shared_photos_and_videos_count": "{assetCount, plural, one {# абагуленае фота і відэа.} few {# абагуленыя фота і відэа.} many {# абагуленых фота і відэа.} other {# абагуленага фота і відэа.}}",
|
||||
"shared_with_me": "Абагуленыя са мной",
|
||||
"shared_with_partner": "Абагулена з {partner}",
|
||||
"sharing": "Абагульванне",
|
||||
|
||||
+25
-6
@@ -323,6 +323,13 @@
|
||||
"oauth_timeout": "Време на изчакване при заявка",
|
||||
"oauth_timeout_description": "Време за изчакване на отговор на заявка, в милисекунди",
|
||||
"ocr_job_description": "Използване на машинно обучение за разпознаване на текст в изображенията",
|
||||
"passkey_additional_domains": "Допълнителни домейни",
|
||||
"passkey_additional_domains_description": "Допълнителни, доверени домейни (свързани заявки за произход). Те ще работят само ако домейнът с ключ за достъп също е достъпен.",
|
||||
"passkey_domain": "Домейн с ключ за достъп",
|
||||
"passkey_domain_description": "Домейн, използван за пароли (доверяваща се страна). Оставете празно, за да се прави извод от заглавията на оригинала. Промяната на тази стойност ще направи съществуващите пароли да не работят.",
|
||||
"passkey_enable_description": "Влизане с парола",
|
||||
"passkey_settings": "Вход с парола",
|
||||
"passkey_settings_description": "Управление на настройки за влизане с парола",
|
||||
"password_enable_description": "Влизане с имейл и парола",
|
||||
"password_settings": "Вписване с парола",
|
||||
"password_settings_description": "Управление на настройките за влизане с парола",
|
||||
@@ -671,6 +678,7 @@
|
||||
"birthdate_set_description": "Датата на раждане се използва за изчисляване на възрастта на този човек към момента на снимката.",
|
||||
"birthday_memory_age": "{age, selectordinal, one {#ви} two {#ри} few {#ти} other {#ти}} рожден ден",
|
||||
"birthday_memory_title": "Ражден ден на {name}",
|
||||
"birthday_memory_title_with_age": "Рожден ден на {name} · {age, plural, one {# година} other {# години}}",
|
||||
"blurred_background": "Замъглен заден фон",
|
||||
"browse_templates": "Разглеждане на шаблони",
|
||||
"bugs_and_feature_requests": "Бъгове и заявки за функции",
|
||||
@@ -872,6 +880,8 @@
|
||||
"delete_local_dialog_ok_backed_up_only": "Изтрий локално само архивираните",
|
||||
"delete_local_dialog_ok_force": "Въпреки това изтрий",
|
||||
"delete_others": "Изтрий останалите",
|
||||
"delete_passkey": "Премахване на парола",
|
||||
"delete_passkey_prompt": "Наистина ли искате да премахнете паролата?",
|
||||
"delete_permanently": "Изтрий за постоянно",
|
||||
"delete_permanently_action_prompt": "{count} изтрити за постоянно",
|
||||
"delete_shared_link": "Изтриване на споделен линк",
|
||||
@@ -941,6 +951,7 @@
|
||||
"edit_location_action_prompt": "{count} локации са редактирани",
|
||||
"edit_location_dialog_title": "Местоположение",
|
||||
"edit_name": "Редактиране на име",
|
||||
"edit_passkey": "Смяна на парола",
|
||||
"edit_people": "Редактиране на хора",
|
||||
"edit_person": "Редактиране на данни за човек",
|
||||
"edit_tag": "Редактиране на етикет",
|
||||
@@ -1238,7 +1249,6 @@
|
||||
"image_alt_text_date_place_3_people": "{isVideo, select, true {Видео} other {Снимка}} заснето с {city}, {country} и {person1}, {person2}, и {person3} на {date}",
|
||||
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Видео} other {Снимка}} заснето в {city}, {country} с {person1}, {person2}, и {additionalCount, number} други на{date}",
|
||||
"immich_logo": "Immich лого",
|
||||
"immich_web_interface": "Уеб интерфейс на Immich",
|
||||
"import_from_json": "Импортиране от JSON",
|
||||
"import_path": "Път за импортиране",
|
||||
"in_albums": "В {count, plural, one {# албум} other {# албума}}",
|
||||
@@ -1286,6 +1296,7 @@
|
||||
"last": "Последен",
|
||||
"last_months": "{count, plural, one {Последния месец} other {Последните # месеца}}",
|
||||
"last_seen": "Последно видяно",
|
||||
"last_used": "Последно използвана",
|
||||
"latest_version": "Последна версия",
|
||||
"latitude": "Ширина",
|
||||
"leave": "Излез",
|
||||
@@ -1361,6 +1372,7 @@
|
||||
"login_has_been_disabled": "Влизането е деактивирано",
|
||||
"login_password_changed_error": "Грешка при обновяване на паролата",
|
||||
"login_password_changed_success": "Успешно обновена парола",
|
||||
"login_with_passkey": "Влизане с паролa",
|
||||
"logout_all_device_confirmation": "Сигурни ли сте, че искате да излезете от всички устройства?",
|
||||
"logout_this_device_confirmation": "Сигурни ли сте, че искате да излезете от това устройство?",
|
||||
"logs": "Дневник",
|
||||
@@ -1400,6 +1412,7 @@
|
||||
"manage_media_access_title": "Управление на медийни файлове",
|
||||
"manage_oauth_account": "Управление на OAuth акаунт",
|
||||
"manage_people": "Управление на хора",
|
||||
"manage_people_access_description_v2": "Даване на достъп на други потребители до {count, plural, one {този човек} other {тези хора}}, като им разрешавате да виждат и управляват от ваше име. Това действие не споделя достъп до никакви ресурси.",
|
||||
"manage_person_access": "Управление на достъпа",
|
||||
"manage_sharing_with_other_users": "Управление на споделяне с други потребители",
|
||||
"manage_sharing_with_partners": "Управление на споделянето с партньори",
|
||||
@@ -1408,6 +1421,7 @@
|
||||
"manage_your_api_keys": "Управление на вашите API ключове",
|
||||
"manage_your_devices": "Управление на влезлите в системата устройства",
|
||||
"manage_your_oauth_connection": "Управление на OAuth връзката",
|
||||
"manage_your_passkeys": "Управлявайте вашите пароли",
|
||||
"map": "Карта",
|
||||
"map_assets_in_bounds": "{count, plural, =0 {Няма снимки в тази зона} one {# снимка} other {# снимки}}",
|
||||
"map_cannot_get_user_location": "Не можах да получа местоположението",
|
||||
@@ -1527,6 +1541,7 @@
|
||||
"new_album": "Нов Албум",
|
||||
"new_api_key": "Нов API ключ",
|
||||
"new_feature": "Нова функция",
|
||||
"new_passkey": "Нова паролa",
|
||||
"new_password": "Нова парола",
|
||||
"new_person": "Нов човек",
|
||||
"new_pin_code": "Нов PIN код",
|
||||
@@ -1639,6 +1654,8 @@
|
||||
"partner_sharing": "Споделяне с партньори",
|
||||
"partners": "Партньори",
|
||||
"partners_description": "Споделете своите снимки и видео с други потребители",
|
||||
"passkeys": "{count, plural, one {Парола} other {Пароли}}",
|
||||
"passkeys_not_supported": "Този браузър не поддържа пароли",
|
||||
"password": "Парола",
|
||||
"password_does_not_match": "Паролата не съвпада",
|
||||
"password_required": "Изисква се парола",
|
||||
@@ -1823,6 +1840,7 @@
|
||||
"removed_from_favorites_count": "{count, plural, other {Премахнати #}} от Любими",
|
||||
"removed_from_stack": "Активът е премахнат от групата",
|
||||
"removed_memory": "Премахнат спомен",
|
||||
"removed_passkey": "Премахната парола: {name}",
|
||||
"removed_tagged_assets": "Премахнат е етикетът от {count, plural, one {# елемент} other {# елемента}}",
|
||||
"rename": "Преименувай",
|
||||
"repository": "Хранилище",
|
||||
@@ -1984,11 +2002,9 @@
|
||||
"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_title": "Зареждане на оригинално изображение",
|
||||
"setting_image_viewer_preview_subtitle": "Разреши за да се зарежда изображение със средно качество (и размер). Забрани за да се зарежда само оригинал и миниатюра.",
|
||||
"setting_image_viewer_preview_title": "Зареждане на миниатюра",
|
||||
"setting_languages_apply": "Приложи",
|
||||
"setting_languages_subtitle": "Смени езика на приложението",
|
||||
"setting_notifications_notify_minutes": "{count} минути",
|
||||
@@ -2008,8 +2024,9 @@
|
||||
"share_everyone": "Споделете с всеки",
|
||||
"share_everyone_description": "Дайте на {user} достъп до всички ваши хора.",
|
||||
"share_link": "Връзка за споделяне",
|
||||
"share_logs": "Споделяне на дневници",
|
||||
"share_original": "Използвай оригинала (голям размер)",
|
||||
"share_preview": "Използвай миниатюра (намален размер)",
|
||||
"share_preview": "Използвай миниатюра (умален размер)",
|
||||
"share_quality_body": "Натиснете и задръжте бутона за споделяне, за да изберете качеството на изображението, преди да го споделите.",
|
||||
"share_quality_title": "Изберете качеството при споделяне",
|
||||
"share_with": "Споделете достъп с",
|
||||
@@ -2059,7 +2076,6 @@
|
||||
"shared_link_password_description": "Изискване на парола за достъп до споделения линк",
|
||||
"shared_links": "Споделени връзки",
|
||||
"shared_links_description": "Сподели снимки и видеа с линк",
|
||||
"shared_photos_and_videos_count": "{assetCount, plural, other {# споделени снимки и видеа.}}",
|
||||
"shared_with": "Споделено с",
|
||||
"shared_with_count": "Споделени ({count})",
|
||||
"shared_with_me": "Споделено с мен",
|
||||
@@ -2187,6 +2203,8 @@
|
||||
"theme": "Тема",
|
||||
"theme_selection": "Избор на тема",
|
||||
"theme_selection_description": "Автоматично задаване на светла или тъмна тема спрямо системните предпочитания на браузъра ви",
|
||||
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Превъртай непрекъснато при влачене по скролбара, вместо да скачаш по месеци",
|
||||
"theme_setting_asset_list_smooth_scrubbing_title": "Плавно превъртане на времевата линия",
|
||||
"theme_setting_asset_list_storage_indicator_title": "Показвай индикатор за хранилището в заглавията на обектите",
|
||||
"theme_setting_asset_list_tiles_per_row_title": "Брой обекти на ред ({count})",
|
||||
"theme_setting_colorful_interface_subtitle": "Нанеси основен цвят върху фоновите повърхности.",
|
||||
@@ -2338,6 +2356,7 @@
|
||||
"view_group": "Вижте група",
|
||||
"view_in_timeline": "Покажи във времева линия",
|
||||
"view_link": "Преглед на връзката",
|
||||
"view_more": "Вижте още",
|
||||
"view_name": "Прегледай",
|
||||
"view_next_asset": "Преглед на следващия файл",
|
||||
"view_previous_asset": "Преглед на предишния файл",
|
||||
|
||||
+6
-5
@@ -34,7 +34,7 @@
|
||||
"add_to_albums": "অ্যালবামে যোগ করুন",
|
||||
"add_to_albums_count": "অ্যালবামে যোগ করুন ({count})",
|
||||
"add_to_bottom_bar": "এ যোগ করুন",
|
||||
"add_upload_to_stack": "আপলোড স্ট্যাকে যোগ করুন",
|
||||
"add_upload_to_stack": "আপলোড করুন এবং {isStack, select, true {স্ট্যাকে যোগ করুন} other {স্ট্যাক তৈরি করুন}}",
|
||||
"add_url": "লিঙ্ক যোগ করুন",
|
||||
"add_user": "ব্যবহারকারী যোগ করুন",
|
||||
"added_to_archive": "আর্কাইভ এ যোগ করা হয়েছে",
|
||||
@@ -323,6 +323,11 @@
|
||||
"oauth_timeout": "রিকোয়েস্ট টাইম-আউট (Request Timeout)",
|
||||
"oauth_timeout_description": "মিলিসেকেন্ডে রিকোয়েস্টের টাইম-আউট (Timeout for requests in milliseconds)",
|
||||
"ocr_job_description": "ছবি থেকে টেক্সট শনাক্ত করতে মেশিন লার্নিং ব্যবহার করুন",
|
||||
"passkey_additional_domains": "অতিরিক্ত ডোমেইনসমূহ",
|
||||
"passkey_additional_domains_description": "অতিরিক্ত বিশ্বস্ত ডোমেইনসমূহ (সংশ্লিষ্ট উৎস থেকে আসা অনুরোধ)। পাসকি ডোমেইনটিও অ্যাক্সেসযোগ্য থাকলেই কেবল এগুলো কাজ করবে।",
|
||||
"passkey_domain": "পাসকি ডোমেইন",
|
||||
"passkey_domain_description": "পাসকির জন্য ব্যবহৃত ডোমেইন (রিলাইং পার্টি)। অরিজিন হেডার থেকে স্বয়ংক্রিয়ভাবে নির্ধারণ করতে চাইলে ফাঁকা রাখুন। এই মান পরিবর্তন করলে বিদ্যমান পাসকিগুলো আর কাজ করবে না।",
|
||||
"passkey_enable_description": "পাসকি দিয়ে লগইন করুন",
|
||||
"password_enable_description": "ইমেল এবং পাসওয়ার্ড দিয়ে লগইন করুন",
|
||||
"password_settings": "পাসওয়ার্ড লগইন (Password Login)",
|
||||
"password_settings_description": "পাসওয়ার্ড লগইন সেটিংস ম্যানেজ করুন",
|
||||
@@ -1220,7 +1225,6 @@
|
||||
"image_alt_text_date_place_3_people": "{city}, {country}-এ {person1}, {person2}, এবং {person3}-এর সাথে {date} তারিখে তোলা {isVideo, select, true {ভিডিও} other {ছবি}}",
|
||||
"image_alt_text_date_place_4_or_more_people": "{city}, {country}-এ {person1}, {person2}, এবং আরও {additionalCount, number} জনের সাথে {date} তারিখে তোলা {isVideo, select, true {ভিডিও} other {ছবি}}",
|
||||
"immich_logo": "ইমিচ লোগো",
|
||||
"immich_web_interface": "ইমিচ ওয়েব ইন্টারফেস",
|
||||
"import_from_json": "জেএসওএন থেকে আমদানি করুন",
|
||||
"import_path": "আমদানি পথ",
|
||||
"in_albums": "{count, plural, one {#টি অ্যালবামে} other {#টি অ্যালবামে}}",
|
||||
@@ -1949,8 +1953,6 @@
|
||||
"setting_image_viewer_help": "ডিটেইল ভিউয়ার প্রথমে ছোট থাম্বনেইল লোড করে, তারপর মাঝারি আকারের প্রিভিউ লোড করে (চালু থাকলে), অবশেষে মূল ছবি লোড করে (চালু থাকলে)।",
|
||||
"setting_image_viewer_original_subtitle": "মূল পূর্ণ-রেজোলিউশন ছবি লোড করতে চালু করুন (বড়!)। ডেটা ব্যবহার কমাতে (নেটওয়ার্ক ও ডিভাইস ক্যাশ উভয়ই) নিষ্ক্রিয় করুন।",
|
||||
"setting_image_viewer_original_title": "মূল ছবি লোড করুন",
|
||||
"setting_image_viewer_preview_subtitle": "মাঝারি-রেজোলিউশন ছবি লোড করতে চালু করুন। সরাসরি মূল ছবি লোড করতে বা শুধুমাত্র থাম্বনেইল ব্যবহার করতে নিষ্ক্রিয় করুন।",
|
||||
"setting_image_viewer_preview_title": "প্রিভিউ ছবি লোড করুন",
|
||||
"setting_languages_apply": "প্রয়োগ করুন",
|
||||
"setting_languages_subtitle": "অ্যাপের ভাষা পরিবর্তন করুন",
|
||||
"setting_notifications_notify_minutes": "{count} মিনিট",
|
||||
@@ -2016,7 +2018,6 @@
|
||||
"shared_link_password_description": "এই শেয়ার লিংক অ্যাক্সেস করতে পাসওয়ার্ড আবশ্যক করুন",
|
||||
"shared_links": "শেয়ার করা লিংক",
|
||||
"shared_links_description": "একটি লিংক দিয়ে ছবি ও ভিডিও শেয়ার করুন",
|
||||
"shared_photos_and_videos_count": "{assetCount, plural, other {#টি শেয়ার করা ছবি ও ভিডিও।}}",
|
||||
"shared_with_me": "আমার সাথে শেয়ার করা হয়েছে",
|
||||
"shared_with_partner": "{partner}-এর সাথে শেয়ার করা হয়েছে",
|
||||
"sharing": "শেয়ারিং",
|
||||
|
||||
+36
-17
@@ -86,7 +86,7 @@
|
||||
"failed_job_command": "L’ordre {command} ha fallat a la tasca: {job}",
|
||||
"force_delete_user_warning": "ADVERTÈNCIA: Aquesta acció suprimirà immediatament l’usuari i tots els recursos. Aquesta acció no es pot desfer i els fitxers no es podran recuperar.",
|
||||
"image_format": "Format",
|
||||
"image_format_description": "WebP genera fitxers més petits que JPEG, però es codifica més lentament.",
|
||||
"image_format_description": "WebP genera fitxers més petits que JPEG, però es codifica més lentament. JPEG XL és la millor opció per a una compressió d’alta qualitat, però no tots els navegadors i aplicacions el poden mostrar.",
|
||||
"image_fullsize_description": "Imatge de mida completa amb les metadades eliminades. S’utilitza en ampliar",
|
||||
"image_fullsize_enabled": "Activa la generació d’imatges de mida completa",
|
||||
"image_fullsize_enabled_description": "Genera imatges de mida completa per a formats no aptes per al web. Quan s’activa «Prefereix la previsualització incrustada», s’utilitzen directament previsualitzacions incrustades sense conversió. No afecta els formats aptes per al web com JPEG.",
|
||||
@@ -100,7 +100,7 @@
|
||||
"image_preview_quality_description": "Qualitat de la previsualització de l’1 al 100. Un valor més alt és millor, però produeix fitxers més grans i pot reduir la capacitat de resposta de l’aplicació. Establir-hi un valor baix pot afectar la qualitat de l’aprenentatge automàtic.",
|
||||
"image_preview_title": "Configuració de les previsualitzacions",
|
||||
"image_progressive": "Progressiu",
|
||||
"image_progressive_description": "Codifica les imatges JPEG en mode progressiu perquè es visualitzin amb una càrrega gradual. No té cap efecte sobre les imatges WebP.",
|
||||
"image_progressive_description": "Codifica les imatges JPEG en mode progressiu perquè es visualitzin amb una càrrega gradual. No té cap efecte sobre altres formats.",
|
||||
"image_quality": "Qualitat",
|
||||
"image_resolution": "Resolució",
|
||||
"image_resolution_description": "Les resolucions més altes poden conservar més detalls, però tarden més temps a codificar-se, tenen mides de fitxer més grans i poden reduir la capacitat de resposta de l’aplicació.",
|
||||
@@ -163,7 +163,7 @@
|
||||
"machine_learning_clip_model_description": "El nom d’un model CLIP que aparegui <link>aquí</link>. Tingueu en compte que si canvieu de model, cal que torneu a executar la tasca «Cerca intel·ligent» per a totes les imatges.",
|
||||
"machine_learning_duplicate_detection": "Detecció de duplicats",
|
||||
"machine_learning_duplicate_detection_enabled": "Activa la detecció de duplicats",
|
||||
"machine_learning_duplicate_detection_enabled_description": "Si està desactivada, els elements idèntics encara es desduplicaran.",
|
||||
"machine_learning_duplicate_detection_enabled_description": "Si està desactivada, els recursos idèntics encara es desduplicaran.",
|
||||
"machine_learning_duplicate_detection_setting_description": "Utilitza incrustacions CLIP per a trobar possibles duplicats",
|
||||
"machine_learning_enabled": "Activa l’aprenentatge automàtic",
|
||||
"machine_learning_enabled_description": "Si està desactivat, totes les funcions de ML es desactivaran, independentment de la configuració següent.",
|
||||
@@ -323,6 +323,13 @@
|
||||
"oauth_timeout": "Temps d’espera de les sol·licituds",
|
||||
"oauth_timeout_description": "Temps d’espera de les sol·licituds en mil·lisegons",
|
||||
"ocr_job_description": "Utilitza aprenentatge automàtic per a reconèixer text a les imatges",
|
||||
"passkey_additional_domains": "Dominis addicionals",
|
||||
"passkey_additional_domains_description": "Dominis addicionals i de confiança (peticions d’origen relacionades). Només funcionen si el domini de la clau d’accés també és accessible.",
|
||||
"passkey_domain": "Domini de la clau d’accés",
|
||||
"passkey_domain_description": "Domini utilitzat per a les claus d’accés («relying party»). Deixeu-lo en blanc per a deduir-lo de la capçalera d’origen. Canviar aquest valor farà que les claus d’accés existents deixin de funcionar.",
|
||||
"passkey_enable_description": "Inicia la sessió amb una clau d’accés",
|
||||
"passkey_settings": "Inici de sessió amb clau d’accés",
|
||||
"passkey_settings_description": "Gestiona la configuració de l’inici de sessió amb claus d’accés",
|
||||
"password_enable_description": "Inicia la sessió amb adreça electrònica i contrasenya",
|
||||
"password_settings": "Inici de sessió amb contrasenya",
|
||||
"password_settings_description": "Gestioneu els paràmetres de l’inici de sessió amb contrasenya",
|
||||
@@ -395,7 +402,7 @@
|
||||
"theme_settings": "Configuració del tema",
|
||||
"theme_settings_description": "Gestioneu la personalització de la interfície web de l’Immich",
|
||||
"thumbnail_generation_job": "Genera les miniatures",
|
||||
"thumbnail_generation_job_description": "Genera les miniatures grans, petites i borroses per a cada element, així com miniatures per a cada persona",
|
||||
"thumbnail_generation_job_description": "Genera les miniatures grans, petites i borroses per a cada recurs, així com miniatures per a cada persona",
|
||||
"transcoding_acceleration_api": "API d’acceleració",
|
||||
"transcoding_acceleration_api_description": "L’API que interactuarà amb el vostre dispositiu per a accelerar la transcodificació. Aquesta configuració és de l’estil «millor esforç»: en cas d’error, tornarà a la transcodificació per programari. El VP9 pot funcionar o no depenent del vostre maquinari.",
|
||||
"transcoding_acceleration_nvenc": "NVENC (requereix GPU d’NVIDIA)",
|
||||
@@ -506,7 +513,7 @@
|
||||
"advanced_settings_clear_image_cache_error": "No s’ha pogut esborrar la memòria cau de les imatges",
|
||||
"advanced_settings_clear_image_cache_success": "S’han esborrat correctament {size}",
|
||||
"advanced_settings_log_level_title": "Nivell del registre: {level}",
|
||||
"advanced_settings_prefer_remote_subtitle": "Alguns dispositius són molt lents en carregar miniatures dels elements locals. Activeu aquest paràmetre per a carregar imatges remotes en el seu lloc.",
|
||||
"advanced_settings_prefer_remote_subtitle": "Alguns dispositius són molt lents en carregar miniatures dels recursos locals. Activeu aquest paràmetre per a carregar imatges remotes en el seu lloc.",
|
||||
"advanced_settings_prefer_remote_title": "Prefereix les imatges remotes",
|
||||
"advanced_settings_proxy_headers_subtitle": "Definiu les capçaleres de servidor intermediari que l’Immich ha d’enviar amb cada sol·licitud de xarxa",
|
||||
"advanced_settings_proxy_headers_title": "Capçaleres de servidor intermediari personalitzades [EXPERIMENTAL]",
|
||||
@@ -615,11 +622,11 @@
|
||||
"assets_cannot_be_added_to_albums": "{count, plural, one {El recurs} other {Els recursos}} no es poden afegir a cap dels àlbums",
|
||||
"assets_count": "{count, plural, one {# recurs} other {# recursos}}",
|
||||
"assets_moved_to_trash_count": "{count, plural, one {S’ha mogut # recurs} other {S’han mogut # recursos}} a la paperera",
|
||||
"assets_permanently_deleted_count": "{count, plural, one {S’ha suprimit # recurs} other {S’ha suprimit # recursos}} permanentment",
|
||||
"assets_permanently_deleted_count": "{count, plural, one {S’ha suprimit # recurs} other {S’han suprimit # recursos}} permanentment",
|
||||
"assets_removed_count": "{count, plural, one {S’ha eliminat # recurs} other {S’han eliminat # recursos}}",
|
||||
"assets_restore_confirmation": "Segur que voleu restaurar tots els recursos de la paperera? Aquesta acció no es pot desfer! Tingueu en compte que els recursos fora de línia no es poden restaurar d’aquesta manera.",
|
||||
"assets_restored_count": "{count, plural, one {S’ha restaurat # recurs} other {S’han restaurat # recursos}}",
|
||||
"assets_trashed": "{count, plural, one {S’ha enviat # element} other {S’ha enviat # elements}} a la paperera",
|
||||
"assets_trashed": "{count, plural, one {S’ha enviat # recurs} other {S’han enviat # recursos}} a la paperera",
|
||||
"assets_trashed_count": "{count, plural, one {S’ha enviat # recurs} other {S’han enviat # recursos}} a la paperera",
|
||||
"assets_were_part_of_album_count": "{count, plural, one {El recurs ja era} other {Els recursos ja eren}} part de l’àlbum",
|
||||
"assets_were_part_of_albums_count": "{count, plural, one {El recurs ja era} other {Els recursos ja eren}} part dels àlbums",
|
||||
@@ -726,7 +733,7 @@
|
||||
"cleanup_found_assets": "S’han trobat {count} recursos amb còpia al servidor",
|
||||
"cleanup_found_assets_with_size": "S’han trobat {count} recursos amb còpia al servidor ({size})",
|
||||
"cleanup_icloud_shared_albums_excluded": "Els àlbums compartits de l’iCloud s’exclouen de l’escaneig",
|
||||
"cleanup_no_assets_found": "No s’han trobat recursos que coincideixin amb els criteris anteriors. L’alliberament d’espai només pot esborrar elements dels quals s’hagi fet còpia al servidor",
|
||||
"cleanup_no_assets_found": "No s’han trobat recursos que coincideixin amb els criteris anteriors. L’alliberament d’espai només pot esborrar recursos dels quals s’hagi fet còpia al servidor",
|
||||
"cleanup_preview_title": "Recursos a eliminar ({count})",
|
||||
"cleanup_step3_description": "Escaneja cercant recursos que ja tinguin una còpia al servidor que encaixin amb la data de tall i la configuració de manteniment.",
|
||||
"cleanup_step4_summary": "S’esborraran {count} recursos (creats abans del {date}) del dispositiu local. Les fotos romandran accessibles a l’aplicació Immich.",
|
||||
@@ -873,6 +880,8 @@
|
||||
"delete_local_dialog_ok_backed_up_only": "Suprimeix només els que tinguin còpia al servidor",
|
||||
"delete_local_dialog_ok_force": "Suprimeix igualment",
|
||||
"delete_others": "Suprimeix els altres",
|
||||
"delete_passkey": "Suprimeix la clau s’accés",
|
||||
"delete_passkey_prompt": "Segur que voleu suprimir aquesta clau d’accés?",
|
||||
"delete_permanently": "Suprimeix permanentment",
|
||||
"delete_permanently_action_prompt": "{count} suprimits permanentment",
|
||||
"delete_shared_link": "Suprimeix l’enllaç compartit",
|
||||
@@ -942,6 +951,7 @@
|
||||
"edit_location_action_prompt": "{count} ubicacions editades",
|
||||
"edit_location_dialog_title": "Ubicació",
|
||||
"edit_name": "Edita el nom",
|
||||
"edit_passkey": "Edita la clau d’accés",
|
||||
"edit_people": "Edita les persones",
|
||||
"edit_person": "Edita la persona",
|
||||
"edit_tag": "Edita l’etiqueta",
|
||||
@@ -1032,8 +1042,8 @@
|
||||
"unable_to_add_comment": "No s’ha pogut afegir el comentari",
|
||||
"unable_to_add_exclusion_pattern": "No s’ha pogut afegir el patró d’exclusió",
|
||||
"unable_to_add_partners": "No s’han pogut afegir companys",
|
||||
"unable_to_add_remove_archive": "No s’ha pogut {archived, select, true {eliminar l’element de} other {afegir l’element a}} l’arxiu",
|
||||
"unable_to_add_remove_favorites": "No s’ha pogut {favorite, select, true {afegir l’element als} other {eliminar l’element dels}} preferits",
|
||||
"unable_to_add_remove_archive": "No s’ha pogut {archived, select, true {eliminar el recurs de} other {afegir el recurs a}} l’arxiu",
|
||||
"unable_to_add_remove_favorites": "No s’ha pogut {favorite, select, true {afegir el recurs als} other {eliminar el recurs dels}} preferits",
|
||||
"unable_to_archive_unarchive": "No s’ha pogut {archived, select, true {arxivar} other {desarxivar}}",
|
||||
"unable_to_change_album_user_role": "No s’ha pogut canviar el rol de l’usuari de l’àlbum",
|
||||
"unable_to_change_date": "No s’ha pogut canviar la data",
|
||||
@@ -1239,7 +1249,6 @@
|
||||
"image_alt_text_date_place_3_people": "{isVideo, select, true {Vídeo enregistrat} other {Imatge capturada}} a {city}, {country} amb {person1}, {person2} i {person3} el {date}",
|
||||
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Vídeo enregistrat} other {Imatge capturada}} a {city}, {country} amb {person1}, {person2} i {additionalCount, number} més el {date}",
|
||||
"immich_logo": "Logo de l’Immich",
|
||||
"immich_web_interface": "Interfície web de l’Immich",
|
||||
"import_from_json": "Importa d’un JSON",
|
||||
"import_path": "Camí d’importació",
|
||||
"in_albums": "En {count, plural, one {# àlbum} other {# àlbums}}",
|
||||
@@ -1287,6 +1296,7 @@
|
||||
"last": "Darrer",
|
||||
"last_months": "{count, plural, one {Darrer mes} other {Darrers # mesos}}",
|
||||
"last_seen": "Vist per darrera vegada",
|
||||
"last_used": "Utilitzada per darrer cop",
|
||||
"latest_version": "Darrera versió",
|
||||
"latitude": "Latitud",
|
||||
"leave": "Surt",
|
||||
@@ -1362,6 +1372,7 @@
|
||||
"login_has_been_disabled": "S’ha desactivat l’inici de sessió",
|
||||
"login_password_changed_error": "S’ha produït un error en actualitzar la contrasenya",
|
||||
"login_password_changed_success": "La contrasenya s’ha canviat correctament",
|
||||
"login_with_passkey": "Inicia la sessió amb una clau d’accés",
|
||||
"logout_all_device_confirmation": "Segur que voleu tancar la sessió en tots els dispositius?",
|
||||
"logout_this_device_confirmation": "Segur que voleu tancar la sessió d’aquest dispositiu?",
|
||||
"logs": "Registres",
|
||||
@@ -1410,6 +1421,7 @@
|
||||
"manage_your_api_keys": "Gestioneu les vostres claus de l’API",
|
||||
"manage_your_devices": "Gestioneu els vostres dispositius connectats",
|
||||
"manage_your_oauth_connection": "Gestioneu la vostra connexió amb l’OAuth",
|
||||
"manage_your_passkeys": "Gestiona les meves claus d’accés",
|
||||
"map": "Mapa",
|
||||
"map_assets_in_bounds": "{count, plural, =0 {No hi ha cap foto en aquesta zona} one {# foto} other {# fotos}}",
|
||||
"map_cannot_get_user_location": "No s’ha pogut obtenir la ubicació de l’usuari",
|
||||
@@ -1529,6 +1541,7 @@
|
||||
"new_album": "Àlbum nou",
|
||||
"new_api_key": "Clau de l’API nova",
|
||||
"new_feature": "Funcionalitat nova",
|
||||
"new_passkey": "Clau d’accés nova",
|
||||
"new_password": "Contrasenya nova",
|
||||
"new_person": "Persona nova",
|
||||
"new_pin_code": "Codi PIN nou",
|
||||
@@ -1641,6 +1654,8 @@
|
||||
"partner_sharing": "Compartició amb companys",
|
||||
"partners": "Companys",
|
||||
"partners_description": "Compartiu les vostres fotos i vídeos amb altres usuaris",
|
||||
"passkeys": "{count, plural, one {Clau d’accés} other {Claus d’accés}}",
|
||||
"passkeys_not_supported": "Aquest navegador no admet les claus d’accés",
|
||||
"password": "Contrasenya",
|
||||
"password_does_not_match": "La contrasenya no coincideix",
|
||||
"password_required": "La contrasenya és obligatòria",
|
||||
@@ -1825,6 +1840,7 @@
|
||||
"removed_from_favorites_count": "{count, plural, other {# eliminats}} dels preferits",
|
||||
"removed_from_stack": "S’ha eliminat el recurs de la pila",
|
||||
"removed_memory": "S’ha eliminat el record",
|
||||
"removed_passkey": "S’ha suprimit la clau d’accés: {name}",
|
||||
"removed_tagged_assets": "S’ha eliminat l’etiqueta {count, plural, one {d’# recurs} other {de # recursos}}",
|
||||
"rename": "Canvia de nom",
|
||||
"repository": "Repositori",
|
||||
@@ -1986,11 +2002,9 @@
|
||||
"setting_image_navigation_enable_subtitle": "Si està activat, podeu navegar a la imatge anterior/següent tocant la quarta part més a l’esquerra/dreta de la pantalla.",
|
||||
"setting_image_navigation_enable_title": "Toqueu per a navegar",
|
||||
"setting_image_navigation_title": "Navegació per les imatges",
|
||||
"setting_image_viewer_help": "El visualitzador de detalls carrega primer la miniatura petita, després carrega la previsualització de mida mitjana (si està activada), i finalment carrega l’original (si està activat).",
|
||||
"setting_image_viewer_help": "El visualitzador de detalls carrega primer la miniatura petita, després carrega la previsualització de mida mitjana, i finalment carrega l’original (si està activat).",
|
||||
"setting_image_viewer_original_subtitle": "Activeu-ho per a carregar la imatge en resolució original (molt gran!). Desactiveu-ho per a reduir el consum de dades (tant de xarxa com de memòria cau del dispositiu).",
|
||||
"setting_image_viewer_original_title": "Carrega la imatge original",
|
||||
"setting_image_viewer_preview_subtitle": "Activeu-ho per a carregar una imatge de resolució mitjana. Desactiveu-ho per carregar directament la imatge original, o bé utilitzar només la miniatura.",
|
||||
"setting_image_viewer_preview_title": "Carrega la imatge de previsualització",
|
||||
"setting_languages_apply": "Aplica",
|
||||
"setting_languages_subtitle": "Canvia la llengua de l’aplicació",
|
||||
"setting_notifications_notify_minutes": "{count} minuts",
|
||||
@@ -2010,8 +2024,9 @@
|
||||
"share_everyone": "Comparteix tothom",
|
||||
"share_everyone_description": "Dóna accés a totes les vostres persones a {user}.",
|
||||
"share_link": "Comparteix l’enllaç",
|
||||
"share_logs": "Comparteix els registres",
|
||||
"share_original": "Utilitza l’original (gran)",
|
||||
"share_preview": "Utilitza la miniatura (petita)",
|
||||
"share_preview": "Utilitza la previsualització (petita)",
|
||||
"share_quality_body": "Mantingueu premut el botó de compartició per a triar la qualitat de la imatge abans de compartir-la.",
|
||||
"share_quality_title": "Trieu la qualitat de la compartició",
|
||||
"share_with": "Comparteix l’accés amb",
|
||||
@@ -2061,7 +2076,6 @@
|
||||
"shared_link_password_description": "Requereix una contrasenya per a accedir a aquest enllaç compartit",
|
||||
"shared_links": "Enllaços compartits",
|
||||
"shared_links_description": "Compartiu fotos i vídeos amb un enllaç",
|
||||
"shared_photos_and_videos_count": "{assetCount, plural, other {# fotos i vídeos compartits.}}",
|
||||
"shared_with": "Compartida amb",
|
||||
"shared_with_count": "Compartit ({count})",
|
||||
"shared_with_me": "Compartit amb mi",
|
||||
@@ -2189,6 +2203,8 @@
|
||||
"theme": "Tema",
|
||||
"theme_selection": "Selecció del tema",
|
||||
"theme_selection_description": "Activa automàticament el tema fosc o clar en funció de les preferències del sistema del navegador",
|
||||
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Es desplaça de manera contínua en arrossegar la barra de desplaçament, en lloc de saltar d’un mes a l’altre",
|
||||
"theme_setting_asset_list_smooth_scrubbing_title": "Desplaçament suau per la cronologia",
|
||||
"theme_setting_asset_list_storage_indicator_title": "Mostra l’indicador d’emmagatzematge a les tessel·les dels recursos",
|
||||
"theme_setting_asset_list_tiles_per_row_title": "Nombre de recursos per fila ({count})",
|
||||
"theme_setting_colorful_interface_subtitle": "Aplica el color primari a les superfícies de fons.",
|
||||
@@ -2229,6 +2245,8 @@
|
||||
"trigger": "Disparador",
|
||||
"trigger_asset_metadata_extraction": "Extracció de metadades dels recursos",
|
||||
"trigger_asset_metadata_extraction_description": "Es dispara quan s’extreuen les metadades EXIF d’un recurs",
|
||||
"trigger_asset_ocr_extraction": "Extracció de text OCR dels recursos",
|
||||
"trigger_asset_ocr_extraction_description": "Es dispara quan s’extreu el text OCR d’un recurs",
|
||||
"trigger_asset_tagged": "Recurs etiquetat",
|
||||
"trigger_asset_tagged_description": "Es dispara quan s’afegeix una etiqueta a un recurs",
|
||||
"trigger_asset_uploaded": "Pujada de recursos",
|
||||
@@ -2304,7 +2322,7 @@
|
||||
"user": "Usuari",
|
||||
"user_has_been_deleted": "Aquest usuari s’ha eliminat.",
|
||||
"user_id": "Identificador d’usuari",
|
||||
"user_liked": "A {user} li ha agradat {type, select, photo {aquesta foto} video {aquest vídeo} asset {aquest recurs} other {això}}",
|
||||
"user_liked": "A {user} li ha agradat {type, select, image {aquesta foto} video {aquest vídeo} other {això}}",
|
||||
"user_pin_code_settings": "Codi PIN",
|
||||
"user_pin_code_settings_description": "Gestioneu el vostre codi PIN",
|
||||
"user_privacy": "Privacitat de l’usuari",
|
||||
@@ -2340,6 +2358,7 @@
|
||||
"view_group": "Mostra el grup",
|
||||
"view_in_timeline": "Mostra a la cronologia",
|
||||
"view_link": "Mostra l’enllaç",
|
||||
"view_more": "Mostra’n més",
|
||||
"view_name": "Visualització",
|
||||
"view_next_asset": "Mostra el recurs següent",
|
||||
"view_previous_asset": "Mostra el recurs anterior",
|
||||
|
||||
+28
-9
@@ -86,7 +86,7 @@
|
||||
"failed_job_command": "Příkaz {command} se nezdařil pro úlohu: {job}",
|
||||
"force_delete_user_warning": "UPOZORNĚNÍ: Tímto okamžitě odstraníte uživatele a všechny jeho položky. Tento krok nelze vrátit zpět a soubory nelze obnovit.",
|
||||
"image_format": "Formát",
|
||||
"image_format_description": "WebP vytváří menší soubory než JPEG, ale je pomalejší při kódování.",
|
||||
"image_format_description": "WebP vytváří menší soubory než JPEG, ale je pomalejší při kódování. JPEG XL je nejlepší volbou pro kompresi ve vysoké kvalitě, ale ne všechny prohlížeče a aplikace jej dokážou zobrazit.",
|
||||
"image_fullsize_description": "Obrázek v plné velikosti s odstraněnými metadaty, použito při přiblížení",
|
||||
"image_fullsize_enabled": "Povolit generování obrázků v plné velikosti",
|
||||
"image_fullsize_enabled_description": "Generovat obrázky v plné velikosti pro formáty, které nejsou vhodné pro web. Pokud je povolena možnost „Preferovat vložený náhled“, budou přímo použity vložené náhledy bez převodu. Neovlivňuje formáty vhodné pro web, jako je JPEG.",
|
||||
@@ -100,7 +100,7 @@
|
||||
"image_preview_quality_description": "Kvalita náhledu od 1 do 100. Vyšší je lepší, ale vytváří větší soubory a může snížit responzivitu aplikace. Nastavení nízké hodnoty může ovlivnit kvalitu strojového učení.",
|
||||
"image_preview_title": "Náhledy",
|
||||
"image_progressive": "Progresivní",
|
||||
"image_progressive_description": "Kódujte JPEG obrázky progresivně pro postupné načítání zobrazení. Na WebP obrázky to nemá žádný vliv.",
|
||||
"image_progressive_description": "Kódujte JPEG obrázky progresivně pro postupné načítání zobrazení. Na ostatní formáty to nemá žádný vliv.",
|
||||
"image_quality": "Kvalita",
|
||||
"image_resolution": "Rozlišení",
|
||||
"image_resolution_description": "Vyšší rozlišení mohou zachovat více detailů, ale jejich kódování trvá déle, mají větší velikost souboru a mohou snížit odezvu aplikace.",
|
||||
@@ -323,6 +323,13 @@
|
||||
"oauth_timeout": "Časový limit požadavku",
|
||||
"oauth_timeout_description": "Časový limit pro požadavky v milisekundách",
|
||||
"ocr_job_description": "Použijte strojové učení k rozpoznávání textu v obrázcích",
|
||||
"passkey_additional_domains": "Další domény",
|
||||
"passkey_additional_domains_description": "Další důvěryhodné domény (související požadavky na origin). Tyto budou fungovat pouze v případě, že je přístupná i doména s přístupovým klíčem.",
|
||||
"passkey_domain": "Doména přístupového klíče",
|
||||
"passkey_domain_description": "Doména používaná pro přístupové klíče (ověřující strana). Nechte pole prázdné, aby se hodnota odvodila z origin hlavičky. Změna této hodnoty způsobí, že stávající přístupové klíče přestanou fungovat.",
|
||||
"passkey_enable_description": "Přihlášení pomocí přístupového klíče",
|
||||
"passkey_settings": "Přihlášení pomocí přístupového klíče",
|
||||
"passkey_settings_description": "Správa nastavení přihlášení pomocí přístupového klíče",
|
||||
"password_enable_description": "Přihlášení pomocí e-mailu a hesla",
|
||||
"password_settings": "Přihlášení heslem",
|
||||
"password_settings_description": "Správa nastavení přihlašování pomocí hesla",
|
||||
@@ -873,6 +880,8 @@
|
||||
"delete_local_dialog_ok_backed_up_only": "Smazat pouze zálohované",
|
||||
"delete_local_dialog_ok_force": "Přesto smazat",
|
||||
"delete_others": "Smazat ostatní",
|
||||
"delete_passkey": "Odstranit přístupový klíč",
|
||||
"delete_passkey_prompt": "Opravdu chcete tento přístupový klíč smazat?",
|
||||
"delete_permanently": "Trvale smazat",
|
||||
"delete_permanently_action_prompt": "{count} trvale smazáno",
|
||||
"delete_shared_link": "Smazat sdílený odkaz",
|
||||
@@ -942,6 +951,7 @@
|
||||
"edit_location_action_prompt": "{count} upravených poloh",
|
||||
"edit_location_dialog_title": "Poloha",
|
||||
"edit_name": "Upravit jméno",
|
||||
"edit_passkey": "Upravit přístupový klíč",
|
||||
"edit_people": "Upravit lidi",
|
||||
"edit_person": "Upravit osobu",
|
||||
"edit_tag": "Upravit značku",
|
||||
@@ -1239,7 +1249,6 @@
|
||||
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video pořízeno} other {Obrázek požízen}} {date} v místě {city}, {country} uživateli {person1}, {person2} a {person3}",
|
||||
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video pořízeno} other {Obrázek požízen}} {date} v místě {city}, {country} uživateli {person1}, {person2} a {additionalCount, plural, one {dalším # uživatelem} other {dalšími # uživateli}}",
|
||||
"immich_logo": "Immich Logo",
|
||||
"immich_web_interface": "Webové rozhraní Immich",
|
||||
"import_from_json": "Import z JSONu",
|
||||
"import_path": "Cesta importu",
|
||||
"in_albums": "{count, plural, one {V # albu} few {Ve # albech} other {V # albech}}",
|
||||
@@ -1287,6 +1296,7 @@
|
||||
"last": "Poslední",
|
||||
"last_months": "{count, plural, one {Poslední měsíc} few {Poslední # měsíce} other {Posledních # měsíců}}",
|
||||
"last_seen": "Naposledy viděno",
|
||||
"last_used": "Naposledy použito",
|
||||
"latest_version": "Nejnovější verze",
|
||||
"latitude": "Zeměpisná šířka",
|
||||
"leave": "Opustit",
|
||||
@@ -1362,6 +1372,7 @@
|
||||
"login_has_been_disabled": "Přihlášení bylo zakázáno",
|
||||
"login_password_changed_error": "Při aktualizaci vašeho hesla došlo k chybě",
|
||||
"login_password_changed_success": "Heslo bylo úspěšně aktualizováno",
|
||||
"login_with_passkey": "Přihlásit se pomocí přístupového klíče",
|
||||
"logout_all_device_confirmation": "Opravdu chcete odhlásit všechna zařízení?",
|
||||
"logout_this_device_confirmation": "Opravdu chcete odhlásit toto zařízení?",
|
||||
"logs": "Protokoly",
|
||||
@@ -1410,6 +1421,7 @@
|
||||
"manage_your_api_keys": "Správa API klíčů",
|
||||
"manage_your_devices": "Správa přihlášených zařízení",
|
||||
"manage_your_oauth_connection": "Správa OAuth propojení",
|
||||
"manage_your_passkeys": "Správa přístupových klíčů",
|
||||
"map": "Mapa",
|
||||
"map_assets_in_bounds": "{count, plural, =0 {Žádné fotografie v této oblasti} one {# fotografie} few {# fotografie} other {# fotografií}}",
|
||||
"map_cannot_get_user_location": "Nelze zjistit polohu uživatele",
|
||||
@@ -1529,6 +1541,7 @@
|
||||
"new_album": "Nové album",
|
||||
"new_api_key": "Nový API klíč",
|
||||
"new_feature": "Nová funkce",
|
||||
"new_passkey": "Nový přístupový klíč",
|
||||
"new_password": "Nové heslo",
|
||||
"new_person": "Nová osoba",
|
||||
"new_pin_code": "Nový PIN kód",
|
||||
@@ -1641,6 +1654,8 @@
|
||||
"partner_sharing": "Sdílení mezi partnery",
|
||||
"partners": "Partneři",
|
||||
"partners_description": "Sdílejte své fotografie a videa s ostatními uživateli",
|
||||
"passkeys": "{count, plural, one {přístupový klíč} few {přístupové klíče} other {přístupových klíčů}}",
|
||||
"passkeys_not_supported": "Tento prohlížeč nepodporuje přístupové klíče",
|
||||
"password": "Heslo",
|
||||
"password_does_not_match": "Heslo se neshoduje",
|
||||
"password_required": "Vyžadováno heslo",
|
||||
@@ -1825,6 +1840,7 @@
|
||||
"removed_from_favorites_count": "{count, plural, one {Odstraněn #} few {Odstraněny #} other {Odstraněno #}} z oblíbených",
|
||||
"removed_from_stack": "Položka byla odebrána ze seskupení",
|
||||
"removed_memory": "Vzpomínka odstraněna",
|
||||
"removed_passkey": "Odstraněný přístupový klíč: {name}",
|
||||
"removed_tagged_assets": "Odstraněná značka z {count, plural, one {# položky} other {# položek}}",
|
||||
"rename": "Přejmenovat",
|
||||
"repository": "Repozitář",
|
||||
@@ -1986,11 +2002,9 @@
|
||||
"setting_image_navigation_enable_subtitle": "Pokud je zapnuto, budete moci přejít na předchozí/další obrázek klepnutím do levé/pravé čtvrtiny obrazovky.",
|
||||
"setting_image_navigation_enable_title": "Klepnutí pro navigaci",
|
||||
"setting_image_navigation_title": "Navigace mezi obrázky",
|
||||
"setting_image_viewer_help": "V prohlížeči detailů se nejprve načte malá miniatura, poté se načte náhled střední velikosti (je-li povolen) a nakonec se načte originál (je-li povolen).",
|
||||
"setting_image_viewer_help": "V prohlížeči detailů se nejprve načte malá miniatura, poté se načte náhled střední velikosti a nakonec se načte originál (je-li povolen).",
|
||||
"setting_image_viewer_original_subtitle": "Umožňuje načíst původní obrázek v plném rozlišení (velký!). Zakažte pro snížení využití dat (v síti i v mezipaměti zařízení).",
|
||||
"setting_image_viewer_original_title": "Načíst původní obrázek",
|
||||
"setting_image_viewer_preview_subtitle": "Umožňuje načíst obrázek se středním rozlišením. Zakažte, pokud chcete přímo načíst originál nebo použít pouze miniaturu.",
|
||||
"setting_image_viewer_preview_title": "Načíst náhled obrázku",
|
||||
"setting_languages_apply": "Použít",
|
||||
"setting_languages_subtitle": "Změna jazyka aplikace",
|
||||
"setting_notifications_notify_minutes": "{count} minut",
|
||||
@@ -2010,8 +2024,9 @@
|
||||
"share_everyone": "Sdílet všechny",
|
||||
"share_everyone_description": "Poskytněte {user} přístup ke všem vašim uživatelům.",
|
||||
"share_link": "Sdílet odkaz",
|
||||
"share_logs": "Sdílet protokol",
|
||||
"share_original": "Použít originál (velký)",
|
||||
"share_preview": "Použít miniaturu (malý)",
|
||||
"share_preview": "Použít náhled (malý)",
|
||||
"share_quality_body": "Při sdílení stiskněte a podržte tlačítko sdílení pro výběr kvality obrázku.",
|
||||
"share_quality_title": "Vyberte si kvalitu sdílení",
|
||||
"share_with": "Sdílet přístup s",
|
||||
@@ -2061,7 +2076,6 @@
|
||||
"shared_link_password_description": "Vyžadovat heslo pro přístup k tomuto sdílenému odkazu",
|
||||
"shared_links": "Sdílené odkazy",
|
||||
"shared_links_description": "Sdílet fotky a videa pomocí odkazu",
|
||||
"shared_photos_and_videos_count": "{assetCount, plural, one {# sdílená fotografie a video.} few {# sdílené fotografie a videa.} other {# sdílených fotografií a videí.}}",
|
||||
"shared_with": "Sdíleno s",
|
||||
"shared_with_count": "Sdíleno ({count})",
|
||||
"shared_with_me": "Sdílené se mnou",
|
||||
@@ -2189,6 +2203,8 @@
|
||||
"theme": "Motiv",
|
||||
"theme_selection": "Výběr motivu",
|
||||
"theme_selection_description": "Automatické nastavení světlého nebo tmavého motivu podle systémových preferencí prohlížeče",
|
||||
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Při tažení posuvníku posouvat plynule, místo aby se posun zastavoval na jednotlivých měsících",
|
||||
"theme_setting_asset_list_smooth_scrubbing_title": "Plynulé posouvání časové osy",
|
||||
"theme_setting_asset_list_storage_indicator_title": "Zobrazit indikátor úložiště na dlaždicích položek",
|
||||
"theme_setting_asset_list_tiles_per_row_title": "Počet položek na řádek ({count})",
|
||||
"theme_setting_colorful_interface_subtitle": "Použít hlavní barvu na povrchy pozadí.",
|
||||
@@ -2229,6 +2245,8 @@
|
||||
"trigger": "Spouštěč",
|
||||
"trigger_asset_metadata_extraction": "Extrakce metadat položek",
|
||||
"trigger_asset_metadata_extraction_description": "Spustí se při extrakci EXIF metadat z mediálního souboru",
|
||||
"trigger_asset_ocr_extraction": "Extrakce dat pomocí OCR",
|
||||
"trigger_asset_ocr_extraction_description": "Spustí se, když je text OCR položky extrahováný",
|
||||
"trigger_asset_tagged": "Označená položka",
|
||||
"trigger_asset_tagged_description": "Spustí se, když je k položce přidána značka",
|
||||
"trigger_asset_uploaded": "Nahrání položky",
|
||||
@@ -2304,7 +2322,7 @@
|
||||
"user": "Uživatel",
|
||||
"user_has_been_deleted": "Tento uživatel byl smazán.",
|
||||
"user_id": "ID uživatele",
|
||||
"user_liked": "Uživateli {user} se {type, select, photo {líbila tato fotka} video {líbilo toto video} asset {líbila tato položka} other {to líbilo}}",
|
||||
"user_liked": "Uživateli {user} se {type, select, image {líbil tento obrázek} video {líbilo toto video} other {to líbilo}}",
|
||||
"user_pin_code_settings": "PIN kód",
|
||||
"user_pin_code_settings_description": "Správa vašeho PIN kódu",
|
||||
"user_privacy": "Ochrana soukromí uživatelů",
|
||||
@@ -2340,6 +2358,7 @@
|
||||
"view_group": "Zobrazit skupinu",
|
||||
"view_in_timeline": "Zobrazit na časové ose",
|
||||
"view_link": "Zobrazit odkaz",
|
||||
"view_more": "Zobrazit více",
|
||||
"view_name": "Zobrazit",
|
||||
"view_next_asset": "Zobrazit další položku",
|
||||
"view_previous_asset": "Zobrazit předchozí položku",
|
||||
|
||||
+17
-11
@@ -191,7 +191,7 @@
|
||||
"machine_learning_ocr_min_detection_score_description": "Minimums konfidensscore for tekst, der skal detekteres, fra 0-1. Lavere værdier vil detektere mere tekst, men kan resultere i falsk positiver.",
|
||||
"machine_learning_ocr_min_recognition_score": "Minimum genkendelsesscore",
|
||||
"machine_learning_ocr_min_score_recognition_description": "Minimum konfidensscore for genkendelse af registreret tekst er fra 0-1. Lavere værdier vil genkende mere tekst, men kan resultere i falsk positiver.",
|
||||
"machine_learning_ocr_model": "OCR model",
|
||||
"machine_learning_ocr_model": "OCR-model",
|
||||
"machine_learning_ocr_model_description": "Server modeller er mere præcise end mobil modeller, men tager længer tid at processere og bruger mere hukommelse.",
|
||||
"machine_learning_settings": "Maskinlæringsindstillinger",
|
||||
"machine_learning_settings_description": "Administrer maskinlæringsfunktioner og indstillinger",
|
||||
@@ -443,7 +443,7 @@
|
||||
"transcoding_realtime_enabled_description": "Hvis deaktiveret, kan serveren ikke starte nye realtids transkodningssessioner.",
|
||||
"transcoding_realtime_resolutions": "Opløsninger",
|
||||
"transcoding_realtime_resolutions_description": "Opløsninger tilgængelig for realtidstranskodning. Højere opløsninger kan forårsage afspilningsproblemer, hvis serveren ikke kan transkode dem hurtigt nok.",
|
||||
"transcoding_realtime_video_codecs": "Video codecs",
|
||||
"transcoding_realtime_video_codecs": "Video-codecs",
|
||||
"transcoding_realtime_video_codecs_description": "De video codecs, tilgængelig for realtidstranskodning. Klienter vælger den bedste mulighed, de understøtter under afspilning. AV1 er mere effektiv end HEVC, som er mere effektiv end H.264. Når du bruger hardwareacceleration, skal du kun vælge de codecs, som acceleratoren kan enkode. Når du bruger softwaretranskodning, skal du bemærke, at H.264 er hurtigere end AV1, som er hurtigere end HEVC.",
|
||||
"transcoding_reference_frames": "Referencerammer",
|
||||
"transcoding_reference_frames_description": "Antallet af frames, der skal refereres til, når en given frame komprimeres. Højere værdier forbedrer kompressionseffektiviteten, men gør indkodning langsommere. 0 sætter denne værdi automatisk.",
|
||||
@@ -669,8 +669,9 @@
|
||||
"biometric_no_options": "Ingen biometrisk adgangskontrol tilgængelig",
|
||||
"biometric_not_available": "Biometrisk adgangskontrol er ikke tilgængelig på denne enhed",
|
||||
"birthdate_set_description": "Fødselsdato bruges til at beregne denne persons alder på det tidspunkt, et billede er taget.",
|
||||
"birthday_memory_age": "{age}. fødselsdag",
|
||||
"birthday_memory_age": "{age, selectordinal, other {#}}-års fødselsdag",
|
||||
"birthday_memory_title": "{name}'s fødselsdag",
|
||||
"birthday_memory_title_with_age": "{name}s fødselsdag · {age, plural, one {# år} other {# år}}",
|
||||
"blurred_background": "Sløret baggrund",
|
||||
"browse_templates": "Gennemse skabeloner",
|
||||
"bugs_and_feature_requests": "Fejl & forbedringsønsker",
|
||||
@@ -914,7 +915,7 @@
|
||||
"downloading": "Downloader",
|
||||
"downloading_archive_filename_size": "Downloader arkiv {filename} (cirka {size})",
|
||||
"downloading_asset_filename": "Downloader mediefil {filename}",
|
||||
"downloading_filename": "Downloading {filename}",
|
||||
"downloading_filename": "Downloader {filename}",
|
||||
"downloading_from_icloud": "Downloading fra iCloud",
|
||||
"downloading_media": "Download medier",
|
||||
"drag_to_reorder": "Træk for at ændre rækkefølgen",
|
||||
@@ -1116,7 +1117,7 @@
|
||||
},
|
||||
"errors_text": "Fejl",
|
||||
"exclusion_pattern": "Udelukkelsesmønster",
|
||||
"exif": "Exif",
|
||||
"exif": "EXIF",
|
||||
"exif_bottom_sheet_description": "Tilføj beskrivelse...",
|
||||
"exif_bottom_sheet_description_error": "Fejl ved opdatering af beskrivelsen",
|
||||
"exif_bottom_sheet_no_description": "Ingen beskrivelse",
|
||||
@@ -1238,7 +1239,6 @@
|
||||
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Billede}} taget i {city}, {country} med {person1}, {person2}, og {person3} den {date}",
|
||||
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Billede}} taget i {city}, {country} med {person1}, {person2}, og {additionalCount, number} andre den {date}",
|
||||
"immich_logo": "Immich logo",
|
||||
"immich_web_interface": "Immich webinterface",
|
||||
"import_from_json": "Importér fra JSON",
|
||||
"import_path": "Import-sti",
|
||||
"in_albums": "I {count, plural, one {# album} other {# albummer}}",
|
||||
@@ -1400,6 +1400,7 @@
|
||||
"manage_media_access_title": "Mediestyringsadgang",
|
||||
"manage_oauth_account": "Administrer OAuth-konto",
|
||||
"manage_people": "Administrér personer",
|
||||
"manage_people_access_description_v2": "Giv andre brugere adgang til {count, plural, one {denne person} other {dine personer}}, så de kan se eller administrere {count, plural, one {personen} other {dem}} på dine vegne. Denne handling deler ikke nogen elementer.",
|
||||
"manage_person_access": "Administrér adgang",
|
||||
"manage_sharing_with_other_users": "Administrer deling med andre brugere",
|
||||
"manage_sharing_with_partners": "Administrér deling med partnere",
|
||||
@@ -1655,6 +1656,7 @@
|
||||
"paused": "Sat på pause",
|
||||
"pending": "Afventer",
|
||||
"people": "Personer",
|
||||
"people_cluster_group": "Persongruppe",
|
||||
"people_count": "{count, plural, one {# person} other {# personer}}",
|
||||
"people_edits_count": "Redigeret {count, plural, one {# person} other {# people}}",
|
||||
"people_feature_description": "Gennemse billeder og videoer grupperet efter personer",
|
||||
@@ -1674,9 +1676,12 @@
|
||||
"person_age_years": "{years, plural, other {# år}} gammel",
|
||||
"person_birthdate": "Født den {date}",
|
||||
"person_edit_only_change_for_me": "Ændr kun for mig",
|
||||
"person_edit_only_change_for_me_description": "Markér for kun at anvende ændringerne på din egen bruger",
|
||||
"person_hidden": "{name}{hidden, select, true { (skjult)} other {}}",
|
||||
"person_recognized": "Person genkendt",
|
||||
"person_share_no_users": "Alle brugere i din persongruppe har allerede adgang til denne person.",
|
||||
"person_update_strategy": "Udfør ændringer på",
|
||||
"person_update_strategy_description": "Vælg, hvilke personer der skal opdateres, når du ændrer navn eller fødselsdato",
|
||||
"person_update_strategy_everyone": "Alle med delt adgang",
|
||||
"person_update_strategy_self": "Kun mig",
|
||||
"photo_shared_all_users": "Det ser ud til, at du har delt dine billeder med alle brugere, eller også har du ikke nogen bruger at dele med.",
|
||||
@@ -1760,7 +1765,8 @@
|
||||
"purchase_settings_server_activated": "Serverens produktnøgle administreres af administratoren",
|
||||
"query_asset_id": "Forespørgsels Asset ID",
|
||||
"queue_status": "Kø {count}/{total}",
|
||||
"rate_asset": "Vurder filer",
|
||||
"rate_asset": "Bedøm mediefil",
|
||||
"rate_asset_description": "Brug tasterne 1 til 5 til hurtigt at bedømme en mediefil, og 0 til at fjerne bedømmelsen",
|
||||
"rating": "Stjernebedømmelse",
|
||||
"rating_clear": "Nulstil vurdering",
|
||||
"rating_count": "{count, plural, =0 {Unrated} one {# stjerne} other {# stjerner}}",
|
||||
@@ -1979,11 +1985,9 @@
|
||||
"setting_image_navigation_enable_subtitle": "Hvis aktiveret, kan du navigere til det forrige/næste billede ved at trykke på den yderste venstre/højre fjerdedel af skærmen.",
|
||||
"setting_image_navigation_enable_title": "Tryk for at navigere",
|
||||
"setting_image_navigation_title": "Billednavigation",
|
||||
"setting_image_viewer_help": "Detaljeret visning indlæser miniaturebilleder først. Herefter indlæses mediumstørrelse forhåndsvisning af billedet (hvis dette er slået til), for til sidst at vise originalen (hvis dette er slået til).",
|
||||
"setting_image_viewer_help": "Detaljevisningen indlæser først det lille miniaturebillede, derefter en mellemstor forhåndsvisning og til sidst originalfilen (hvis funktionen er aktiveret).",
|
||||
"setting_image_viewer_original_subtitle": "Slå indlæsning af originalbillede i fuld størrelse til (stort!). Deaktiver for at reducere dataforbruget (både på netværket og for enhedscache).",
|
||||
"setting_image_viewer_original_title": "Indlæs originalbillede",
|
||||
"setting_image_viewer_preview_subtitle": "Slå indlæsning af et mediumstørrelse billede til. Slå fra for enten direkte at indlæse originalen eller kun at bruge miniaturebilledet.",
|
||||
"setting_image_viewer_preview_title": "Indlæs forhåndsvisning af billedet",
|
||||
"setting_languages_apply": "Anvend",
|
||||
"setting_languages_subtitle": "Ændr app-sprog",
|
||||
"setting_notifications_notify_minutes": "{count} minutter",
|
||||
@@ -1998,6 +2002,7 @@
|
||||
"settings_saved": "Indstillinger er gemt",
|
||||
"setup_pin_code": "Indstil en PIN kode",
|
||||
"share": "Del",
|
||||
"share_back": "Del tilbage",
|
||||
"share_dialog_preparing": "Forbereder...",
|
||||
"share_everyone": "Del alle",
|
||||
"share_everyone_description": "Giv {user} adgang til alle dine personer.",
|
||||
@@ -2053,8 +2058,8 @@
|
||||
"shared_link_password_description": "Kodeord krævet for at få adgang til dette delte link",
|
||||
"shared_links": "Delte links",
|
||||
"shared_links_description": "Del billeder og videoer med et link",
|
||||
"shared_photos_and_videos_count": "{assetCount, plural, other {# delte billeder & videoer.}}",
|
||||
"shared_with": "Delt med",
|
||||
"shared_with_count": "Delt ({count})",
|
||||
"shared_with_me": "Delt med mig",
|
||||
"shared_with_partner": "Delt med {partner}",
|
||||
"sharing": "Delte",
|
||||
@@ -2138,6 +2143,7 @@
|
||||
"stop_motion_photo": "Stopmotionbillede",
|
||||
"stop_photo_sharing": "Stop med at dele dine billeder?",
|
||||
"stop_photo_sharing_description": "{partner} vil ikke længere kunne tilgå dine billeder.",
|
||||
"stop_sharing_person_with_user": "Ophæv deling af denne person med denne bruger",
|
||||
"stop_sharing_photos_with_user": "Afslut deling af dine fotos med denne bruger",
|
||||
"storage": "Lagringsplads",
|
||||
"storage_label": "Lagringsmærkat",
|
||||
|
||||
+29
-10
@@ -86,7 +86,7 @@
|
||||
"failed_job_command": "Befehl {command} ist für Aufgabe {job} fehlgeschlagen",
|
||||
"force_delete_user_warning": "WARNUNG: Diese Aktion löscht sofort den Benutzer und all seine Dateien. Dies kann nicht rückgängig gemacht werden und die Dateien können nicht wiederhergestellt werden.",
|
||||
"image_format": "Format",
|
||||
"image_format_description": "WebP erzeugt kleinere Dateien als JPEG, ist aber etwas langsamer in der Erstellung.",
|
||||
"image_format_description": "WebP erzeugt kleinere Dateien als JPEG, die Kodierung dauert jedoch länger. JPEG XL ist die beste Option für eine hochwertige Komprimierung, wird jedoch nicht von allen Browsern und Apps unterstützt.",
|
||||
"image_fullsize_description": "Hochauflösendes Bild mit entfernten Metadaten, das beim Zoomen verwendet wird",
|
||||
"image_fullsize_enabled": "Hochauflösende Vorschaubilder aktivieren",
|
||||
"image_fullsize_enabled_description": "Generiere Hochauflösende Vorschaubilder in Originalauflösung für nicht web-kompatibel Formate. Wenn \"Eingebettete Vorschau bevorzugen\" aktiviert ist, werden eingebettete Vorschaubilder direkt verwendet. Hat keinen Einfluss auf web-kompatible Formate wie JPEG.",
|
||||
@@ -100,7 +100,7 @@
|
||||
"image_preview_quality_description": "Vorschauqualität von 1-100. Ein höherer Wert ist besser, erzeugt dadurch aber größere Dateien und kann die Reaktionsfähigkeit der App beeinträchtigen. Die Einstellung eines niedrigen Wertes kann dafür aber die Qualität des maschinellen Lernens beeinträchtigen.",
|
||||
"image_preview_title": "Vorschaueinstellungen",
|
||||
"image_progressive": "Fortschrittlich",
|
||||
"image_progressive_description": "JPEG-Bilder werden schrittweise kodiert, um ein stufenweises Laden zu ermöglichen. Dies hat keine Auswirkungen auf WebP-Bilder.",
|
||||
"image_progressive_description": "JPEG-Bilder werden schrittweise kodiert, um ein stufenweises Laden zu ermöglichen. Dies hat keine Auswirkungen auf andere Dateiformate.",
|
||||
"image_quality": "Qualität",
|
||||
"image_resolution": "Auflösung",
|
||||
"image_resolution_description": "Höhere Auflösungen können mehr Details erhalten, benötigen aber mehr Zeit für die Kodierung, haben größere Dateigrößen und können die Reaktionsfähigkeit von Anwendungen beeinträchtigen.",
|
||||
@@ -323,6 +323,13 @@
|
||||
"oauth_timeout": "Zeitüberschreitung bei Anfrage",
|
||||
"oauth_timeout_description": "Zeitüberschreitung für Anfragen in Millisekunden",
|
||||
"ocr_job_description": "Verwende Machine Learning zur Erkennung von Text in Bildern",
|
||||
"passkey_additional_domains": "Zusätzliche Domains",
|
||||
"passkey_additional_domains_description": "Zusätzliche, vertrauenswürdige Domänen(Related Origin Requests). Diese funktionieren nur, wenn auch die Passkey-Domäne erreichbar ist.",
|
||||
"passkey_domain": "Passkex Domäne",
|
||||
"passkey_domain_description": "Für Passkeys verwendete Domäne(Relying Party). Lassen Sie dieses Feld leer, damit der Wert aus dem Original-Header abgeleitet wird. Eine Änderung dieses Werts führt dazu, dass vorhandene Passkeys nicht mehr funktionieren.",
|
||||
"passkey_enable_description": "Mit einem Passkey anmelden",
|
||||
"passkey_settings": "Anmeldung per Passkey",
|
||||
"passkey_settings_description": "Einstellungen für die Anmeldung per Passkey verwalten",
|
||||
"password_enable_description": "Mit E-Mail und Passwort anmelden",
|
||||
"password_settings": "Passwort-Anmeldung",
|
||||
"password_settings_description": "Passwort-Anmeldeeinstellungen verwalten",
|
||||
@@ -670,8 +677,8 @@
|
||||
"biometric_not_available": "Die biometrische Authentifizierung ist auf diesem Gerät nicht verfügbar",
|
||||
"birthdate_set_description": "Das Geburtsdatum wird verwendet, um das Alter dieser Person zum Zeitpunkt eines Fotos zu berechnen.",
|
||||
"birthday_memory_age": "{age, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} Geburtstag",
|
||||
"birthday_memory_title": "{name}'s Geburtstag",
|
||||
"birthday_memory_title_with_age": "{name}'s Geburtstag· {age, plural, one {# Jahr alt} other {# Jahre alt}}",
|
||||
"birthday_memory_title": "{name}s Geburtstag",
|
||||
"birthday_memory_title_with_age": "{name}s Geburtstag· {age, plural, one {# Jahr alt} other {# Jahre alt}}",
|
||||
"blurred_background": "Unscharfer Hintergrund",
|
||||
"browse_templates": "Vorlagen durchsuchen",
|
||||
"bugs_and_feature_requests": "Fehler & Verbesserungsvorschläge",
|
||||
@@ -873,6 +880,8 @@
|
||||
"delete_local_dialog_ok_backed_up_only": "Nur gesicherte Inhalte löschen",
|
||||
"delete_local_dialog_ok_force": "Trotzdem löschen",
|
||||
"delete_others": "Andere löschen",
|
||||
"delete_passkey": "Passkey löschen",
|
||||
"delete_passkey_prompt": "Sind Sie sicher, dass Sie diesen Passkey löschen wollen?",
|
||||
"delete_permanently": "Endgültig löschen",
|
||||
"delete_permanently_action_prompt": "{count} endgültig gelöscht",
|
||||
"delete_shared_link": "geteilten Link löschen",
|
||||
@@ -942,6 +951,7 @@
|
||||
"edit_location_action_prompt": "{count} Geolokationen angepasst",
|
||||
"edit_location_dialog_title": "Standort bearbeiten",
|
||||
"edit_name": "Name bearbeiten",
|
||||
"edit_passkey": "Passkey bearbeiten",
|
||||
"edit_people": "Personen bearbeiten",
|
||||
"edit_person": "Person bearbeiten",
|
||||
"edit_tag": "Tag bearbeiten",
|
||||
@@ -1239,7 +1249,6 @@
|
||||
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Bild}} aufgenommen in {city}, {country} mit {person1}, {person2}, und {person3} am {date}",
|
||||
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Bild}} aufgenommen in {city}, {country} mit {person1}, {person2}, und {additionalCount, number} anderen am {date}",
|
||||
"immich_logo": "Immich-Logo",
|
||||
"immich_web_interface": "Immich-Web-Oberfläche",
|
||||
"import_from_json": "Aus JSON importieren",
|
||||
"import_path": "Importpfad",
|
||||
"in_albums": "In {count, plural, one {# Album} other {# Alben}}",
|
||||
@@ -1287,6 +1296,7 @@
|
||||
"last": "Letzte",
|
||||
"last_months": "{count, plural, one {Letzter Monat} other {Letzte # Monate}}",
|
||||
"last_seen": "Zuletzt gesehen",
|
||||
"last_used": "Zuletzt verwendet",
|
||||
"latest_version": "Neuste Version",
|
||||
"latitude": "Breitengrad",
|
||||
"leave": "Verlassen",
|
||||
@@ -1362,6 +1372,7 @@
|
||||
"login_has_been_disabled": "Die Anmeldung wurde deaktiviert",
|
||||
"login_password_changed_error": "Fehler beim Ändern deines Passwortes",
|
||||
"login_password_changed_success": "Passwort erfolgreich geändert",
|
||||
"login_with_passkey": "Anmeldung mit Passkey",
|
||||
"logout_all_device_confirmation": "Bist du sicher, dass du alle Geräte abmelden willst?",
|
||||
"logout_this_device_confirmation": "Bist du sicher, dass du dieses Gerät abmelden willst?",
|
||||
"logs": "Protokolle",
|
||||
@@ -1410,6 +1421,7 @@
|
||||
"manage_your_api_keys": "Deine API-Schlüssel verwalten",
|
||||
"manage_your_devices": "Deine eingeloggten Geräte verwalten",
|
||||
"manage_your_oauth_connection": "Deine OAuth-Verknüpfung verwalten",
|
||||
"manage_your_passkeys": "Verwalten Sie Ihre Passkeys",
|
||||
"map": "Karte",
|
||||
"map_assets_in_bounds": "{count, plural, =0 {Keine Fotos in diesem Bereich} one {# Foto} other {# Fotos}}",
|
||||
"map_cannot_get_user_location": "Standort konnte nicht ermittelt werden",
|
||||
@@ -1529,6 +1541,7 @@
|
||||
"new_album": "Neues Album",
|
||||
"new_api_key": "Neuer API-Schlüssel",
|
||||
"new_feature": "Neue Funktion",
|
||||
"new_passkey": "Neuer Passkey",
|
||||
"new_password": "Neues Passwort",
|
||||
"new_person": "Neue Person",
|
||||
"new_pin_code": "Neuer PIN-Code",
|
||||
@@ -1641,6 +1654,8 @@
|
||||
"partner_sharing": "Partner-Sharing",
|
||||
"partners": "Partner",
|
||||
"partners_description": "Teile deine Fotos und Videos mit anderen Benutzern",
|
||||
"passkeys": "{count, plural, one {Passkey} other {Passkeys}}",
|
||||
"passkeys_not_supported": "Passkeys werden von diesem Browser nicht unterstützt",
|
||||
"password": "Passwort",
|
||||
"password_does_not_match": "Passwort stimmt nicht überein",
|
||||
"password_required": "Passwort benötigt",
|
||||
@@ -1825,6 +1840,7 @@
|
||||
"removed_from_favorites_count": "{count, plural, other {#}} aus den Favoriten entfernt",
|
||||
"removed_from_stack": "Datei aus Stapel entfernen",
|
||||
"removed_memory": "Erinnerung entfernt",
|
||||
"removed_passkey": "Passkey entfernt: {name}",
|
||||
"removed_tagged_assets": "Tag von {count, plural, one {# Datei} other {# Dateien}} entfernt",
|
||||
"rename": "Umbenennen",
|
||||
"repository": "Repository",
|
||||
@@ -1986,11 +2002,9 @@
|
||||
"setting_image_navigation_enable_subtitle": "Aktivieren, um durch Tippen auf den linksäußeren/rechtsäußeren Bildschirmrand zum vorherigen/nächsten Bild zu navigieren.",
|
||||
"setting_image_navigation_enable_title": "Tippen zum Navigieren",
|
||||
"setting_image_navigation_title": "Bild Navigation",
|
||||
"setting_image_viewer_help": "Der Detailbildbetrachter lädt zuerst ein (kleines) Vorschaubild, dann ein Vorschaubild in mittlerer Größe (falls aktiviert) und schließlich das Original (falls aktiviert).",
|
||||
"setting_image_viewer_help": "Der Detailbildbetrachter lädt zuerst ein Vorschaubild, dann ein Vorschaubild mittlerer Größe und schließlich das Original (falls aktiviert).",
|
||||
"setting_image_viewer_original_subtitle": "Aktivieren, um das Originalbild in voller Auflösung (groß!) zu laden. Deaktivieren, um den Datenverbrauch zu reduzieren (sowohl im Netzwerk als auch im Gerätespeicher).",
|
||||
"setting_image_viewer_original_title": "Original laden",
|
||||
"setting_image_viewer_preview_subtitle": "Aktivieren, um ein Bild mit mittlerer Auflösung zu laden. Deaktivieren, um entweder das Original direkt zu laden oder nur das Vorschaubild zu verwenden.",
|
||||
"setting_image_viewer_preview_title": "Vorschaubild laden",
|
||||
"setting_languages_apply": "Anwenden",
|
||||
"setting_languages_subtitle": "App-Sprache ändern",
|
||||
"setting_notifications_notify_minutes": "{count} Minuten",
|
||||
@@ -2010,8 +2024,9 @@
|
||||
"share_everyone": "Teilt es mit allen",
|
||||
"share_everyone_description": "Gewähren Sie {user} Zugriff auf alle Ihre Personen.",
|
||||
"share_link": "Link teilen",
|
||||
"share_logs": "Logbücher teilen",
|
||||
"share_original": "Original verwenden (groß)",
|
||||
"share_preview": "Thumbnail verwenden (klein)",
|
||||
"share_preview": "Vorschau anzeigen (klein)",
|
||||
"share_quality_body": "Halte den Teilen Button gedrückt um die Bildqualität vor dem Teilen auszuwählen.",
|
||||
"share_quality_title": "Wähle deine Qualität zum Teilen aus",
|
||||
"share_with": "Zugriff freigeben für",
|
||||
@@ -2061,7 +2076,6 @@
|
||||
"shared_link_password_description": "Für den Zugriff auf diesen freigegebenen Link ist ein Passwort erforderlich",
|
||||
"shared_links": "Geteilte Links",
|
||||
"shared_links_description": "Teile Fotos und Videos mit einem Link",
|
||||
"shared_photos_and_videos_count": "{assetCount, plural, one {# geteiltes Foto oder Video.} other {# geteilte Fotos & Videos.}}",
|
||||
"shared_with": "Geteilt mit",
|
||||
"shared_with_count": "Geteilt ({count})",
|
||||
"shared_with_me": "Mit mir geteilt",
|
||||
@@ -2189,6 +2203,8 @@
|
||||
"theme": "Theme",
|
||||
"theme_selection": "Themenauswahl",
|
||||
"theme_selection_description": "Automatische Einstellung des Themes auf Hell oder Dunkel, je nach Systemeinstellung des Browsers",
|
||||
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Beim Ziehen des Scrollbalkens kontinuierlich scrollen, ohne an den Monaten einzurasten",
|
||||
"theme_setting_asset_list_smooth_scrubbing_title": "Gleichmäßiges Verschieben auf der Zeitleiste",
|
||||
"theme_setting_asset_list_storage_indicator_title": "Fortschrittsbalken der Sicherung auf dem Vorschaubild",
|
||||
"theme_setting_asset_list_tiles_per_row_title": "Anzahl der Elemente pro Reihe ({count})",
|
||||
"theme_setting_colorful_interface_subtitle": "Primärfarbe auf App-Hintergrund anwenden.",
|
||||
@@ -2229,6 +2245,8 @@
|
||||
"trigger": "Auslöser",
|
||||
"trigger_asset_metadata_extraction": "Extraktion von Datei-Metadaten",
|
||||
"trigger_asset_metadata_extraction_description": "Wird ausgelöst, wenn die EXIF-Daten einer Datei extrahiert werden",
|
||||
"trigger_asset_ocr_extraction": "Auslesen von OCR-Text aus Dateien",
|
||||
"trigger_asset_ocr_extraction_description": "Wird ausgelöst, wenn der OCR-Text einer Datei extrahiert wird",
|
||||
"trigger_asset_tagged": "Datei getaggt",
|
||||
"trigger_asset_tagged_description": "Wird ausgelöst, wenn einer Datei ein Tag zugewiesen wird",
|
||||
"trigger_asset_uploaded": "Datei hochladen",
|
||||
@@ -2340,6 +2358,7 @@
|
||||
"view_group": "Gruppe ansehen",
|
||||
"view_in_timeline": "In Zeitleiste anzeigen",
|
||||
"view_link": "Link anzeigen",
|
||||
"view_more": "Mehr anzeigen",
|
||||
"view_name": "Ansicht",
|
||||
"view_next_asset": "Nächste Datei anzeigen",
|
||||
"view_previous_asset": "Vorherige Datei anzeigen",
|
||||
|
||||
+3
-2
@@ -100,7 +100,7 @@
|
||||
"image_preview_quality_description": "Vorschauqualität von 1-100. Ein höherer Wert ist besser, erzeugt dadurch aber grössere Dateien und kann die Reaktionsfähigkeit der App beeinträchtigen. Ein niedriger Wert kann dafür aber die Qualität des maschinellen Lernens beeinträchtigen.",
|
||||
"image_preview_title": "Vorschaueinstellungen",
|
||||
"image_progressive": "Fortlaufend",
|
||||
"image_progressive_description": "JPEG-Bilder schrittweise kodieren, um ein stufenweises Laden zu ermöglichen. Dies hat keine Auswirkungen auf WebP-Bilder.",
|
||||
"image_progressive_description": "JPEG-Bilder schrittweise kodieren, um ein stufenweises Laden zu ermöglichen. Dies hat keine Auswirkungen auf andere Dateiformate.",
|
||||
"image_quality": "Qualität",
|
||||
"image_resolution": "Uflösig",
|
||||
"image_resolution_description": "Höhere Auflösungen können mehr Details erhalten, benötigen aber mehr Zeit für die Kodierung, haben grössere Dateigrössen und können die Reaktionsfähigkeit der App beeinträchtigen.",
|
||||
@@ -248,7 +248,8 @@
|
||||
"map_settings_description": "Karten- und GPS-Einstellungen verwalten",
|
||||
"map_style_description": "URL zu einem style.json Karten-Theme",
|
||||
"memory_cleanup_job": "Erinnerungen aufräumen",
|
||||
"memory_generate_job": "Erinnerungen Generierung"
|
||||
"memory_generate_job": "Erinnerungen Generierung",
|
||||
"metadata_extraction_job": "Metadaten extrahieren"
|
||||
},
|
||||
"video_quality": "Videoqualität",
|
||||
"videos": "Videos",
|
||||
|
||||
+27
-4
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"about": "Σχετικά",
|
||||
"accept": "Αποδοχή",
|
||||
"account": "Λογαριασμός",
|
||||
"account_settings": "Ρυθμίσεις Λογαριασμού",
|
||||
"acknowledge": "Έλαβα γνώση",
|
||||
@@ -29,11 +30,13 @@
|
||||
"add_to_album_bottom_sheet_added": "Προστέθηκε στο {album}",
|
||||
"add_to_album_bottom_sheet_already_exists": "Ήδη στο {album}",
|
||||
"add_to_album_bottom_sheet_some_local_assets": "Ορισμένα τοπικά στοιχεία δεν μπόρεσαν να προστεθούν στο άλμπουμ",
|
||||
"add_to_album_item_count": "Προσθήκη {αριθμός, πληθυντικός, ένα {#αντικείμενο} άλλο {#αντικείμενα}} στο άλμπουμ",
|
||||
"add_to_albums": "Προσθήκη στα άλμπουμ",
|
||||
"add_to_albums_count": "Προσθήκη στα άλμπουμ ({count})",
|
||||
"add_to_bottom_bar": "Προσθήκη σε",
|
||||
"add_upload_to_stack": "Προσθήκη αρχείου στην ουρά",
|
||||
"add_url": "Προσθήκη Συνδέσμου",
|
||||
"add_user": "Προσθήκη χρήστη",
|
||||
"added_to_archive": "Προστέθηκε στο αρχείο",
|
||||
"added_to_favorites": "Προστέθηκε στα αγαπημένα",
|
||||
"added_to_favorites_count": "Προστέθηκαν {count, number} στα αγαπημένα",
|
||||
@@ -290,6 +293,8 @@
|
||||
"notification_enable_email_notifications": "Ενεργοποίηση ειδοποιήσεων μέσω email",
|
||||
"notification_settings": "Ρυθμίσεις ειδοποιήσεων",
|
||||
"notification_settings_description": "Διαχείρηση ρυθμίσεων ειδοποιήσεων, συμπεριλαμβανομένου του email",
|
||||
"oauth_account_management_url": "URL Διαχείρισης Λογαριασμού",
|
||||
"oauth_account_management_url_description": "Τοποθεσία στον πάροχο εξωτερικής ταυτοποίησης όπου ο χρήστης μπορεί να διαχειριστεί τις ρυθμίσεις ή το προφίλ του.",
|
||||
"oauth_allow_insecure_requests": "Να επιτρέπονται μη ασφαλή αιτήματα",
|
||||
"oauth_allow_insecure_requests_description": "ΠΡΟΕΙΔΟΠΟΙΗΣΗ: Αυτό απενεργοποιεί την επαλήθευση πιστοποιητικών TLS για αιτήματα OAuth και μπορεί να σας εκθέσει σε επιθέσεις MITM.",
|
||||
"oauth_auto_launch": "Αυτόματη εκκίνηση",
|
||||
@@ -318,6 +323,13 @@
|
||||
"oauth_timeout": "Χρονικό όριο Αιτήματος",
|
||||
"oauth_timeout_description": "Χρονικό όριο Αιτήματος σε milliseconds",
|
||||
"ocr_job_description": "Χρήση μηχανικής μάθησης για αναγνώριση κειμένου σε εικόνες",
|
||||
"passkey_additional_domains": "Επιπλέον τομείς",
|
||||
"passkey_additional_domains_description": "Επιπλέον, έμπιστοι τομείς (αιτήματα από σχετική προέλευση). Αυτοί θα λειτουργήσουν μόνο εάν είναι προσβάσιμος και ο τομέας του κλειδιού πρόσβασης (passkey).",
|
||||
"passkey_domain": "Τομέας κλειδιού πρόσβασης (Passkey)",
|
||||
"passkey_domain_description": "Τομέας που χρησιμοποιείται για τα κλειδιά πρόσβασης (passkeys) (εμπιστευόμενο μέρος). Αφήστε το κενό για να προσδιοριστεί αυτόματα από την κεφαλίδα προέλευσης. Η αλλαγή αυτής της τιμής θα έχει ως αποτέλεσμα τα υπάρχοντα κλειδιά πρόσβασης να πάψουν να λειτουργούν.",
|
||||
"passkey_enable_description": "Σύνδεση με κλειδί πρόσβασης (passkey)",
|
||||
"passkey_settings": "Είσοδος με κλειδί πρόσβασης",
|
||||
"passkey_settings_description": "Διαχείριση ρυθμίσεων σύνδεσης με κλειδί πρόσβασης",
|
||||
"password_enable_description": "Σύνδεση με ηλεκτρονικό ταχυδρομείο",
|
||||
"password_settings": "Σύνδεση με κωδικό",
|
||||
"password_settings_description": "Διαχείριση ρυθμίσεων σύνδεσης μέσω κωδικού πρόσβασης",
|
||||
@@ -363,10 +375,17 @@
|
||||
"storage_template_migration_info": "Το πρότυπο αποθήκευσης θα μετατρέψει όλες τις επεκτάσεις σε πεζά γράμματα. Οι αλλαγές στο πρότυπο θα ισχύουν μόνο για νέα περιουσιακά στοιχεία. Για να εφαρμόσετε αναδρομικά το πρότυπο σε περιουσιακά στοιχεία που έχουν μεταφορτωθεί προηγουμένως, εκτελέστε το <link>{job}</link>.",
|
||||
"storage_template_migration_job": "Εργασία Μεταφοράς Προτύπων Αποθήκευσης",
|
||||
"storage_template_more_details": "Για περισσότερες λεπτομέρειες σχετικά με αυτήν τη δυνατότητα, ανατρέξτε στο <template-link>Πρότυπο Αποθήκευσης</template-link> και στις <implications-link>συνέπειές</implications-link> του",
|
||||
"storage_template_onboarding_description_v3": "Τα πρότυπα αποθήκευσης σας επιτρέπουν να ορίσετε έναν προσαρμοσμένο φάκελο στον δίσκο για τα αρχεία που ανεβάζετε. Αυτό μπορεί πάντα να αλλαχτεί ξανά αργότερα.",
|
||||
"storage_template_option_custom": "Προσαρμοσμένος",
|
||||
"storage_template_option_custom_description": "Δημιουργήστε την δική σας δομή φακέλων με τις πρότυπες μεταβλητές",
|
||||
"storage_template_option_date_description": "Ταξινομήστε τα αρχεία μέσα σε φακέλους ανά χρόνο, μήνα και ημέρα",
|
||||
"storage_template_option_none": "Κανένα πρότυπο",
|
||||
"storage_template_option_none_description": "Διατήρηση της δομής φακέλων όπως την χρησιμοποιεί το Immich εξ'ορισμού",
|
||||
"storage_template_path_length": "Όριο μήκους διαδρομής: <b>{length, number}</b>/{limit, number}, κατά προσέγγιση",
|
||||
"storage_template_settings": "Πρότυπο αποθήκευσης",
|
||||
"storage_template_settings_description": "Διαχείριση της δομής φακέλου και του ονόματος, του ανεβασμένου αρχείου",
|
||||
"storage_template_user_label": "<code>{label}</code> είναι η Ετικέτα Αποθήκευσης του χρήστη",
|
||||
"storage_template_variables": "Πρότυπες μεταβλητές",
|
||||
"system_settings": "Ρυθμίσεις Συστήματος",
|
||||
"tag_cleanup_job": "Καθαρισμός ετικετών",
|
||||
"template_email_available_tags": "Μπορείτε να χρησιμοποιήσετε τις εξής μεταβλητές στο πρότυπό σας: {tags}",
|
||||
@@ -489,6 +508,7 @@
|
||||
"admin_password": "Κωδικός πρόσβασης Διαχειριστή",
|
||||
"administration": "Διαχείριση",
|
||||
"advanced": "Για προχωρημένους",
|
||||
"advanced_filters": "Προηγμένα φίλτρα",
|
||||
"advanced_settings_clear_image_cache": "Καθαρισμός προσωρινής μνήμης εικόνων",
|
||||
"advanced_settings_clear_image_cache_error": "Αποτυχία καθαρισμού προσωρινής μνήμης εικόνων",
|
||||
"advanced_settings_clear_image_cache_success": "Επιτυχής εκκαθάριση {size}",
|
||||
@@ -543,6 +563,7 @@
|
||||
"allow_public_user_to_download": "Επιτρέψτε σε δημόσιο χρήστη να κατεβάσει",
|
||||
"allow_public_user_to_upload": "Επιτρέψτε στον δημόσιο χρήστη να ανεβάσει",
|
||||
"allowed": "Επιτρεπόμενο",
|
||||
"also_known_as": "γνωστό και ως",
|
||||
"alt_text_qr_code": "Εικόνα κωδικού QR",
|
||||
"always_keep": "Διατήρηση πάντα",
|
||||
"always_keep_photos_hint": "Η «Απελευθέρωση χώρου» θα κρατήσει όλες τις φωτογραφίες σε αυτήν τη συσκευή.",
|
||||
@@ -655,6 +676,7 @@
|
||||
"biometric_no_options": "Δεν υπάρχουν διαθέσιμοι τρόποι βιομετρικής ταυτοποίησης",
|
||||
"biometric_not_available": "Δεν υπάρχει διαθέσιμη βιομετρική ταυτοποίηση σε αυτή τη συσκευή",
|
||||
"birthdate_set_description": "Η ημερομηνία γέννησης χρησιμοποιείται για τον υπολογισμό της ηλικίας αυτού του ατόμου, τη χρονική στιγμή μιας φωτογραφίας.",
|
||||
"birthday_memory_title": "Του/Της {name} τα γενέθλια",
|
||||
"blurred_background": "Θολό φόντο",
|
||||
"browse_templates": "Περιήγηση σε πρότυπα",
|
||||
"bugs_and_feature_requests": "Σφάλματα & Αιτήματα Λειτουργιών",
|
||||
@@ -719,6 +741,8 @@
|
||||
"clear_all_recent_searches": "Εκκαθάριση όλων των πρόσφατων αναζητήσεων",
|
||||
"clear_failed_count": "Αποτυχία εκκαθάρισης ({count})",
|
||||
"clear_file_cache": "Εκκαθάριση της Προσωρινής Μνήμης Αρχείων",
|
||||
"clear_file_cache_error": "Αποτυχία διαγραφής προσωρινής μνήμης αρχείων",
|
||||
"clear_file_cache_success": "Έγινε εκκαθάριση της προσωρινής μνήμης αρχείων",
|
||||
"clear_message": "Εκκαθάριση μηνύματος",
|
||||
"clear_value": "Εκκαθάριση τιμής",
|
||||
"client_cert_import": "Εισαγωγή",
|
||||
@@ -730,6 +754,9 @@
|
||||
"client_cert_subtitle": "Υποστηρίζει μόνο τη μορφή PKCS12 (.p12, .pfx). Η εισαγωγή/αφαίρεση πιστοποιητικού είναι διαθέσιμη μόνο πριν από τη σύνδεση",
|
||||
"client_cert_title": "Πιστοποιητικό SSL πελάτη [ΠΕΙΡΑΜΑΤΙΚΟ]",
|
||||
"close": "Κλείσιμο",
|
||||
"cluster_group_description": "Τα άτομα αναγνωρίζονται στις φωτογραφίες όλων των μελών του γκρουπ",
|
||||
"cluster_group_facial_recognition": "Επαναφορά αναγνώρισης προσώπου",
|
||||
"cluster_group_facial_recognition_prompt": "Επαναφέροντας την αναγνώριση προσώπου θα διαγραφούν όλα τα άτομα για όλους τους χρήστες σε αυτό το γκρουπ. Θα χαθούν και τα ήδη ρυθμισμένα ονόματα και ημερομηνίες γέννησης. Είστε σίγουρος ότι θέλετε να συνεχίσετε?",
|
||||
"collapse": "Σύμπτυξη",
|
||||
"collapse_all": "Σύμπτυξη όλων",
|
||||
"color": "Χρώμα",
|
||||
@@ -1204,7 +1231,6 @@
|
||||
"image_alt_text_date_place_3_people": "{isVideo, select, true {Βίντεο} other {Εικόνα}} τραβηγμένο στην/στον {city}, {country} με τον/την {person1}, τον/την {person2} και τον/την {person3} στις {date}",
|
||||
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Βίντεο} other {Εικόνα}} τραβηγμένο στον/στην {city}, {country} με τον/την {person1}, {person2}, και {additionalCount, number} άλλους στις {date}",
|
||||
"immich_logo": "Λογότυπο Immich",
|
||||
"immich_web_interface": "Ιστότοπος Immich",
|
||||
"import_from_json": "Εισαγωγή από αρχείο JSON",
|
||||
"import_path": "Εισαγωγή διαδρομής",
|
||||
"in_albums": "Μέσα σε {count, plural, one {# άλμπουμ} other {# άλμπουμ}}",
|
||||
@@ -1908,8 +1934,6 @@
|
||||
"setting_image_viewer_help": "Το πρόγραμμα προβολής λεπτομερειών φορτώνει πρώτα τη μικρογραφία, στη συνέχεια φορτώνει την προεπισκόπηση μεσαίου μεγέθους (αν είναι ενεργοποιημένη), τέλος φορτώνει το πρωτότυπο (αν είναι ενεργοποιημένο).",
|
||||
"setting_image_viewer_original_subtitle": "Ενεργοποιήστε τη φόρτωση της πρωτότυπης εικόνας πλήρους ανάλυσης (μεγάλη!). Απενεργοποιήστε για να μειώσετε τη χρήση δεδομένων (τόσο στο δίκτυο όσο και στην κρυφή μνήμη της συσκευής).",
|
||||
"setting_image_viewer_original_title": "Φόρτωση πρωτότυπης εικόνας",
|
||||
"setting_image_viewer_preview_subtitle": "Ενεργοποιήστε τη φόρτωση μιας εικόνας μέσης ανάλυσης. Απενεργοποιήστε είτε για να φορτώνεται απευθείας το πρωτότυπο είτε για να χρησιμοποιείται μόνο η μικρογραφία.",
|
||||
"setting_image_viewer_preview_title": "Φόρτωση εικόνας προεπισκόπησης",
|
||||
"setting_languages_apply": "Εφαρμογή",
|
||||
"setting_languages_subtitle": "Αλλάξτε τη γλώσσα της εφαρμογής",
|
||||
"setting_notifications_notify_minutes": "{count} λεπτά",
|
||||
@@ -1975,7 +1999,6 @@
|
||||
"shared_link_password_description": "Απαίτηση κωδικού για πρόσβαση στον κοινοποιημένο σύνδεσμο",
|
||||
"shared_links": "Κοινόχρηστοι σύνδεσμοι",
|
||||
"shared_links_description": "Μοιραστείτε φωτογραφίες και βίντεο με σύνδεσμο",
|
||||
"shared_photos_and_videos_count": "{assetCount, plural, other {# κοινόχρηστες φωτογραφίες & βίντεο.}}",
|
||||
"shared_with_me": "Μοιρασμένα μαζί μου",
|
||||
"shared_with_partner": "Σε κοινή χρήση με {partner}",
|
||||
"sharing": "Κοινοποίηση",
|
||||
|
||||
+72
-8
@@ -62,6 +62,12 @@
|
||||
"backup_onboarding_title": "Backups",
|
||||
"backup_settings": "Database Dump Settings",
|
||||
"backup_settings_description": "Manage database dump settings.",
|
||||
"backups": "Backups",
|
||||
"backups_attempts": "Backup attempts",
|
||||
"backups_hide_reminder": "Hide backups reminder",
|
||||
"backups_hide_reminder_description": "If you already manage backups outside of Immich, you can hide the backup setup reminder.",
|
||||
"backups_settings": "Backup settings",
|
||||
"backups_snapshots": "Snapshots",
|
||||
"cleared_jobs": "Cleared jobs for: {job}",
|
||||
"config_set_by_file": "Config is currently set by a config file",
|
||||
"confirm_delete_library": "Are you sure you want to delete {library} library?",
|
||||
@@ -87,7 +93,7 @@
|
||||
"failed_job_command": "Command {command} failed for job: {job}",
|
||||
"force_delete_user_warning": "WARNING: This will immediately remove the user and all assets. This cannot be undone and the files cannot be recovered.",
|
||||
"image_format": "Format",
|
||||
"image_format_description": "WebP produces smaller files than JPEG, but is slower to encode.",
|
||||
"image_format_description": "WebP produces smaller files than JPEG, but is slower to encode. JPEG XL is the best option for high-quality compression, but not all browsers and apps can display it.",
|
||||
"image_fullsize_description": "Full-size image with stripped metadata, used when zoomed in",
|
||||
"image_fullsize_enabled": "Enable full-size image generation",
|
||||
"image_fullsize_enabled_description": "Generate full-size image for non-web-friendly formats. When \"Prefer embedded preview\" is enabled, embedded previews are used directly without conversion. Does not affect web-friendly formats like JPEG.",
|
||||
@@ -101,7 +107,7 @@
|
||||
"image_preview_quality_description": "Preview quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness. Setting a low value may affect machine learning quality.",
|
||||
"image_preview_title": "Preview Settings",
|
||||
"image_progressive": "Progressive",
|
||||
"image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on WebP images.",
|
||||
"image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on other formats.",
|
||||
"image_quality": "Quality",
|
||||
"image_resolution": "Resolution",
|
||||
"image_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes and can reduce app responsiveness.",
|
||||
@@ -324,6 +330,13 @@
|
||||
"oauth_timeout": "Request Timeout",
|
||||
"oauth_timeout_description": "Timeout for requests in milliseconds",
|
||||
"ocr_job_description": "Use machine learning to recognize text in images",
|
||||
"passkey_additional_domains": "Additional domains",
|
||||
"passkey_additional_domains_description": "Additional, trusted domains (related origin requests). These will only work if the passkey domain is also accessible.",
|
||||
"passkey_domain": "Passkey domain",
|
||||
"passkey_domain_description": "Domain used for passkeys (relying party). Leave blank to infer from the origin header. Changing this value will result in existing passkeys no longer working.",
|
||||
"passkey_enable_description": "Login with a passkey",
|
||||
"passkey_settings": "Passkey Login",
|
||||
"passkey_settings_description": "Manage passkey login settings",
|
||||
"password_enable_description": "Login with email and password",
|
||||
"password_settings": "Password Login",
|
||||
"password_settings_description": "Manage password login settings",
|
||||
@@ -874,6 +887,8 @@
|
||||
"delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only",
|
||||
"delete_local_dialog_ok_force": "Delete Anyway",
|
||||
"delete_others": "Delete others",
|
||||
"delete_passkey": "Delete passkey",
|
||||
"delete_passkey_prompt": "Are you sure you want to delete this passkey?",
|
||||
"delete_permanently": "Delete permanently",
|
||||
"delete_permanently_action_prompt": "{count} deleted permanently",
|
||||
"delete_shared_link": "Delete shared link",
|
||||
@@ -943,6 +958,7 @@
|
||||
"edit_location_action_prompt": "{count} location edited",
|
||||
"edit_location_dialog_title": "Location",
|
||||
"edit_name": "Edit name",
|
||||
"edit_passkey": "Edit passkey",
|
||||
"edit_people": "Edit people",
|
||||
"edit_person": "Edit person",
|
||||
"edit_tag": "Edit tag",
|
||||
@@ -1013,6 +1029,7 @@
|
||||
"failed_to_keep_this_delete_others": "Failed to keep this asset and delete the other assets",
|
||||
"failed_to_load_asset": "Failed to load asset",
|
||||
"failed_to_load_assets": "Failed to load assets",
|
||||
"failed_to_load_database_backups": "Failed to load database backups",
|
||||
"failed_to_load_notifications": "Failed to load notifications",
|
||||
"failed_to_load_people": "Failed to load people",
|
||||
"failed_to_remove_product_key": "Failed to remove product key",
|
||||
@@ -1063,7 +1080,6 @@
|
||||
"unable_to_empty_trash": "Unable to empty trash",
|
||||
"unable_to_enter_fullscreen": "Unable to enter fullscreen",
|
||||
"unable_to_exit_fullscreen": "Unable to exit fullscreen",
|
||||
"unable_to_get_comments_number": "Unable to get number of comments",
|
||||
"unable_to_get_shared_link": "Failed to get shared link",
|
||||
"unable_to_hide_person": "Unable to hide person",
|
||||
"unable_to_leave_cluster_group": "Unable to leave cluster group",
|
||||
@@ -1158,6 +1174,31 @@
|
||||
"file_name_or_extension": "File name or extension",
|
||||
"file_name_text": "File name",
|
||||
"file_size": "File size",
|
||||
"file_vault": {
|
||||
"conflict": "This name already exists. Rename the item or choose another folder.",
|
||||
"description": "Personal documents and work folders",
|
||||
"drop": "Drop files or folders here",
|
||||
"empty": "This folder is empty",
|
||||
"empty_trash": "Trash is empty",
|
||||
"error": "The action failed. Check your connection and try again.",
|
||||
"folder_name": "Folder name",
|
||||
"invalid": "Check the name and destination folder. Names cannot contain slash characters.",
|
||||
"limit": "Maximum file size",
|
||||
"move_to": "Move to folder",
|
||||
"new_folder": "New folder",
|
||||
"private": "Only you can access these files.",
|
||||
"purge_confirm": "Delete permanently? This item and its contents cannot be restored.",
|
||||
"quota": "Insufficient storage space or the maximum file size was exceeded.",
|
||||
"root": "My files",
|
||||
"selected": "Files selected",
|
||||
"title": "Files",
|
||||
"trash_confirm": "Move to trash? The folder and its contents will be moved together.",
|
||||
"unavailable": "This file or folder is unavailable. Refresh the list.",
|
||||
"upload_files": "Upload files",
|
||||
"upload_folder": "Upload folder",
|
||||
"uploading": "Uploading",
|
||||
"used": "Used"
|
||||
},
|
||||
"filename": "Filename",
|
||||
"filetype": "Filetype",
|
||||
"filter": "Filter",
|
||||
@@ -1287,6 +1328,7 @@
|
||||
"last": "Last",
|
||||
"last_months": "{count, plural, one {Last month} other {Last # months}}",
|
||||
"last_seen": "Last seen",
|
||||
"last_used": "Last used",
|
||||
"latest_version": "Latest Version",
|
||||
"latitude": "Latitude",
|
||||
"leave": "Leave",
|
||||
@@ -1300,10 +1342,6 @@
|
||||
"library": "Library",
|
||||
"library_add_folder": "Add folder",
|
||||
"library_edit_folder": "Edit folder",
|
||||
"library_page_sort_asset_count": "Number of assets",
|
||||
"library_page_sort_created": "Created date",
|
||||
"library_page_sort_last_modified": "Last modified",
|
||||
"library_page_sort_title": "Album title",
|
||||
"licenses": "Licenses",
|
||||
"light": "Light",
|
||||
"light_theme": "Switch to light theme",
|
||||
@@ -1379,6 +1417,7 @@
|
||||
"login_has_been_disabled": "Login has been disabled",
|
||||
"login_password_changed_error": "There was an error updating your password",
|
||||
"login_password_changed_success": "Password updated successfully",
|
||||
"login_with_passkey": "Login with passkey",
|
||||
"logout_all_device_confirmation": "Are you sure you want to log out all devices?",
|
||||
"logout_this_device_confirmation": "Are you sure you want to log out this device?",
|
||||
"logs": "Logs",
|
||||
@@ -1389,10 +1428,12 @@
|
||||
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
|
||||
"main_menu": "Main menu",
|
||||
"maintenance_action_restore": "Restoring Database",
|
||||
"maintenance_action_rollback": "Restoring from Backup",
|
||||
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
|
||||
"maintenance_end": "End maintenance mode",
|
||||
"maintenance_logged_in_as": "Currently logged in as {user}",
|
||||
"maintenance_restore_from_backup": "Restore From Backup",
|
||||
"maintenance_restore_latest_backup_description": "We'll restore your database from the most recent backup. You can also pick a different one.",
|
||||
"maintenance_restore_library": "Restore Your Library",
|
||||
"maintenance_restore_library_confirm": "If this looks correct, continue to restoring a backup!",
|
||||
"maintenance_restore_library_description": "Restoring Database",
|
||||
@@ -1405,6 +1446,13 @@
|
||||
"maintenance_restore_library_hint_regenerate_later": "You can regenerate these later in settings",
|
||||
"maintenance_restore_library_hint_storage_template_missing_files": "Using storage template? You may be missing files",
|
||||
"maintenance_restore_library_loading": "Loading integrity checks and heuristics…",
|
||||
"maintenance_restore_loading_backups": "Loading backups…",
|
||||
"maintenance_restore_no_backups": "There are no database backups.",
|
||||
"maintenance_restore_select_another": "Select another backup",
|
||||
"maintenance_restore_source_database_backup": "Database Backup",
|
||||
"maintenance_restore_source_futo_backups": "FUTO Backups",
|
||||
"maintenance_restore_source_title": "Where would you like to restore from?",
|
||||
"maintenance_restore_upload_backup": "Upload a backup",
|
||||
"maintenance_task_backup": "Creating a backup of the existing database…",
|
||||
"maintenance_task_migrations": "Running database migrations…",
|
||||
"maintenance_task_restore": "Restoring the chosen backup…",
|
||||
@@ -1427,6 +1475,7 @@
|
||||
"manage_your_api_keys": "Manage your API keys",
|
||||
"manage_your_devices": "Manage your logged-in devices",
|
||||
"manage_your_oauth_connection": "Manage your OAuth connection",
|
||||
"manage_your_passkeys": "Manage your passkeys",
|
||||
"map": "Map",
|
||||
"map_assets_in_bounds": "{count, plural, =0 {No photos in this area} one {# photo} other {# photos}}",
|
||||
"map_cannot_get_user_location": "Cannot get user's location",
|
||||
@@ -1546,6 +1595,7 @@
|
||||
"new_album": "New Album",
|
||||
"new_api_key": "New API Key",
|
||||
"new_feature": "New Feature",
|
||||
"new_passkey": "New passkey",
|
||||
"new_password": "New password",
|
||||
"new_person": "New person",
|
||||
"new_pin_code": "New PIN code",
|
||||
@@ -1658,6 +1708,11 @@
|
||||
"partner_sharing": "Partner Sharing",
|
||||
"partners": "Partners",
|
||||
"partners_description": "Share your photos and videos with other users",
|
||||
"passkey_added": "Passkey added",
|
||||
"passkeys": "{count, plural, one {Passkey} other {Passkeys}}",
|
||||
"passkeys_not_supported": "Passkeys are not supported by this browser",
|
||||
"passkeys_on_server": "Passkeys registered with the Immich server",
|
||||
"passkeys_on_server_description": "Note: Removing a passkey from the Immich server does not remove it from your device",
|
||||
"password": "Password",
|
||||
"password_does_not_match": "Password does not match",
|
||||
"password_required": "Password Required",
|
||||
@@ -1773,6 +1828,8 @@
|
||||
"purchase_panel_title": "Support the project",
|
||||
"purchase_per_server": "Per server",
|
||||
"purchase_per_user": "Per user",
|
||||
"purchase_product_key_description": "Product keys support Immich development and give you a supporter badge.",
|
||||
"purchase_product_key_excludes_backups": "Product keys do not include FUTO Backups.",
|
||||
"purchase_remove_product_key": "Remove Product Key",
|
||||
"purchase_remove_product_key_prompt": "Are you sure you want to remove the product key?",
|
||||
"purchase_remove_server_product_key": "Remove Server product key",
|
||||
@@ -1842,6 +1899,7 @@
|
||||
"removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",
|
||||
"removed_from_stack": "Removed asset from stack",
|
||||
"removed_memory": "Removed memory",
|
||||
"removed_passkey": "Removed passkey: {name}",
|
||||
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
|
||||
"rename": "Rename",
|
||||
"repository": "Repository",
|
||||
@@ -2025,6 +2083,7 @@
|
||||
"share_everyone": "Share everyone",
|
||||
"share_everyone_description": "Give {user} access to all of your people.",
|
||||
"share_link": "Share Link",
|
||||
"share_logs": "Share Logs",
|
||||
"share_original": "Use original (large)",
|
||||
"share_preview": "Use preview (small)",
|
||||
"share_quality_body": "Press and hold the share button to choose the image quality before you share.",
|
||||
@@ -2173,6 +2232,7 @@
|
||||
"sunrise_on_the_beach": "Sunrise on the beach",
|
||||
"support": "Support",
|
||||
"support_and_feedback": "Support & Feedback",
|
||||
"support_ensure_latest": "Before reaching out through official Immich support channels, please ensure you are using the <link>latest version</link> of Immich.",
|
||||
"support_third_party_description": "Your Immich installation was packaged by a third-party. Issues you experience may be caused by that package, so please raise issues with them in the first instance using the links below.",
|
||||
"supporter": "Supporter",
|
||||
"swap_merge_direction": "Swap merge direction",
|
||||
@@ -2203,6 +2263,8 @@
|
||||
"theme": "Theme",
|
||||
"theme_selection": "Theme selection",
|
||||
"theme_selection_description": "Automatically set the theme to light or dark based on your browser's system preference",
|
||||
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Scroll continuously when dragging the scrollbar instead of snapping to months",
|
||||
"theme_setting_asset_list_smooth_scrubbing_title": "Smooth timeline scrubbing",
|
||||
"theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles",
|
||||
"theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({count})",
|
||||
"theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.",
|
||||
@@ -2243,6 +2305,8 @@
|
||||
"trigger": "Trigger",
|
||||
"trigger_asset_metadata_extraction": "Asset Metadata Extraction",
|
||||
"trigger_asset_metadata_extraction_description": "Triggered when the EXIF metadata of an asset is extracted",
|
||||
"trigger_asset_ocr_extraction": "Asset OCR Extraction",
|
||||
"trigger_asset_ocr_extraction_description": "Triggered when the OCR text of an asset is extracted",
|
||||
"trigger_asset_tagged": "Asset Tagged",
|
||||
"trigger_asset_tagged_description": "Triggered when a tag is added to an asset",
|
||||
"trigger_asset_uploaded": "Asset Upload",
|
||||
@@ -2318,7 +2382,7 @@
|
||||
"user": "User",
|
||||
"user_has_been_deleted": "This user has been deleted.",
|
||||
"user_id": "User ID",
|
||||
"user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}",
|
||||
"user_liked": "{user} liked {type, select, image {this photo} video {this video} other {this}}",
|
||||
"user_pin_code_settings": "PIN Code",
|
||||
"user_pin_code_settings_description": "Manage your PIN code",
|
||||
"user_privacy": "User Privacy",
|
||||
|
||||
+6
-4
@@ -368,6 +368,12 @@
|
||||
"storage_template_migration_info": "The storage template will convert all extensions to lowercase. Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the <link>{job}</link>.",
|
||||
"storage_template_migration_job": "Storage Template Migration Job",
|
||||
"storage_template_more_details": "For more details about this feature, refer to the <template-link>Storage Template</template-link> and its <implications-link>implications</implications-link>",
|
||||
"storage_template_onboarding_description_v3": "Storage templates allow you to define a custom folder structure on disk for your uploaded assets. This can always be changed later.",
|
||||
"storage_template_option_custom": "Custom",
|
||||
"storage_template_option_custom_description": "Build your own folder structure with template variables",
|
||||
"storage_template_option_date_description": "Sort files into year, month, and day folders",
|
||||
"storage_template_option_none": "No template",
|
||||
"storage_template_option_none_description": "Keep the folder structure Immich uses by default",
|
||||
"storage_template_path_length": "Approximate path length limit: <b>{length, number}</b>/{limit, number}",
|
||||
"storage_template_settings": "Storage Template",
|
||||
"storage_template_settings_description": "Manage the folder structure and file name of the upload asset",
|
||||
@@ -1224,7 +1230,6 @@
|
||||
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} taken in {city}, {country} with {person1}, {person2}, and {person3} on {date}",
|
||||
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} taken in {city}, {country} with {person1}, {person2}, and {additionalCount, number} others on {date}",
|
||||
"immich_logo": "Immich Logo",
|
||||
"immich_web_interface": "Immich Web Interface",
|
||||
"import_from_json": "Import from JSON",
|
||||
"import_path": "Import path",
|
||||
"in_albums": "In {count, plural, one {# album} other {# albums}}",
|
||||
@@ -1958,8 +1963,6 @@
|
||||
"setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).",
|
||||
"setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).",
|
||||
"setting_image_viewer_original_title": "Load original image",
|
||||
"setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.",
|
||||
"setting_image_viewer_preview_title": "Load preview image",
|
||||
"setting_languages_apply": "Apply",
|
||||
"setting_languages_subtitle": "Change the app's language",
|
||||
"setting_notifications_notify_minutes": "{count} minutes",
|
||||
@@ -2025,7 +2028,6 @@
|
||||
"shared_link_password_description": "Require a password to access this shared link",
|
||||
"shared_links": "Shared links",
|
||||
"shared_links_description": "Share photos and videos with a link",
|
||||
"shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}",
|
||||
"shared_with_me": "Shared with me",
|
||||
"shared_with_partner": "Shared with {partner}",
|
||||
"sharing": "Sharing",
|
||||
|
||||
Loaded 100 of 1649 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user