mirror of
https://github.com/immich-app/immich.git
synced 2025-12-08 05:41:04 -08:00
Compare commits
300 Commits
fix/drift-
...
feat/editi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2fb6cb7e4 | ||
|
|
62030c7e4c | ||
|
|
dd2c19233d | ||
|
|
e144161d51 | ||
|
|
40326b434e | ||
|
|
06b69d9258 | ||
|
|
01fb712691 | ||
|
|
1565d15537 | ||
|
|
bbba1bfe8c | ||
|
|
4be9a5ebf8 | ||
|
|
d41921247b | ||
|
|
853a024f0f | ||
|
|
4fe494776e | ||
|
|
76b4adf276 | ||
|
|
75dde0d076 | ||
|
|
cffb68d1c4 | ||
|
|
45f68f73a9 | ||
|
|
4f93eda8d8 | ||
|
|
f5df5fa98d | ||
|
|
f07d1441ea | ||
|
|
1bcf28c062 | ||
|
|
62628dfcfa | ||
|
|
b11aecd184 | ||
|
|
116012f6f8 | ||
|
|
7594136050 | ||
|
|
bb341cc774 | ||
|
|
af1d4afb95 | ||
|
|
75b1ef2c57 | ||
|
|
1e37f7c8c8 | ||
|
|
a32f450059 | ||
|
|
b452ab463b | ||
|
|
79bed80226 | ||
|
|
6249996cdb | ||
|
|
a3f281caa3 | ||
|
|
7c19b0591f | ||
|
|
95c29a8aea | ||
|
|
d8ca210641 | ||
|
|
ab35afd3b1 | ||
|
|
65e4fdf98d | ||
|
|
fa43fae2a5 | ||
|
|
46afd6a101 | ||
|
|
46e1967760 | ||
|
|
922282b2b4 | ||
|
|
e3ab16a5bd | ||
|
|
08f320c801 | ||
|
|
e36261b552 | ||
|
|
c0a3b58bba | ||
|
|
f12f609038 | ||
|
|
1f6eb662e5 | ||
|
|
0c1fe35f2f | ||
|
|
e98a33cf9d | ||
|
|
d38305360c | ||
|
|
3e3ca4c104 | ||
|
|
81edf0749f | ||
|
|
01f83ae964 | ||
|
|
5eec0dc981 | ||
|
|
ca4fd07656 | ||
|
|
7ce43b3824 | ||
|
|
ce00119926 | ||
|
|
fffee80e2f | ||
|
|
64cd4e96e3 | ||
|
|
955a3bfaa6 | ||
|
|
e699d8f170 | ||
|
|
13104d49cd | ||
|
|
2d5ec528d5 | ||
|
|
5226898184 | ||
|
|
dd4169876c | ||
|
|
8321c275b8 | ||
|
|
3d6c26350a | ||
|
|
db15e5e423 | ||
|
|
35d18da14a | ||
|
|
cb56a11f0b | ||
|
|
104fa09f69 | ||
|
|
66ae07ee39 | ||
|
|
939d2c8b27 | ||
|
|
2801a6e672 | ||
|
|
4742360469 | ||
|
|
b56fa62b32 | ||
|
|
ddbe485074 | ||
|
|
01310c6d86 | ||
|
|
512327ef69 | ||
|
|
8755cd59fd | ||
|
|
7694b342ed | ||
|
|
78553a0258 | ||
|
|
c1198b99b7 | ||
|
|
8b7b9ee394 | ||
|
|
d6b39a464d | ||
|
|
75d23fe135 | ||
|
|
c860809aa1 | ||
|
|
0498f6cb9d | ||
|
|
24e5dabb51 | ||
|
|
aecf064ec9 | ||
|
|
57be3ff8c7 | ||
|
|
99505f987e | ||
|
|
1e1c4ac9d2 | ||
|
|
d952b62053 | ||
|
|
9f3eeed091 | ||
|
|
1dbc20fd77 | ||
|
|
ba8df712c4 | ||
|
|
741d838f56 | ||
|
|
ec2fa6e308 | ||
|
|
b974ed5735 | ||
|
|
78457d9b89 | ||
|
|
5d043b435e | ||
|
|
9a403d5886 | ||
|
|
1a31faf1a2 | ||
|
|
edbdc14178 | ||
|
|
e7261a04e1 | ||
|
|
acded69adf | ||
|
|
45a0315606 | ||
|
|
3856d4053c | ||
|
|
8175b3b75b | ||
|
|
56e431226f | ||
|
|
f59417cc77 | ||
|
|
11cec56e80 | ||
|
|
810f22057c | ||
|
|
2152f20b6c | ||
|
|
a6c76e78d6 | ||
|
|
644a3bf090 | ||
|
|
42dd3315f8 | ||
|
|
3a694219bf | ||
|
|
d9fd52ea18 | ||
|
|
2a281e7906 | ||
|
|
5f987a95f5 | ||
|
|
edf577d7f7 | ||
|
|
5e482dabc6 | ||
|
|
76c73549ae | ||
|
|
271a42ac7f | ||
|
|
4462952564 | ||
|
|
38d4d1a573 | ||
|
|
d310c6f3cd | ||
|
|
c086a65fa8 | ||
|
|
7134dd29ca | ||
|
|
3e08953a43 | ||
|
|
58c3c7e26b | ||
|
|
237ddcb648 | ||
|
|
fbaeffd65c | ||
|
|
d64c339b4f | ||
|
|
69880ee165 | ||
|
|
15e00f82f0 | ||
|
|
ce82e27f4b | ||
|
|
eeee5147cc | ||
|
|
af22f9b014 | ||
|
|
1086f22166 | ||
|
|
e94eb5012f | ||
|
|
4dcc049465 | ||
|
|
d784d431d0 | ||
|
|
1200bfad13 | ||
|
|
f11bfb9581 | ||
|
|
074fdb2b96 | ||
|
|
f1f203719d | ||
|
|
f73ca9d9c0 | ||
|
|
ad3f4fb434 | ||
|
|
8001dedcbf | ||
|
|
07a39226c5 | ||
|
|
88e7e21683 | ||
|
|
2cefbf8ca3 | ||
|
|
4a6c50cd81 | ||
|
|
e0535e20e6 | ||
|
|
62580455af | ||
|
|
0e7e67efe1 | ||
|
|
2c54b506b3 | ||
|
|
8969b8bdb2 | ||
|
|
5186092faa | ||
|
|
4c9142308f | ||
|
|
bea5d4fd37 | ||
|
|
74c24bfa88 | ||
|
|
95834c68d9 | ||
|
|
09024c3558 | ||
|
|
137cb043ef | ||
|
|
edf21bae41 | ||
|
|
c958f9856d | ||
|
|
70ab8bc657 | ||
|
|
edde0f93ae | ||
|
|
896665bca9 | ||
|
|
e8e9e7830e | ||
|
|
4fd9e42ce5 | ||
|
|
337e3a8dac | ||
|
|
2dc81e28fc | ||
|
|
f915d4cc90 | ||
|
|
905f4375b0 | ||
|
|
0b3633db4f | ||
|
|
2f40f5aad8 | ||
|
|
2611e2ec20 | ||
|
|
433a3cd339 | ||
|
|
0b487897a4 | ||
|
|
d5c5bdffcb | ||
|
|
dea95ac2e6 | ||
|
|
9e2208b8dd | ||
|
|
6922a92b69 | ||
|
|
7a2c8e0662 | ||
|
|
787158247f | ||
|
|
b0a0b7c2e1 | ||
|
|
cb6d81771d | ||
|
|
8de6ec1a1b | ||
|
|
d27c01ef70 | ||
|
|
d6307b262f | ||
|
|
b2cbefe41e | ||
|
|
da5a72f6de | ||
|
|
45304f1211 | ||
|
|
a4e65a7ea8 | ||
|
|
dd393c8346 | ||
|
|
493cde9d55 | ||
|
|
7705c84b04 | ||
|
|
ce0172b8c1 | ||
|
|
718b3a7b52 | ||
|
|
8a73de018c | ||
|
|
d92df63f84 | ||
|
|
6c6b00067b | ||
|
|
9cc88ed2a6 | ||
|
|
4905bba694 | ||
|
|
853d19dc2d | ||
|
|
c935ae47d0 | ||
|
|
93ab42fa24 | ||
|
|
6913697ad1 | ||
|
|
a4ae86ce29 | ||
|
|
2c50f2e244 | ||
|
|
365abd8906 | ||
|
|
25fb43bbe3 | ||
|
|
125e8cee01 | ||
|
|
c15e9bfa72 | ||
|
|
35e188e6e7 | ||
|
|
3cc9dd126c | ||
|
|
aa69d89b9f | ||
|
|
29c14a3f58 | ||
|
|
0df70365d7 | ||
|
|
c34be73d81 | ||
|
|
f396e9e374 | ||
|
|
821a9d4691 | ||
|
|
cad654586f | ||
|
|
28eb1bc13c | ||
|
|
1e4779cf48 | ||
|
|
0647c22956 | ||
|
|
b8087b4fa2 | ||
|
|
d94cb9641b | ||
|
|
517c3e1d4c | ||
|
|
619de2a5e4 | ||
|
|
79d0e3e1ed | ||
|
|
f5ff36a1f8 | ||
|
|
b5efc9c16e | ||
|
|
1036076b0d | ||
|
|
c76324c611 | ||
|
|
0ddb92e1ec | ||
|
|
d08a520aa2 | ||
|
|
7bdf0f6c50 | ||
|
|
2b33a58448 | ||
|
|
b35f00f768 | ||
|
|
86cc7c3c73 | ||
|
|
5854cbbe97 | ||
|
|
ceb36a304d | ||
|
|
f5d7e5acca | ||
|
|
be15a84f9b | ||
|
|
32791e98c2 | ||
|
|
7ea443b3a9 | ||
|
|
c69786b039 | ||
|
|
5c7d5539ea | ||
|
|
3531856d1c | ||
|
|
4abaad548a | ||
|
|
61a2c3ace3 | ||
|
|
e9038193db | ||
|
|
3f5cd48a59 | ||
|
|
4cb094e7ae | ||
|
|
57c8378ca7 | ||
|
|
b073f9b802 | ||
|
|
1a2e7d06cb | ||
|
|
217d719b0b | ||
|
|
cf75ad2f26 | ||
|
|
2286444158 | ||
|
|
b489bdf8d3 | ||
|
|
5e6087ea28 | ||
|
|
4ae7cadeae | ||
|
|
fdfb04d83c | ||
|
|
8273c822d7 | ||
|
|
12bb39a111 | ||
|
|
9098717c55 | ||
|
|
8d25f81bec | ||
|
|
52596255c8 | ||
|
|
106effca2e | ||
|
|
9676da27c9 | ||
|
|
3edcb180eb | ||
|
|
9f0b5790af | ||
|
|
e0c2cdddd4 | ||
|
|
74f2c10a5a | ||
|
|
fb97d9f4d9 | ||
|
|
f72bcc8a8f | ||
|
|
46a4dce16b | ||
|
|
62ed5fe27f | ||
|
|
8e3f6cdbbf | ||
|
|
d51b8c1cdf | ||
|
|
698531d6e0 | ||
|
|
44149d187f | ||
|
|
9e3b4ef3db | ||
|
|
ac0d646401 | ||
|
|
664a8fa499 | ||
|
|
3194538817 | ||
|
|
b0d427f8f9 | ||
|
|
02b29046b3 | ||
|
|
c666dc6c67 | ||
|
|
382481735a | ||
|
|
6bb1a9e083 |
@@ -29,6 +29,12 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||||
|
// https://github.com/devcontainers/features/issues/1466
|
||||||
|
"moby": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"forwardPorts": [3000, 9231, 9230, 2283],
|
"forwardPorts": [3000, 9231, 9230, 2283],
|
||||||
"portsAttributes": {
|
"portsAttributes": {
|
||||||
"3000": {
|
"3000": {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ services:
|
|||||||
- app-node_modules:/usr/src/app/node_modules
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
- coverage:/usr/src/app/web/coverage
|
- coverage:/usr/src/app/web/coverage
|
||||||
|
- ../plugins:/build/corePlugin
|
||||||
immich-web:
|
immich-web:
|
||||||
env_file: !reset []
|
env_file: !reset []
|
||||||
immich-machine-learning:
|
immich-machine-learning:
|
||||||
|
|||||||
2
.github/.nvmrc
vendored
2
.github/.nvmrc
vendored
@@ -1 +1 @@
|
|||||||
22.20.0
|
24.11.1
|
||||||
|
|||||||
2
.github/labeler.yml
vendored
2
.github/labeler.yml
vendored
@@ -31,7 +31,7 @@ documentation:
|
|||||||
🧠machine-learning:
|
🧠machine-learning:
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
- machine-learning/app/**
|
- machine-learning/**
|
||||||
|
|
||||||
changelog:translation:
|
changelog:translation:
|
||||||
- head-branch: ['^chore/translations$']
|
- head-branch: ['^chore/translations$']
|
||||||
|
|||||||
10
.github/mise.toml
vendored
Normal file
10
.github/mise.toml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[tasks.install]
|
||||||
|
run = "pnpm install --filter github --frozen-lockfile"
|
||||||
|
|
||||||
|
[tasks.format]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "prettier --check ."
|
||||||
|
|
||||||
|
[tasks."format-fix"]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "prettier --write ."
|
||||||
120
.github/workflows/build-mobile.yml
vendored
120
.github/workflows/build-mobile.yml
vendored
@@ -1,12 +1,16 @@
|
|||||||
name: Build Mobile
|
name: Build Mobile
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
ref:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
|
environment:
|
||||||
|
description: 'Target environment'
|
||||||
|
required: true
|
||||||
|
default: 'development'
|
||||||
|
type: string
|
||||||
secrets:
|
secrets:
|
||||||
KEY_JKS:
|
KEY_JKS:
|
||||||
required: true
|
required: true
|
||||||
@@ -16,6 +20,30 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
ANDROID_STORE_PASSWORD:
|
ANDROID_STORE_PASSWORD:
|
||||||
required: true
|
required: true
|
||||||
|
APP_STORE_CONNECT_API_KEY_ID:
|
||||||
|
required: true
|
||||||
|
APP_STORE_CONNECT_API_KEY_ISSUER_ID:
|
||||||
|
required: true
|
||||||
|
APP_STORE_CONNECT_API_KEY:
|
||||||
|
required: true
|
||||||
|
IOS_CERTIFICATE_P12:
|
||||||
|
required: true
|
||||||
|
IOS_CERTIFICATE_PASSWORD:
|
||||||
|
required: true
|
||||||
|
IOS_PROVISIONING_PROFILE:
|
||||||
|
required: true
|
||||||
|
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION:
|
||||||
|
required: true
|
||||||
|
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION:
|
||||||
|
required: true
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE:
|
||||||
|
required: true
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION:
|
||||||
|
required: true
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION:
|
||||||
|
required: true
|
||||||
|
FASTLANE_TEAM_ID:
|
||||||
|
required: true
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
@@ -58,8 +86,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
# Skip when PR from a fork
|
# Skip when PR from a fork
|
||||||
# if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||||
if: ${{ false }}
|
|
||||||
runs-on: mich
|
runs-on: mich
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -69,7 +96,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.sha }}
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -138,7 +165,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Publish Android Artifact
|
- name: Publish Android Artifact
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: release-apk-signed
|
name: release-apk-signed
|
||||||
path: mobile/build/app/outputs/flutter-apk/*.apk
|
path: mobile/build/app/outputs/flutter-apk/*.apk
|
||||||
@@ -161,19 +188,19 @@ jobs:
|
|||||||
needs: pre-job
|
needs: pre-job
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
# Run on main branch or workflow_dispatch
|
# 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 && github.ref == 'refs/heads/main' }}
|
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.sha }}
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Flutter SDK
|
- name: Setup Flutter SDK
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
flutter-version-file: ./mobile/pubspec.yaml
|
flutter-version-file: ./mobile/pubspec.yaml
|
||||||
@@ -194,17 +221,22 @@ jobs:
|
|||||||
- name: Setup Ruby
|
- name: Setup Ruby
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
ruby-version: '3.2'
|
ruby-version: '3.3'
|
||||||
working-directory: ./mobile/ios
|
working-directory: ./mobile/ios
|
||||||
|
|
||||||
- name: Install Fastlane
|
- name: Install CocoaPods dependencies
|
||||||
|
working-directory: ./mobile/ios
|
||||||
|
run: |
|
||||||
|
pod install
|
||||||
|
|
||||||
|
- name: Install Fastlane
|
||||||
|
working-directory: ./mobile/ios
|
||||||
run: |
|
run: |
|
||||||
cd mobile/ios
|
|
||||||
gem install bundler
|
gem install bundler
|
||||||
bundle config set --local path 'vendor/bundle'
|
bundle config set --local path 'vendor/bundle'
|
||||||
bundle install
|
bundle install
|
||||||
|
|
||||||
- name: Create API Key JSON
|
- name: Create API Key
|
||||||
env:
|
env:
|
||||||
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||||
API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||||
@@ -213,35 +245,55 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.appstoreconnect/private_keys
|
mkdir -p ~/.appstoreconnect/private_keys
|
||||||
echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8
|
echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8
|
||||||
cat > api_key.json << EOF
|
|
||||||
{
|
|
||||||
"key_id": "${API_KEY_ID}",
|
|
||||||
"issuer_id": "${API_KEY_ISSUER_ID}",
|
|
||||||
"key": "$(cat ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8)",
|
|
||||||
"duration": 1200,
|
|
||||||
"in_house": false
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Import Certificate and Provisioning Profile
|
- name: Import Certificate and Provisioning Profiles
|
||||||
env:
|
env:
|
||||||
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||||
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||||
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||||
|
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||||
|
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||||
|
ENVIRONMENT: ${{ inputs.environment || 'development' }}
|
||||||
working-directory: ./mobile/ios
|
working-directory: ./mobile/ios
|
||||||
run: |
|
run: |
|
||||||
|
# Decode certificate
|
||||||
echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
|
echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
|
||||||
echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
|
|
||||||
|
|
||||||
- name: Create keychain
|
# Decode provisioning profiles based on environment
|
||||||
|
if [[ "$ENVIRONMENT" == "development" ]]; then
|
||||||
|
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE" | base64 --decode > profile_dev.mobileprovision
|
||||||
|
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_dev_share.mobileprovision
|
||||||
|
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_dev_widget.mobileprovision
|
||||||
|
ls -lh profile_dev*.mobileprovision
|
||||||
|
else
|
||||||
|
echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
|
||||||
|
echo "$IOS_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_share.mobileprovision
|
||||||
|
echo "$IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_widget.mobileprovision
|
||||||
|
ls -lh profile*.mobileprovision
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create keychain and import certificate
|
||||||
env:
|
env:
|
||||||
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||||
|
CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||||
|
working-directory: ./mobile/ios
|
||||||
run: |
|
run: |
|
||||||
|
# Create keychain
|
||||||
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||||
security default-keychain -s build.keychain
|
security default-keychain -s build.keychain
|
||||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||||
security set-keychain-settings -t 3600 -u build.keychain
|
security set-keychain-settings -t 3600 -u build.keychain
|
||||||
|
|
||||||
|
# Import certificate
|
||||||
|
security import certificate.p12 -k build.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
|
||||||
|
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||||
|
|
||||||
|
# Verify certificate was imported
|
||||||
|
security find-identity -v -p codesigning build.keychain
|
||||||
|
|
||||||
- name: Build and deploy to TestFlight
|
- name: Build and deploy to TestFlight
|
||||||
env:
|
env:
|
||||||
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||||
@@ -250,8 +302,22 @@ jobs:
|
|||||||
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||||
|
ENVIRONMENT: ${{ inputs.environment || 'development' }}
|
||||||
|
BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }}
|
||||||
|
GITHUB_REF: ${{ github.ref }}
|
||||||
working-directory: ./mobile/ios
|
working-directory: ./mobile/ios
|
||||||
run: bundle exec fastlane release_ci
|
run: |
|
||||||
|
# Only upload to TestFlight on main branch
|
||||||
|
if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
|
||||||
|
if [[ "$ENVIRONMENT" == "development" ]]; then
|
||||||
|
bundle exec fastlane gha_testflight_dev
|
||||||
|
else
|
||||||
|
bundle exec fastlane gha_release_prod
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Build only, no TestFlight upload for non-main branches
|
||||||
|
bundle exec fastlane gha_build_only
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Clean up keychain
|
- name: Clean up keychain
|
||||||
if: always()
|
if: always()
|
||||||
@@ -259,7 +325,7 @@ jobs:
|
|||||||
security delete-keychain build.keychain || true
|
security delete-keychain build.keychain || true
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
- name: Upload IPA artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: ios-release-ipa
|
name: ios-release-ipa
|
||||||
path: mobile/ios/Runner.ipa
|
path: mobile/ios/Runner.ipa
|
||||||
|
|||||||
2
.github/workflows/cache-cleanup.yml
vendored
2
.github/workflows/cache-cleanup.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
11
.github/workflows/cli.yml
vendored
11
.github/workflows/cli.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -78,13 +78,13 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
@@ -95,7 +95,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ steps.token.outputs.token }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Get package version
|
- name: Get package version
|
||||||
id: package-version
|
id: package-version
|
||||||
@@ -105,7 +105,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate docker image tags
|
- name: Generate docker image tags
|
||||||
id: metadata
|
id: metadata
|
||||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
with:
|
with:
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=false
|
latest=false
|
||||||
@@ -125,4 +125,3 @@ jobs:
|
|||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
tags: ${{ steps.metadata.outputs.tags }}
|
tags: ${{ steps.metadata.outputs.tags }}
|
||||||
labels: ${{ steps.metadata.outputs.labels }}
|
labels: ${{ steps.metadata.outputs.labels }}
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
|
||||||
|
|||||||
2
.github/workflows/close-duplicates.yml
vendored
2
.github/workflows/close-duplicates.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
|||||||
needs: [get_body, should_run]
|
needs: [get_body, should_run]
|
||||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||||
container:
|
container:
|
||||||
image: ghcr.io/immich-app/mdq:main@sha256:6b8450bfc06770af1af66bce9bf2ced7d1d9b90df1a59fc4c83a17777a9f6723
|
image: ghcr.io/immich-app/mdq:main@sha256:237cdae7783609c96f18037a513d38088713cf4a2e493a3aa136d0c45490749a
|
||||||
outputs:
|
outputs:
|
||||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
@@ -50,14 +50,14 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
uses: github/codeql-action/autobuild@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
@@ -83,6 +83,6 @@ jobs:
|
|||||||
# ./location_of_script_within_repo/buildscript.sh
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
@@ -132,7 +132,7 @@ jobs:
|
|||||||
suffixes: '-rocm'
|
suffixes: '-rocm'
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
runner-mapping: '{"linux/amd64": "mich"}'
|
runner-mapping: '{"linux/amd64": "mich"}'
|
||||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@a667ef0a5cf3ff1ff1e41be52d3fe326b24e3a00 # multi-runner-build-workflow-v1.1.3
|
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
@@ -155,7 +155,7 @@ jobs:
|
|||||||
name: Build and Push Server
|
name: Build and Push Server
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@a667ef0a5cf3ff1ff1e41be52d3fe326b24e3a00 # multi-runner-build-workflow-v1.1.3
|
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
|
|||||||
4
.github/workflows/docs-build.yml
vendored
4
.github/workflows/docs-build.yml
vendored
@@ -60,7 +60,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -85,7 +85,7 @@ jobs:
|
|||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Upload build output
|
- name: Upload build output
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: docs-build-output
|
name: docs-build-output
|
||||||
path: docs/build/
|
path: docs/build/
|
||||||
|
|||||||
8
.github/workflows/docs-deploy.yml
vendored
8
.github/workflows/docs-deploy.yml
vendored
@@ -125,7 +125,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -174,7 +174,7 @@ jobs:
|
|||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
||||||
working-directory: 'deployment/modules/cloudflare/docs'
|
working-directory: 'deployment/modules/cloudflare/docs'
|
||||||
run: 'mise run tf apply'
|
run: 'mise run //deployment:tf apply'
|
||||||
|
|
||||||
- name: Deploy Docs Subdomain Output
|
- name: Deploy Docs Subdomain Output
|
||||||
id: docs-output
|
id: docs-output
|
||||||
@@ -186,7 +186,7 @@ jobs:
|
|||||||
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
||||||
working-directory: 'deployment/modules/cloudflare/docs'
|
working-directory: 'deployment/modules/cloudflare/docs'
|
||||||
run: |
|
run: |
|
||||||
mise run tf output -- -json | jq -r '
|
mise run //deployment:tf output -- -json | jq -r '
|
||||||
"projectName=\(.pages_project_name.value)",
|
"projectName=\(.pages_project_name.value)",
|
||||||
"subdomain=\(.immich_app_branch_subdomain.value)"
|
"subdomain=\(.immich_app_branch_subdomain.value)"
|
||||||
' >> $GITHUB_OUTPUT
|
' >> $GITHUB_OUTPUT
|
||||||
@@ -211,7 +211,7 @@ jobs:
|
|||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
||||||
working-directory: 'deployment/modules/cloudflare/docs-release'
|
working-directory: 'deployment/modules/cloudflare/docs-release'
|
||||||
run: 'mise run tf apply'
|
run: 'mise run //deployment:tf apply'
|
||||||
|
|
||||||
- name: Comment
|
- name: Comment
|
||||||
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
|
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
|
||||||
|
|||||||
4
.github/workflows/docs-destroy.yml
vendored
4
.github/workflows/docs-destroy.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
||||||
working-directory: 'deployment/modules/cloudflare/docs'
|
working-directory: 'deployment/modules/cloudflare/docs'
|
||||||
run: 'mise run tf destroy -- -refresh=false'
|
run: 'mise run //deployment:tf destroy -- -refresh=false'
|
||||||
|
|
||||||
- name: Comment
|
- name: Comment
|
||||||
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
|
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
|
||||||
|
|||||||
6
.github/workflows/fix-format.yml
vendored
6
.github/workflows/fix-format.yml
vendored
@@ -16,13 +16,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate-token
|
id: generate-token
|
||||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: 'Checkout'
|
- name: 'Checkout'
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
ref: ${{ github.event.pull_request.head.ref }}
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
- name: Fix formatting
|
- name: Fix formatting
|
||||||
run: make install-all && make format-all
|
run: pnpm --recursive install && pnpm run --recursive --parallel fix:format
|
||||||
|
|
||||||
- name: Commit and push
|
- name: Commit and push
|
||||||
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
||||||
|
|||||||
2
.github/workflows/merge-translations.yml
vendored
2
.github/workflows/merge-translations.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
|||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate_token
|
id: generate_token
|
||||||
if: ${{ inputs.skip != true }}
|
if: ${{ inputs.skip != true }}
|
||||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|||||||
29
.github/workflows/prepare-release.yml
vendored
29
.github/workflows/prepare-release.yml
vendored
@@ -49,20 +49,20 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate-token
|
id: generate-token
|
||||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
persist-credentials: true
|
persist-credentials: true
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
|
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
@@ -99,8 +99,23 @@ jobs:
|
|||||||
ALIAS: ${{ secrets.ALIAS }}
|
ALIAS: ${{ secrets.ALIAS }}
|
||||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||||
|
# iOS secrets
|
||||||
|
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||||
|
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||||
|
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
|
||||||
|
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||||
|
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||||
|
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||||
|
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||||
|
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||||
|
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||||
|
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.bump_version.outputs.ref }}
|
ref: ${{ needs.bump_version.outputs.ref }}
|
||||||
|
environment: production
|
||||||
|
|
||||||
prepare_release:
|
prepare_release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -111,25 +126,25 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate-token
|
id: generate-token
|
||||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Download APK
|
- name: Download APK
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: release-apk-signed
|
name: release-apk-signed
|
||||||
github-token: ${{ steps.generate-token.outputs.token }}
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|
||||||
- name: Create draft release
|
- name: Create draft release
|
||||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
|
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
tag_name: ${{ env.IMMICH_VERSION }}
|
tag_name: ${{ env.IMMICH_VERSION }}
|
||||||
|
|||||||
170
.github/workflows/release-pr.yml
vendored
Normal file
170
.github/workflows/release-pr.yml
vendored
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
name: Manage release PR
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bump:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Generate a token
|
||||||
|
id: generate-token
|
||||||
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
with:
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
persist-credentials: true
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
|
with:
|
||||||
|
node-version-file: './server/.nvmrc'
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
|
- name: Determine release type
|
||||||
|
id: bump-type
|
||||||
|
uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0
|
||||||
|
with:
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Bump versions
|
||||||
|
env:
|
||||||
|
TYPE: ${{ steps.bump-type.outputs.bump }}
|
||||||
|
run: |
|
||||||
|
if [ "$TYPE" == "none" ]; then
|
||||||
|
exit 1 # TODO: Is there a cleaner way to abort the workflow?
|
||||||
|
fi
|
||||||
|
misc/release/pump-version.sh -s $TYPE -m true
|
||||||
|
|
||||||
|
- name: Manage Outline release document
|
||||||
|
id: outline
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
env:
|
||||||
|
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
|
||||||
|
NEXT_VERSION: ${{ steps.bump-type.outputs.next }}
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const outlineKey = process.env.OUTLINE_API_KEY;
|
||||||
|
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'
|
||||||
|
const collectionId = 'e2910656-714c-4871-8721-447d9353bd73';
|
||||||
|
const baseUrl = 'https://outline.immich.cloud';
|
||||||
|
|
||||||
|
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${outlineKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ parentDocumentId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!listResponse.ok) {
|
||||||
|
throw new Error(`Outline list failed: ${listResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listData = await listResponse.json();
|
||||||
|
const allDocuments = listData.data || [];
|
||||||
|
|
||||||
|
const document = allDocuments.find(doc => doc.title === 'next');
|
||||||
|
|
||||||
|
let documentId;
|
||||||
|
let documentUrl;
|
||||||
|
let documentText;
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
// Create new document
|
||||||
|
console.log('No existing document found. Creating new one...');
|
||||||
|
const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8');
|
||||||
|
const createResponse = await fetch(`${baseUrl}/api/documents.create`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${outlineKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: 'next',
|
||||||
|
text: notesTmpl,
|
||||||
|
collectionId: collectionId,
|
||||||
|
parentDocumentId: parentDocumentId,
|
||||||
|
publish: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResponse.ok) {
|
||||||
|
throw new Error(`Failed to create document: ${createResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createData = await createResponse.json();
|
||||||
|
documentId = createData.data.id;
|
||||||
|
const urlId = createData.data.urlId;
|
||||||
|
documentUrl = `${baseUrl}/doc/next-${urlId}`;
|
||||||
|
documentText = createData.data.text || '';
|
||||||
|
console.log(`Created new document: ${documentUrl}`);
|
||||||
|
} else {
|
||||||
|
documentId = document.id;
|
||||||
|
const docPath = document.url;
|
||||||
|
documentUrl = `${baseUrl}${docPath}`;
|
||||||
|
documentText = document.text || '';
|
||||||
|
console.log(`Found existing document: ${documentUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate GitHub release notes
|
||||||
|
console.log('Generating GitHub release notes...');
|
||||||
|
const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
tag_name: `${process.env.NEXT_VERSION}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine the content
|
||||||
|
const changelog = `
|
||||||
|
# ${process.env.NEXT_VERSION}
|
||||||
|
|
||||||
|
${documentText}
|
||||||
|
|
||||||
|
${releaseNotesResponse.data.body}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '';
|
||||||
|
fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8');
|
||||||
|
|
||||||
|
core.setOutput('document_url', documentUrl);
|
||||||
|
|
||||||
|
- name: Create PR
|
||||||
|
id: create-pr
|
||||||
|
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||||
|
with:
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||||
|
title: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||||
|
body: 'Release notes: ${{ steps.outline.outputs.document_url }}'
|
||||||
|
labels: 'changelog:skip'
|
||||||
|
branch: 'release/next'
|
||||||
|
draft: true
|
||||||
148
.github/workflows/release.yml
vendored
Normal file
148
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
name: release.yml
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
paths:
|
||||||
|
- CHANGELOG.md
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Maybe double check PR source branch?
|
||||||
|
|
||||||
|
merge_translations:
|
||||||
|
uses: ./.github/workflows/merge-translations.yml
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
secrets:
|
||||||
|
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
|
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
|
||||||
|
|
||||||
|
build_mobile:
|
||||||
|
uses: ./.github/workflows/build-mobile.yml
|
||||||
|
needs: merge_translations
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
secrets:
|
||||||
|
KEY_JKS: ${{ secrets.KEY_JKS }}
|
||||||
|
ALIAS: ${{ secrets.ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||||
|
# iOS secrets
|
||||||
|
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||||
|
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||||
|
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
|
||||||
|
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||||
|
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||||
|
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||||
|
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl
|
||||||
|
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||||
|
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
environment: production
|
||||||
|
|
||||||
|
prepare_release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build_mobile
|
||||||
|
permissions:
|
||||||
|
actions: read # To download the app artifact
|
||||||
|
steps:
|
||||||
|
- name: Generate a token
|
||||||
|
id: generate-token
|
||||||
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
with:
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
persist-credentials: false
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Extract changelog
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
|
||||||
|
sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
|
||||||
|
echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
|
||||||
|
VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Download APK
|
||||||
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||||
|
with:
|
||||||
|
name: release-apk-signed
|
||||||
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Create draft release
|
||||||
|
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.version.outputs.result }}
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
body_path: ${{ steps.changelog.outputs.path }}
|
||||||
|
draft: true
|
||||||
|
files: |
|
||||||
|
docker/docker-compose.yml
|
||||||
|
docker/example.env
|
||||||
|
docker/hwaccel.ml.yml
|
||||||
|
docker/hwaccel.transcoding.yml
|
||||||
|
docker/prometheus.yml
|
||||||
|
*.apk
|
||||||
|
|
||||||
|
- name: Rename Outline document
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
|
||||||
|
VERSION: ${{ steps.changelog.outputs.version }}
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const outlineKey = process.env.OUTLINE_API_KEY;
|
||||||
|
const version = process.env.VERSION;
|
||||||
|
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
|
||||||
|
const baseUrl = 'https://outline.immich.cloud';
|
||||||
|
|
||||||
|
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${outlineKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ parentDocumentId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!listResponse.ok) {
|
||||||
|
throw new Error(`Outline list failed: ${listResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listData = await listResponse.json();
|
||||||
|
const allDocuments = listData.data || [];
|
||||||
|
const document = allDocuments.find(doc => doc.title === 'next');
|
||||||
|
|
||||||
|
if (document) {
|
||||||
|
console.log(`Found document 'next', renaming to '${version}'...`);
|
||||||
|
|
||||||
|
const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${outlineKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: document.id,
|
||||||
|
title: version
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updateResponse.ok) {
|
||||||
|
throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('No document titled "next" found to rename');
|
||||||
|
}
|
||||||
2
.github/workflows/sdk.yml
vendored
2
.github/workflows/sdk.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
2
.github/workflows/static_analysis.yml
vendored
2
.github/workflows/static_analysis.yml
vendored
@@ -55,7 +55,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
45
.github/workflows/test.yml
vendored
45
.github/workflows/test.yml
vendored
@@ -69,7 +69,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -161,7 +161,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -203,7 +203,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -247,7 +247,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -285,7 +285,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -333,7 +333,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -379,9 +379,10 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
submodules: 'recursive'
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
@@ -417,7 +418,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
@@ -472,7 +473,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
@@ -499,8 +500,16 @@ jobs:
|
|||||||
run: docker compose build
|
run: docker compose build
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
- name: Run e2e tests (web)
|
- name: Run e2e tests (web)
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
run: npx playwright test
|
run: npx playwright test
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
- name: Archive test results
|
||||||
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
|
if: success() || failure()
|
||||||
|
with:
|
||||||
|
name: e2e-web-test-results-${{ matrix.runner }}
|
||||||
|
path: e2e/playwright-report/
|
||||||
success-check-e2e:
|
success-check-e2e:
|
||||||
name: End-to-End Tests Success
|
name: End-to-End Tests Success
|
||||||
needs: [e2e-tests-server-cli, e2e-tests-web]
|
needs: [e2e-tests-server-cli, e2e-tests-web]
|
||||||
@@ -525,7 +534,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -557,13 +566,13 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
|
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||||
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
|
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
|
||||||
# with:
|
# with:
|
||||||
# python-version: 3.11
|
# python-version: 3.11
|
||||||
@@ -601,7 +610,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -630,7 +639,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -652,7 +661,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -714,7 +723,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
3
.github/workflows/weblate-lock.yml
vendored
3
.github/workflows/weblate-lock.yml
vendored
@@ -36,8 +36,7 @@ jobs:
|
|||||||
github-token: ${{ steps.token.outputs.token }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
filters: |
|
filters: |
|
||||||
i18n:
|
i18n:
|
||||||
- 'i18n/!(en)**\.json'
|
- modified: 'i18n/!(en)**\.json'
|
||||||
exclude-branches: 'chore/translations'
|
|
||||||
skip-force-logic: 'true'
|
skip-force-logic: 'true'
|
||||||
|
|
||||||
enforce-lock:
|
enforce-lock:
|
||||||
|
|||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -52,7 +52,7 @@
|
|||||||
},
|
},
|
||||||
"cSpell.words": ["immich"],
|
"cSpell.words": ["immich"],
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"eslint.validate": ["javascript", "svelte"],
|
"eslint.validate": ["javascript", "typescript", "svelte"],
|
||||||
"explorer.fileNesting.enabled": true,
|
"explorer.fileNesting.enabled": true,
|
||||||
"explorer.fileNesting.patterns": {
|
"explorer.fileNesting.patterns": {
|
||||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||||
|
|||||||
3
Makefile
3
Makefile
@@ -17,6 +17,9 @@ dev-docs:
|
|||||||
e2e:
|
e2e:
|
||||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
|
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
|
||||||
|
|
||||||
|
e2e-dev:
|
||||||
|
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
|
||||||
|
|
||||||
e2e-update:
|
e2e-update:
|
||||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
||||||
|
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -118,16 +118,16 @@ Read more about translations [here](https://docs.immich.app/developer/translatio
|
|||||||
|
|
||||||
## Star history
|
## Star history
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
<a href="https://star-history.com/#immich-app/immich&type=date&legend=top-left">
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date&theme=dark" />
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date" />
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=date" width="100%" />
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
<a href="https://github.com/immich-app/immich/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
22.20.0
|
24.11.1
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS core
|
FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25 AS core
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
COPY package* pnpm* .pnpmfile.cjs ./
|
COPY package* pnpm* .pnpmfile.cjs ./
|
||||||
|
|||||||
29
cli/mise.toml
Normal file
29
cli/mise.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[tasks.install]
|
||||||
|
run = "pnpm install --filter @immich/cli --frozen-lockfile"
|
||||||
|
|
||||||
|
[tasks.build]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "vite build"
|
||||||
|
|
||||||
|
[tasks.test]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "vite"
|
||||||
|
|
||||||
|
[tasks.lint]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "eslint \"src/**/*.ts\" --max-warnings 0"
|
||||||
|
|
||||||
|
[tasks."lint-fix"]
|
||||||
|
run = { task = "lint --fix" }
|
||||||
|
|
||||||
|
[tasks.format]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "prettier --check ."
|
||||||
|
|
||||||
|
[tasks."format-fix"]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "prettier --write ."
|
||||||
|
|
||||||
|
[tasks.check]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "tsc --noEmit"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.97",
|
"version": "2.2.103",
|
||||||
"description": "Command Line Interface (CLI) for Immich",
|
"description": "Command Line Interface (CLI) for Immich",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./dist/index.js",
|
"exports": "./dist/index.js",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/micromatch": "^4.0.9",
|
"@types/micromatch": "^4.0.9",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/node": "^22.18.10",
|
"@types/node": "^24.10.1",
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
"@vitest/coverage-v8": "^3.0.0",
|
||||||
"byte-size": "^9.0.0",
|
"byte-size": "^9.0.0",
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"eslint": "^9.14.0",
|
"eslint": "^9.14.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"eslint-plugin-unicorn": "^60.0.0",
|
"eslint-plugin-unicorn": "^62.0.0",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"mock-fs": "^5.2.0",
|
"mock-fs": "^5.2.0",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
@@ -69,6 +69,6 @@
|
|||||||
"micromatch": "^4.0.8"
|
"micromatch": "^4.0.8"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "22.20.0"
|
"node": "24.11.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ describe('crawl', () => {
|
|||||||
.map(([file]) => file);
|
.map(([file]) => file);
|
||||||
|
|
||||||
// Compare file's content instead of path since a file can be represent in multiple ways.
|
// Compare file's content instead of path since a file can be represent in multiple ways.
|
||||||
expect(actual.map((path) => readContent(path)).sort()).toEqual(expected.sort());
|
expect(actual.map((path) => readContent(path)).toSorted()).toEqual(expected.toSorted());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export const crawl = async (options: CrawlOptions): Promise<string[]> => {
|
|||||||
ignore: [`**/${exclusionPattern}`],
|
ignore: [`**/${exclusionPattern}`],
|
||||||
});
|
});
|
||||||
globbedFiles.push(...crawledFiles);
|
globbedFiles.push(...crawledFiles);
|
||||||
return globbedFiles.sort();
|
return globbedFiles.toSorted();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sha1 = (filepath: string) => {
|
export const sha1 = (filepath: string) => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"target": "es2022",
|
"target": "es2023",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
|
|||||||
20
deployment/mise.toml
Normal file
20
deployment/mise.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[tools]
|
||||||
|
terragrunt = "0.93.10"
|
||||||
|
opentofu = "1.10.7"
|
||||||
|
|
||||||
|
[tasks."tg:fmt"]
|
||||||
|
run = "terragrunt hclfmt"
|
||||||
|
description = "Format terragrunt files"
|
||||||
|
|
||||||
|
[tasks.tf]
|
||||||
|
run = "terragrunt run --all"
|
||||||
|
description = "Wrapper for terragrunt run-all"
|
||||||
|
dir = "{{cwd}}"
|
||||||
|
|
||||||
|
[tasks."tf:fmt"]
|
||||||
|
run = "tofu fmt -recursive tf/"
|
||||||
|
description = "Format terraform files"
|
||||||
|
|
||||||
|
[tasks."tf:init"]
|
||||||
|
run = { task = "tf init -- -reconfigure" }
|
||||||
|
dir = "{{cwd}}"
|
||||||
@@ -41,6 +41,7 @@ services:
|
|||||||
- app-node_modules:/usr/src/app/node_modules
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
- coverage:/usr/src/app/web/coverage
|
- coverage:/usr/src/app/web/coverage
|
||||||
|
- ../plugins:/build/corePlugin
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@@ -122,7 +123,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 3003:3003
|
- 3003:3003
|
||||||
volumes:
|
volumes:
|
||||||
- ../machine-learning:/usr/src/app
|
- ../machine-learning/immich_ml:/usr/src/immich_ml
|
||||||
- model-cache:/cache
|
- model-cache:/cache
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
@@ -134,7 +135,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
@@ -83,7 +83,7 @@ services:
|
|||||||
container_name: immich_prometheus
|
container_name: immich_prometheus
|
||||||
ports:
|
ports:
|
||||||
- 9090:9090
|
- 9090:9090
|
||||||
image: prom/prometheus@sha256:23031bfe0e74a13004252caaa74eccd0d62b6c6e7a04711d5b8bf5b7e113adc7
|
image: prom/prometheus@sha256:49214755b6153f90a597adcbff0252cc61069f8ab69ce8411285cd4a560e8038
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
- prometheus-data:/prometheus
|
- prometheus-data:/prometheus
|
||||||
@@ -95,7 +95,7 @@ services:
|
|||||||
command: ['./run.sh', '-disable-reporting']
|
command: ['./run.sh', '-disable-reporting']
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- 3000:3000
|
||||||
image: grafana/grafana:12.1.1-ubuntu@sha256:d1da838234ff2de93e0065ee1bf0e66d38f948dcc5d718c25fa6237e14b4424a
|
image: grafana/grafana:12.3.0-ubuntu@sha256:cee936306135e1925ab21dffa16f8a411535d16ab086bef2309339a8e74d62df
|
||||||
volumes:
|
volumes:
|
||||||
- grafana-data:/var/lib/grafana
|
- grafana-data:/var/lib/grafana
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
22.20.0
|
24.11.1
|
||||||
|
|||||||
@@ -133,9 +133,9 @@ There are a few different scenarios that can lead to this situation. The solutio
|
|||||||
The job is only automatically run once per asset after upload. If metadata extraction originally failed, the jobs were cleared/canceled, etc.,
|
The job is only automatically run once per asset after upload. If metadata extraction originally failed, the jobs were cleared/canceled, etc.,
|
||||||
the job may not have run automatically the first time.
|
the job may not have run automatically the first time.
|
||||||
|
|
||||||
### How can I hide photos from the timeline?
|
### How can I hide a photo or video from the timeline?
|
||||||
|
|
||||||
You can _archive_ them.
|
You can _archive_ them. This will hide the asset from the main timeline and folder view, but it will still show up in searches. All archived assets can be found in the _Archive_ view
|
||||||
|
|
||||||
### How can I backup data from Immich?
|
### How can I backup data from Immich?
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ By default, Immich will keep the last 14 database dumps and create a new dump ev
|
|||||||
|
|
||||||
#### Trigger Dump
|
#### Trigger Dump
|
||||||
|
|
||||||
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/jobs-status).
|
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/queues).
|
||||||
Visit the page, open the "Create job" modal from the top right, select "Create Database Dump" and click "Confirm".
|
Visit the page, open the "Create job" modal from the top right, select "Create Database Dump" and click "Confirm".
|
||||||
A job will run and trigger a dump, you can verify this worked correctly by checking the logs or the `backups/` folder.
|
A job will run and trigger a dump, you can verify this worked correctly by checking the logs or the `backups/` folder.
|
||||||
This dumps will count towards the last `X` dumps that will be kept based on your settings.
|
This dumps will count towards the last `X` dumps that will be kept based on your settings.
|
||||||
|
|||||||
18
docs/docs/administration/maintenance-mode.md
Normal file
18
docs/docs/administration/maintenance-mode.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Maintenance Mode
|
||||||
|
|
||||||
|
Maintenance mode is used to perform administrative tasks such as restoring backups to Immich.
|
||||||
|
|
||||||
|
You can enter maintenance mode by either:
|
||||||
|
|
||||||
|
- Selecting "enable maintenance mode" in system settings in administration.
|
||||||
|
- Running the enable maintenance mode [administration command](./server-commands.md).
|
||||||
|
|
||||||
|
## Logging in during maintenance
|
||||||
|
|
||||||
|
Maintenance mode uses a separate login system which is handled automatically behind the scenes in most cases. Enabling maintenance mode in settings will automatically log you into maintenance mode when the server comes back up.
|
||||||
|
|
||||||
|
If you find that you've been logged out, you can:
|
||||||
|
|
||||||
|
- Open the logs for the Immich server and look for _"🚧 Immich is in maintenance mode, you can log in using the following URL:"_
|
||||||
|
- Run the enable maintenance mode [administration command](./server-commands.md) again, this will give you a new URL to login with.
|
||||||
|
- Run the disable maintenance mode [administration command](./server-commands.md) then re-enter through system settings.
|
||||||
@@ -10,16 +10,19 @@ Running with a pre-existing Postgres server can unlock powerful administrative f
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
You must install `pgvector` (`>= 0.7.0, < 1.0.0`), as it is a prerequisite for `vchord`.
|
You must install pgvector as it is a prerequisite for VectorChord.
|
||||||
The easiest way to do this on Debian/Ubuntu is by adding the [PostgreSQL Apt repository][pg-apt] and then
|
The easiest way to do this on Debian/Ubuntu is by adding the [PostgreSQL Apt repository][pg-apt] and then
|
||||||
running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`).
|
running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`).
|
||||||
|
|
||||||
You must install VectorChord into your instance of Postgres using their [instructions][vchord-install]. After installation, add `shared_preload_libraries = 'vchord.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vchord.so'`.
|
You must install VectorChord into your instance of Postgres using their [instructions][vchord-install]. After installation, add `shared_preload_libraries = 'vchord.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vchord.so'`.
|
||||||
|
|
||||||
:::note
|
:::note Supported versions
|
||||||
Immich is known to work with Postgres versions `>= 14, < 18`.
|
Immich is known to work with Postgres versions `>= 14, < 19`.
|
||||||
|
|
||||||
Make sure the installed version of VectorChord is compatible with your version of Immich. The current accepted range for VectorChord is `>= 0.3.0, < 0.5.0`.
|
VectorChord is known to work with pgvector versions `>= 0.7, < 0.9`.
|
||||||
|
|
||||||
|
The Immich server will check the VectorChord version on startup to ensure compatibility, and refuse to start if a compatible version is not found.
|
||||||
|
The current accepted range for VectorChord is `>= 0.3, < 0.6`.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Specifying the connection URL
|
## Specifying the connection URL
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ server {
|
|||||||
# allow large file uploads
|
# allow large file uploads
|
||||||
client_max_body_size 50000M;
|
client_max_body_size 50000M;
|
||||||
|
|
||||||
|
# disable buffering uploads to prevent OOM on reverse proxy server and make uploads twice as fast (no pause)
|
||||||
|
proxy_request_buffering off;
|
||||||
|
|
||||||
# Set headers
|
# Set headers
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
The `immich-server` docker image comes preinstalled with an administrative CLI (`immich-admin`) that supports the following commands:
|
The `immich-server` docker image comes preinstalled with an administrative CLI (`immich-admin`) that supports the following commands:
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------------------------ | ------------------------------------------------------------- |
|
| -------------------------- | ------------------------------------------------------------- |
|
||||||
| `help` | Display help |
|
| `help` | Display help |
|
||||||
| `reset-admin-password` | Reset the password for the admin user |
|
| `reset-admin-password` | Reset the password for the admin user |
|
||||||
| `disable-password-login` | Disable password login |
|
| `disable-password-login` | Disable password login |
|
||||||
| `enable-password-login` | Enable password login |
|
| `enable-password-login` | Enable password login |
|
||||||
|
| `disable-maintenance-mode` | Disable maintenance mode |
|
||||||
|
| `enable-maintenance-mode` | Enable maintenance mode |
|
||||||
| `enable-oauth-login` | Enable OAuth login |
|
| `enable-oauth-login` | Enable OAuth login |
|
||||||
| `disable-oauth-login` | Disable OAuth login |
|
| `disable-oauth-login` | Disable OAuth login |
|
||||||
| `list-users` | List Immich users |
|
| `list-users` | List Immich users |
|
||||||
@@ -47,6 +49,23 @@ immich-admin enable-password-login
|
|||||||
Password login has been enabled.
|
Password login has been enabled.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Disable Maintenance Mode
|
||||||
|
|
||||||
|
```
|
||||||
|
immich-admin disable-maintenace-mode
|
||||||
|
Maintenance mode has been disabled.
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable Maintenance Mode
|
||||||
|
|
||||||
|
```
|
||||||
|
immich-admin enable-maintenance-mode
|
||||||
|
Maintenance mode has been enabled.
|
||||||
|
|
||||||
|
Log in using the following URL:
|
||||||
|
https://my.immich.app/maintenance?token=<token>
|
||||||
|
```
|
||||||
|
|
||||||
Enable OAuth login
|
Enable OAuth login
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,3 +12,13 @@ pnpm run migrations:generate <migration-name>
|
|||||||
3. Move the migration file to folder `./server/src/schema/migrations` in your code editor.
|
3. Move the migration file to folder `./server/src/schema/migrations` in your code editor.
|
||||||
|
|
||||||
The server will automatically detect `*.ts` file changes and restart. Part of the server start-up process includes running any new migrations, so it will be applied immediately.
|
The server will automatically detect `*.ts` file changes and restart. Part of the server start-up process includes running any new migrations, so it will be applied immediately.
|
||||||
|
|
||||||
|
## Reverting a Migration
|
||||||
|
|
||||||
|
If you need to undo the most recently applied migration—for example, when developing or testing on schema changes—run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run migrations:revert
|
||||||
|
```
|
||||||
|
|
||||||
|
This command rolls back the latest migration and brings the database schema back to its previous state.
|
||||||
|
|||||||
@@ -268,12 +268,13 @@ make test-all # Runs tests for all components
|
|||||||
make test-medium-dev # End-to-end tests
|
make test-medium-dev # End-to-end tests
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Using NPM Directly
|
#### Using PNPM Directly
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Server tests
|
# Server tests
|
||||||
cd /workspaces/immich/server
|
cd /workspaces/immich/server
|
||||||
pnpm test # Run all tests
|
pnpm test # Run all tests
|
||||||
|
pnpm run test:medium # Medium tests (integration tests)
|
||||||
pnpm run test:watch # Watch mode
|
pnpm run test:watch # Watch mode
|
||||||
pnpm run test:cov # Coverage report
|
pnpm run test:cov # Coverage report
|
||||||
|
|
||||||
@@ -307,7 +308,7 @@ make check-web # Type check web
|
|||||||
make check-all # Check all components
|
make check-all # Check all components
|
||||||
|
|
||||||
# Complete hygiene check
|
# Complete hygiene check
|
||||||
make hygiene-all # Runs lint, format, check, SQL sync, and audit
|
make hygiene-all # Run lint, format, check, SQL sync, and audit
|
||||||
```
|
```
|
||||||
|
|
||||||
### Additional Make Commands
|
### Additional Make Commands
|
||||||
|
|||||||
@@ -14,15 +14,15 @@ When contributing code through a pull request, please check the following:
|
|||||||
- [ ] `pnpm run check:typescript` (check typescript)
|
- [ ] `pnpm run check:typescript` (check typescript)
|
||||||
- [ ] `pnpm test` (unit tests)
|
- [ ] `pnpm test` (unit tests)
|
||||||
|
|
||||||
|
:::tip AIO
|
||||||
|
Run all web checks with `pnpm run check:all`
|
||||||
|
:::
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [ ] `pnpm run format` (formatting via Prettier)
|
- [ ] `pnpm run format` (formatting via Prettier)
|
||||||
- [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation.
|
- [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation.
|
||||||
|
|
||||||
:::tip AIO
|
|
||||||
Run all web checks with `pnpm run check:all`
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Server Checks
|
## Server Checks
|
||||||
|
|
||||||
- [ ] `pnpm run lint` (linting via ESLint)
|
- [ ] `pnpm run lint` (linting via ESLint)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ sidebar_position: 2
|
|||||||
# Setup
|
# Setup
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
If there's a feature you're planning to work on, just give us a heads up in [Discord](https://discord.com/channels/979116623879368755/1071165397228855327) so we can:
|
If there's a feature you're planning to work on, just give us a heads up in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327) on [our Discord](https://discord.immich.app) so we can:
|
||||||
|
|
||||||
1. Let you know if it's something we would accept into Immich
|
1. Let you know if it's something we would accept into Immich
|
||||||
2. Provide any guidance on how something like that would ideally be implemented
|
2. Provide any guidance on how something like that would ideally be implemented
|
||||||
@@ -48,7 +48,6 @@ You can access the web from `http://your-machine-ip:3000` or `http://localhost:3
|
|||||||
**Notes:**
|
**Notes:**
|
||||||
|
|
||||||
- The "web" development container runs with uid 1000. If that uid does not have read/write permissions on the mounted volumes, you may encounter errors
|
- The "web" development container runs with uid 1000. If that uid does not have read/write permissions on the mounted volumes, you may encounter errors
|
||||||
- In case of rootless docker setup, you need to use root within the container, otherwise you will encounter read/write permission related errors, see comments in `docker/docker-compose.dev.yml`.
|
|
||||||
|
|
||||||
#### Connect web to a remote backend
|
#### Connect web to a remote backend
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ make e2e
|
|||||||
Before you can run the tests, you need to run the following commands _once_:
|
Before you can run the tests, you need to run the following commands _once_:
|
||||||
|
|
||||||
- `pnpm install` (in `e2e/`)
|
- `pnpm install` (in `e2e/`)
|
||||||
|
- `pnpm run build` (in `cli/`)
|
||||||
- `make open-api` (in the project root `/`)
|
- `make open-api` (in the project root `/`)
|
||||||
|
|
||||||
Once the test environment is running, the e2e tests can be run via:
|
Once the test environment is running, the e2e tests can be run via:
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ import MobileAppBackup from '/docs/partials/_mobile-app-backup.md';
|
|||||||
|
|
||||||
<MobileAppDownload />
|
<MobileAppDownload />
|
||||||
|
|
||||||
|
:::info Android verification
|
||||||
|
Below are the SHA-256 fingerprints for the certificates signing the android applications.
|
||||||
|
|
||||||
|
- Playstore / Github releases:
|
||||||
|
`86:C5:C4:55:DF:AF:49:85:92:3A:8F:35:AD:B3:1D:0C:9E:0B:95:7D:7F:94:C2:D2:AF:6A:24:38:AA:96:00:20`
|
||||||
|
- F-Droid releases:
|
||||||
|
`FA:8B:43:95:F4:A6:47:71:A0:53:D1:C7:57:73:5F:A2:30:13:74:F5:3D:58:0D:D1:75:AA:F7:A1:35:72:9C:BF`
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
:::info Beta Program
|
:::info Beta Program
|
||||||
The beta release channel allows users to test upcoming changes before they are officially released. To join the channel use the links below.
|
The beta release channel allows users to test upcoming changes before they are officially released. To join the channel use the links below.
|
||||||
|
|
||||||
|
|||||||
@@ -1222,4 +1222,4 @@ Feel free to make a feature request if there's a model you want to use that we d
|
|||||||
[huggingface-clip]: https://huggingface.co/collections/immich-app/clip-654eaefb077425890874cd07
|
[huggingface-clip]: https://huggingface.co/collections/immich-app/clip-654eaefb077425890874cd07
|
||||||
[huggingface-multilingual-clip]: https://huggingface.co/collections/immich-app/multilingual-clip-654eb08c2382f591eeb8c2a7
|
[huggingface-multilingual-clip]: https://huggingface.co/collections/immich-app/multilingual-clip-654eb08c2382f591eeb8c2a7
|
||||||
[smart-search-settings]: https://my.immich.app/admin/system-settings?isOpen=machine-learning+smart-search
|
[smart-search-settings]: https://my.immich.app/admin/system-settings?isOpen=machine-learning+smart-search
|
||||||
[job-status-page]: https://my.immich.app/admin/jobs-status
|
[job-status-page]: https://my.immich.app/admin/queues
|
||||||
|
|||||||
@@ -106,14 +106,14 @@ SELECT "user"."email", "asset"."type", COUNT(*) FROM "asset"
|
|||||||
|
|
||||||
```sql title="Count by tag"
|
```sql title="Count by tag"
|
||||||
SELECT "t"."value" AS "tag_name", COUNT(*) AS "number_assets" FROM "tag" "t"
|
SELECT "t"."value" AS "tag_name", COUNT(*) AS "number_assets" FROM "tag" "t"
|
||||||
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagsId" JOIN "asset" "a" ON "ta"."assetsId" = "a"."id"
|
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagId" JOIN "asset" "a" ON "ta"."assetId" = "a"."id"
|
||||||
WHERE "a"."visibility" != 'hidden'
|
WHERE "a"."visibility" != 'hidden'
|
||||||
GROUP BY "t"."value" ORDER BY "number_assets" DESC;
|
GROUP BY "t"."value" ORDER BY "number_assets" DESC;
|
||||||
```
|
```
|
||||||
|
|
||||||
```sql title="Count by tag (per user)"
|
```sql title="Count by tag (per user)"
|
||||||
SELECT "t"."value" AS "tag_name", "u"."email" as "user_email", COUNT(*) AS "number_assets" FROM "tag" "t"
|
SELECT "t"."value" AS "tag_name", "u"."email" as "user_email", COUNT(*) AS "number_assets" FROM "tag" "t"
|
||||||
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagsId" JOIN "asset" "a" ON "ta"."assetsId" = "a"."id" JOIN "user" "u" ON "a"."ownerId" = "u"."id"
|
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagId" JOIN "asset" "a" ON "ta"."assetId" = "a"."id" JOIN "user" "u" ON "a"."ownerId" = "u"."id"
|
||||||
WHERE "a"."visibility" != 'hidden'
|
WHERE "a"."visibility" != 'hidden'
|
||||||
GROUP BY "t"."value", "u"."email" ORDER BY "number_assets" DESC;
|
GROUP BY "t"."value", "u"."email" ORDER BY "number_assets" DESC;
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ Version mismatches between both hosts may cause bugs and instability, so remembe
|
|||||||
|
|
||||||
Adding a new URL to the settings is recommended over replacing the existing URL. This is because it will allow machine learning tasks to be processed successfully when the remote server is down by falling back to the local machine learning container. If you do not want machine learning tasks to be processed locally when the remote server is not available, you can instead replace the existing URL and only provide the remote container's URL. If doing this, you can remove the `immich-machine-learning` section of the local `docker-compose.yml` file to save resources, as this service will never be used.
|
Adding a new URL to the settings is recommended over replacing the existing URL. This is because it will allow machine learning tasks to be processed successfully when the remote server is down by falling back to the local machine learning container. If you do not want machine learning tasks to be processed locally when the remote server is not available, you can instead replace the existing URL and only provide the remote container's URL. If doing this, you can remove the `immich-machine-learning` section of the local `docker-compose.yml` file to save resources, as this service will never be used.
|
||||||
|
|
||||||
Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/jobs-status) page for the jobs to be retried.
|
Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/queues) page for the jobs to be retried.
|
||||||
|
|
||||||
## Load balancing
|
## Load balancing
|
||||||
|
|
||||||
|
|||||||
@@ -16,48 +16,76 @@ The default configuration looks like this:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ffmpeg": {
|
|
||||||
"crf": 23,
|
|
||||||
"threads": 0,
|
|
||||||
"preset": "ultrafast",
|
|
||||||
"targetVideoCodec": "h264",
|
|
||||||
"acceptedVideoCodecs": ["h264"],
|
|
||||||
"targetAudioCodec": "aac",
|
|
||||||
"acceptedAudioCodecs": ["aac", "mp3", "libopus", "pcm_s16le"],
|
|
||||||
"acceptedContainers": ["mov", "ogg", "webm"],
|
|
||||||
"targetResolution": "720",
|
|
||||||
"maxBitrate": "0",
|
|
||||||
"bframes": -1,
|
|
||||||
"refs": 0,
|
|
||||||
"gopSize": 0,
|
|
||||||
"temporalAQ": false,
|
|
||||||
"cqMode": "auto",
|
|
||||||
"twoPass": false,
|
|
||||||
"preferredHwDevice": "auto",
|
|
||||||
"transcode": "required",
|
|
||||||
"tonemap": "hable",
|
|
||||||
"accel": "disabled",
|
|
||||||
"accelDecode": false
|
|
||||||
},
|
|
||||||
"backup": {
|
"backup": {
|
||||||
"database": {
|
"database": {
|
||||||
"enabled": true,
|
|
||||||
"cronExpression": "0 02 * * *",
|
"cronExpression": "0 02 * * *",
|
||||||
|
"enabled": true,
|
||||||
"keepLastAmount": 14
|
"keepLastAmount": 14
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ffmpeg": {
|
||||||
|
"accel": "disabled",
|
||||||
|
"accelDecode": false,
|
||||||
|
"acceptedAudioCodecs": ["aac", "mp3", "libopus"],
|
||||||
|
"acceptedContainers": ["mov", "ogg", "webm"],
|
||||||
|
"acceptedVideoCodecs": ["h264"],
|
||||||
|
"bframes": -1,
|
||||||
|
"cqMode": "auto",
|
||||||
|
"crf": 23,
|
||||||
|
"gopSize": 0,
|
||||||
|
"maxBitrate": "0",
|
||||||
|
"preferredHwDevice": "auto",
|
||||||
|
"preset": "ultrafast",
|
||||||
|
"refs": 0,
|
||||||
|
"targetAudioCodec": "aac",
|
||||||
|
"targetResolution": "720",
|
||||||
|
"targetVideoCodec": "h264",
|
||||||
|
"temporalAQ": false,
|
||||||
|
"threads": 0,
|
||||||
|
"tonemap": "hable",
|
||||||
|
"transcode": "required",
|
||||||
|
"twoPass": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"colorspace": "p3",
|
||||||
|
"extractEmbedded": false,
|
||||||
|
"fullsize": {
|
||||||
|
"enabled": false,
|
||||||
|
"format": "jpeg",
|
||||||
|
"quality": 80
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"format": "jpeg",
|
||||||
|
"quality": 80,
|
||||||
|
"size": 1440
|
||||||
|
},
|
||||||
|
"thumbnail": {
|
||||||
|
"format": "webp",
|
||||||
|
"quality": 80,
|
||||||
|
"size": 250
|
||||||
|
}
|
||||||
|
},
|
||||||
"job": {
|
"job": {
|
||||||
"backgroundTask": {
|
"backgroundTask": {
|
||||||
"concurrency": 5
|
"concurrency": 5
|
||||||
},
|
},
|
||||||
"smartSearch": {
|
"faceDetection": {
|
||||||
"concurrency": 2
|
"concurrency": 2
|
||||||
},
|
},
|
||||||
|
"library": {
|
||||||
|
"concurrency": 5
|
||||||
|
},
|
||||||
"metadataExtraction": {
|
"metadataExtraction": {
|
||||||
"concurrency": 5
|
"concurrency": 5
|
||||||
},
|
},
|
||||||
"faceDetection": {
|
"migration": {
|
||||||
"concurrency": 2
|
"concurrency": 5
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"concurrency": 5
|
||||||
|
},
|
||||||
|
"ocr": {
|
||||||
|
"concurrency": 1
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"concurrency": 5
|
"concurrency": 5
|
||||||
@@ -65,20 +93,23 @@ The default configuration looks like this:
|
|||||||
"sidecar": {
|
"sidecar": {
|
||||||
"concurrency": 5
|
"concurrency": 5
|
||||||
},
|
},
|
||||||
"library": {
|
"smartSearch": {
|
||||||
"concurrency": 5
|
"concurrency": 2
|
||||||
},
|
|
||||||
"migration": {
|
|
||||||
"concurrency": 5
|
|
||||||
},
|
},
|
||||||
"thumbnailGeneration": {
|
"thumbnailGeneration": {
|
||||||
"concurrency": 3
|
"concurrency": 3
|
||||||
},
|
},
|
||||||
"videoConversion": {
|
"videoConversion": {
|
||||||
"concurrency": 1
|
"concurrency": 1
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"library": {
|
||||||
"concurrency": 5
|
"scan": {
|
||||||
|
"cronExpression": "0 0 * * *",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"watch": {
|
||||||
|
"enabled": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"logging": {
|
"logging": {
|
||||||
@@ -86,8 +117,11 @@ The default configuration looks like this:
|
|||||||
"level": "log"
|
"level": "log"
|
||||||
},
|
},
|
||||||
"machineLearning": {
|
"machineLearning": {
|
||||||
|
"availabilityChecks": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"urls": ["http://immich-machine-learning:3003"],
|
"interval": 30000,
|
||||||
|
"timeout": 2000
|
||||||
|
},
|
||||||
"clip": {
|
"clip": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"modelName": "ViT-B-32__openai"
|
"modelName": "ViT-B-32__openai"
|
||||||
@@ -96,27 +130,59 @@ The default configuration looks like this:
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"maxDistance": 0.01
|
"maxDistance": 0.01
|
||||||
},
|
},
|
||||||
|
"enabled": true,
|
||||||
"facialRecognition": {
|
"facialRecognition": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"modelName": "buffalo_l",
|
|
||||||
"minScore": 0.7,
|
|
||||||
"maxDistance": 0.5,
|
"maxDistance": 0.5,
|
||||||
"minFaces": 3
|
"minFaces": 3,
|
||||||
}
|
"minScore": 0.7,
|
||||||
|
"modelName": "buffalo_l"
|
||||||
|
},
|
||||||
|
"ocr": {
|
||||||
|
"enabled": true,
|
||||||
|
"maxResolution": 736,
|
||||||
|
"minDetectionScore": 0.5,
|
||||||
|
"minRecognitionScore": 0.8,
|
||||||
|
"modelName": "PP-OCRv5_mobile"
|
||||||
|
},
|
||||||
|
"urls": ["http://immich-machine-learning:3003"]
|
||||||
},
|
},
|
||||||
"map": {
|
"map": {
|
||||||
|
"darkStyle": "https://tiles.immich.cloud/v1/style/dark.json",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"lightStyle": "https://tiles.immich.cloud/v1/style/light.json",
|
"lightStyle": "https://tiles.immich.cloud/v1/style/light.json"
|
||||||
"darkStyle": "https://tiles.immich.cloud/v1/style/dark.json"
|
|
||||||
},
|
|
||||||
"reverseGeocoding": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"faces": {
|
"faces": {
|
||||||
"import": false
|
"import": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"newVersionCheck": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"nightlyTasks": {
|
||||||
|
"clusterNewFaces": true,
|
||||||
|
"databaseCleanup": true,
|
||||||
|
"generateMemories": true,
|
||||||
|
"missingThumbnails": true,
|
||||||
|
"startTime": "00:00",
|
||||||
|
"syncQuotaUsage": true
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"smtp": {
|
||||||
|
"enabled": false,
|
||||||
|
"from": "",
|
||||||
|
"replyTo": "",
|
||||||
|
"transport": {
|
||||||
|
"host": "",
|
||||||
|
"ignoreCert": false,
|
||||||
|
"password": "",
|
||||||
|
"port": 587,
|
||||||
|
"secure": false,
|
||||||
|
"username": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"oauth": {
|
"oauth": {
|
||||||
"autoLaunch": false,
|
"autoLaunch": false,
|
||||||
"autoRegister": true,
|
"autoRegister": true,
|
||||||
@@ -128,70 +194,44 @@ The default configuration looks like this:
|
|||||||
"issuerUrl": "",
|
"issuerUrl": "",
|
||||||
"mobileOverrideEnabled": false,
|
"mobileOverrideEnabled": false,
|
||||||
"mobileRedirectUri": "",
|
"mobileRedirectUri": "",
|
||||||
|
"profileSigningAlgorithm": "none",
|
||||||
|
"roleClaim": "immich_role",
|
||||||
"scope": "openid email profile",
|
"scope": "openid email profile",
|
||||||
"signingAlgorithm": "RS256",
|
"signingAlgorithm": "RS256",
|
||||||
"profileSigningAlgorithm": "none",
|
|
||||||
"storageLabelClaim": "preferred_username",
|
"storageLabelClaim": "preferred_username",
|
||||||
"storageQuotaClaim": "immich_quota"
|
"storageQuotaClaim": "immich_quota",
|
||||||
|
"timeout": 30000,
|
||||||
|
"tokenEndpointAuthMethod": "client_secret_post"
|
||||||
},
|
},
|
||||||
"passwordLogin": {
|
"passwordLogin": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
"reverseGeocoding": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"externalDomain": "",
|
||||||
|
"loginPageMessage": "",
|
||||||
|
"publicUsers": true
|
||||||
|
},
|
||||||
"storageTemplate": {
|
"storageTemplate": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"hashVerificationEnabled": true,
|
"hashVerificationEnabled": true,
|
||||||
"template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}"
|
"template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}"
|
||||||
},
|
},
|
||||||
"image": {
|
"templates": {
|
||||||
"thumbnail": {
|
"email": {
|
||||||
"format": "webp",
|
"albumInviteTemplate": "",
|
||||||
"size": 250,
|
"albumUpdateTemplate": "",
|
||||||
"quality": 80
|
"welcomeTemplate": ""
|
||||||
},
|
}
|
||||||
"preview": {
|
|
||||||
"format": "jpeg",
|
|
||||||
"size": 1440,
|
|
||||||
"quality": 80
|
|
||||||
},
|
|
||||||
"colorspace": "p3",
|
|
||||||
"extractEmbedded": false
|
|
||||||
},
|
|
||||||
"newVersionCheck": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"trash": {
|
|
||||||
"enabled": true,
|
|
||||||
"days": 30
|
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"customCss": ""
|
"customCss": ""
|
||||||
},
|
},
|
||||||
"library": {
|
"trash": {
|
||||||
"scan": {
|
"days": 30,
|
||||||
"enabled": true,
|
"enabled": true
|
||||||
"cronExpression": "0 0 * * *"
|
|
||||||
},
|
|
||||||
"watch": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"server": {
|
|
||||||
"externalDomain": "",
|
|
||||||
"loginPageMessage": ""
|
|
||||||
},
|
|
||||||
"notifications": {
|
|
||||||
"smtp": {
|
|
||||||
"enabled": false,
|
|
||||||
"from": "",
|
|
||||||
"replyTo": "",
|
|
||||||
"transport": {
|
|
||||||
"ignoreCert": false,
|
|
||||||
"host": "",
|
|
||||||
"port": 587,
|
|
||||||
"username": "",
|
|
||||||
"password": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"deleteDelay": 7
|
"deleteDelay": 7
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ Information on the current workers can be found [here](/administration/jobs-work
|
|||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default | Containers |
|
||||||
| :------------ | :------------- | :----------------------------------------: |
|
| :------------ | :------------- | :----------------------------------------: | :----------------------- |
|
||||||
| `IMMICH_HOST` | Listening host | `0.0.0.0` |
|
| `IMMICH_HOST` | Listening host | `0.0.0.0` | server, machine learning |
|
||||||
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) |
|
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | server, machine learning |
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ Information on the current workers can be found [here](/administration/jobs-work
|
|||||||
| `DB_SSL_MODE` | Database SSL mode | | server |
|
| `DB_SSL_MODE` | Database SSL mode | | server |
|
||||||
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
|
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
|
||||||
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
|
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
|
||||||
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | server |
|
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database |
|
||||||
|
|
||||||
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.
|
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ Information on the current workers can be found [here](/administration/jobs-work
|
|||||||
All `DB_` variables must be provided to all Immich workers, including `api` and `microservices`.
|
All `DB_` variables must be provided to all Immich workers, including `api` and `microservices`.
|
||||||
|
|
||||||
`DB_URL` must be in the format `postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename`.
|
`DB_URL` must be in the format `postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename`.
|
||||||
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&sslmode=no-verify`.
|
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&uselibpqcompat=true`. This allows both immich and `pg_dumpall` (the utility used for database backups) to [properly connect](https://github.com/brianc/node-postgres/tree/master/packages/pg-connection-string#tcp-connections) to your database.
|
||||||
|
|
||||||
When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD` and `DB_DATABASE_NAME` database variables are ignored.
|
When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD` and `DB_DATABASE_NAME` database variables are ignored.
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ Redis (Sentinel) URL example JSON before encoding:
|
|||||||
## Machine Learning
|
## Machine Learning
|
||||||
|
|
||||||
| Variable | Description | Default | Containers |
|
| Variable | Description | Default | Containers |
|
||||||
| :---------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- |
|
| :---------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- |
|
||||||
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
|
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
|
||||||
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
|
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
|
||||||
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
|
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
|
||||||
@@ -169,9 +169,11 @@ Redis (Sentinel) URL example JSON before encoding:
|
|||||||
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
||||||
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
|
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
|
||||||
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
|
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | 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` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
|
||||||
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning |
|
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning |
|
||||||
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
|
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
|
||||||
|
| `MACHINE_LEARNING_OPENVINO_PRECISION` | If set to FP16, uses half-precision floating-point operations for faster inference with reduced accuracy (one of [`FP16`, `FP32`], applies only to OpenVINO) | `FP32` | machine learning |
|
||||||
|
|
||||||
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.
|
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.
|
||||||
|
|
||||||
|
|||||||
25
docs/mise.toml
Normal file
25
docs/mise.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[tasks.install]
|
||||||
|
run = "pnpm install --filter documentation --frozen-lockfile"
|
||||||
|
|
||||||
|
[tasks.start]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "docusaurus --port 3005"
|
||||||
|
|
||||||
|
[tasks.build]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = [
|
||||||
|
"jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
|
||||||
|
"docusaurus build",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tasks.preview]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "docusaurus serve"
|
||||||
|
|
||||||
|
[tasks.format]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "prettier --check ."
|
||||||
|
|
||||||
|
[tasks."format-fix"]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "prettier --write ."
|
||||||
@@ -57,6 +57,6 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "22.20.0"
|
"node": "24.11.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
docs/static/archived-versions.json
vendored
24
docs/static/archived-versions.json
vendored
@@ -1,4 +1,28 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"label": "v2.3.1",
|
||||||
|
"url": "https://docs.v2.3.1.archive.immich.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "v2.3.0",
|
||||||
|
"url": "https://docs.v2.3.0.archive.immich.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "v2.2.3",
|
||||||
|
"url": "https://docs.v2.2.3.archive.immich.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "v2.2.2",
|
||||||
|
"url": "https://docs.v2.2.2.archive.immich.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "v2.2.1",
|
||||||
|
"url": "https://docs.v2.2.1.archive.immich.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "v2.2.0",
|
||||||
|
"url": "https://docs.v2.2.0.archive.immich.app"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "v2.1.0",
|
"label": "v2.1.0",
|
||||||
"url": "https://docs.v2.1.0.archive.immich.app"
|
"url": "https://docs.v2.1.0.archive.immich.app"
|
||||||
|
|||||||
1
e2e/.gitignore
vendored
1
e2e/.gitignore
vendored
@@ -4,3 +4,4 @@ node_modules/
|
|||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/dist
|
/dist
|
||||||
|
.env
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
22.20.0
|
24.11.1
|
||||||
|
|||||||
105
e2e/docker-compose.dev.yml
Normal file
105
e2e/docker-compose.dev.yml
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
name: immich-e2e
|
||||||
|
|
||||||
|
services:
|
||||||
|
immich-server:
|
||||||
|
container_name: immich-e2e-server
|
||||||
|
command: ['immich-dev']
|
||||||
|
image: immich-server-dev:latest
|
||||||
|
build:
|
||||||
|
context: ../
|
||||||
|
dockerfile: server/Dockerfile.dev
|
||||||
|
target: dev
|
||||||
|
environment:
|
||||||
|
- DB_HOSTNAME=database
|
||||||
|
- DB_USERNAME=postgres
|
||||||
|
- DB_PASSWORD=postgres
|
||||||
|
- DB_DATABASE_NAME=immich
|
||||||
|
- IMMICH_MACHINE_LEARNING_ENABLED=false
|
||||||
|
- IMMICH_TELEMETRY_INCLUDE=all
|
||||||
|
- IMMICH_ENV=testing
|
||||||
|
- IMMICH_PORT=2285
|
||||||
|
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
|
||||||
|
volumes:
|
||||||
|
- ./test-assets:/test-assets
|
||||||
|
- ..:/usr/src/app
|
||||||
|
- ${UPLOAD_LOCATION}/photos:/data
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
|
- web-node_modules:/usr/src/app/web/node_modules
|
||||||
|
- github-node_modules:/usr/src/app/.github/node_modules
|
||||||
|
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||||
|
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||||
|
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||||
|
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||||
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
|
- coverage:/usr/src/app/web/coverage
|
||||||
|
- ../plugins:/build/corePlugin
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
database:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
immich-web:
|
||||||
|
container_name: immich-e2e-web
|
||||||
|
image: immich-web-dev:latest
|
||||||
|
build:
|
||||||
|
context: ../
|
||||||
|
dockerfile: server/Dockerfile.dev
|
||||||
|
target: dev
|
||||||
|
command: ['immich-web']
|
||||||
|
ports:
|
||||||
|
- 2285:3000
|
||||||
|
environment:
|
||||||
|
- IMMICH_SERVER_URL=http://immich-server:2285/
|
||||||
|
volumes:
|
||||||
|
- ..:/usr/src/app
|
||||||
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
|
- web-node_modules:/usr/src/app/web/node_modules
|
||||||
|
- github-node_modules:/usr/src/app/.github/node_modules
|
||||||
|
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||||
|
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||||
|
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||||
|
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||||
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
|
- coverage:/usr/src/app/web/coverage
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
|
||||||
|
|
||||||
|
database:
|
||||||
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
|
||||||
|
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_DB: immich
|
||||||
|
ports:
|
||||||
|
- 5435:5432
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U postgres -d immich']
|
||||||
|
interval: 1s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
model-cache:
|
||||||
|
prometheus-data:
|
||||||
|
grafana-data:
|
||||||
|
pnpm-store:
|
||||||
|
server-node_modules:
|
||||||
|
web-node_modules:
|
||||||
|
github-node_modules:
|
||||||
|
cli-node_modules:
|
||||||
|
docs-node_modules:
|
||||||
|
e2e-node_modules:
|
||||||
|
sdk-node_modules:
|
||||||
|
app-node_modules:
|
||||||
|
sveltekit:
|
||||||
|
coverage:
|
||||||
@@ -7,6 +7,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ../
|
context: ../
|
||||||
dockerfile: server/Dockerfile
|
dockerfile: server/Dockerfile
|
||||||
|
cache_from:
|
||||||
|
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-amd64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main
|
||||||
|
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-arm64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main
|
||||||
args:
|
args:
|
||||||
- BUILD_ID=1234567890
|
- BUILD_ID=1234567890
|
||||||
- BUILD_IMAGE=e2e
|
- BUILD_IMAGE=e2e
|
||||||
@@ -35,7 +38,7 @@ services:
|
|||||||
- 2285:2285
|
- 2285:2285
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:6.2-alpine@sha256:77697a75da9f94e9357b61fcaf8345f69e3d9d32e9d15032c8415c21263977dc
|
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
|
||||||
|
|
||||||
database:
|
database:
|
||||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
|
||||||
|
|||||||
29
e2e/mise.toml
Normal file
29
e2e/mise.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[tasks.install]
|
||||||
|
run = "pnpm install --filter immich-e2e --frozen-lockfile"
|
||||||
|
|
||||||
|
[tasks.test]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "vitest --run"
|
||||||
|
|
||||||
|
[tasks."test-web"]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "playwright test"
|
||||||
|
|
||||||
|
[tasks.format]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "prettier --check ."
|
||||||
|
|
||||||
|
[tasks."format-fix"]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "prettier --write ."
|
||||||
|
|
||||||
|
[tasks.lint]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "eslint \"src/**/*.ts\" --max-warnings 0"
|
||||||
|
|
||||||
|
[tasks."lint-fix"]
|
||||||
|
run = { task = "lint --fix" }
|
||||||
|
|
||||||
|
[tasks.check]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "tsc --noEmit"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "2.1.0",
|
"version": "2.3.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -20,21 +20,23 @@
|
|||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.8.0",
|
"@eslint/js": "^9.8.0",
|
||||||
|
"@faker-js/faker": "^10.1.0",
|
||||||
"@immich/cli": "file:../cli",
|
"@immich/cli": "file:../cli",
|
||||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||||
"@playwright/test": "^1.44.1",
|
"@playwright/test": "^1.44.1",
|
||||||
"@socket.io/component-emitter": "^3.1.2",
|
"@socket.io/component-emitter": "^3.1.2",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^22.18.10",
|
"@types/node": "^24.10.1",
|
||||||
"@types/oidc-provider": "^9.0.0",
|
"@types/oidc-provider": "^9.0.0",
|
||||||
"@types/pg": "^8.15.1",
|
"@types/pg": "^8.15.1",
|
||||||
"@types/pngjs": "^6.0.4",
|
"@types/pngjs": "^6.0.4",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"eslint": "^9.14.0",
|
"eslint": "^9.14.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"eslint-plugin-unicorn": "^60.0.0",
|
"eslint-plugin-unicorn": "^62.0.0",
|
||||||
"exiftool-vendored": "^31.1.0",
|
"exiftool-vendored": "^33.0.0",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"jose": "^5.6.3",
|
"jose": "^5.6.3",
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
@@ -43,7 +45,7 @@
|
|||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"prettier-plugin-organize-imports": "^4.0.0",
|
"prettier-plugin-organize-imports": "^4.0.0",
|
||||||
"sharp": "^0.34.4",
|
"sharp": "^0.34.5",
|
||||||
"socket.io-client": "^4.7.4",
|
"socket.io-client": "^4.7.4",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
@@ -52,6 +54,6 @@
|
|||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "22.20.0"
|
"node": "24.11.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,50 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices, PlaywrightTestConfig } from '@playwright/test';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { cpus } from 'node:os';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
export default defineConfig({
|
dotenv.config({ path: resolve(import.meta.dirname, '.env') });
|
||||||
|
|
||||||
|
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
|
||||||
|
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
|
||||||
|
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
|
||||||
|
export const playwriteSlowMo = 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/web/specs',
|
testDir: './src/web/specs',
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 4 : 0,
|
||||||
workers: 1,
|
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://127.0.0.1:2285',
|
baseURL: playwriteBaseUrl,
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
launchOptions: {
|
||||||
|
slowMo: playwriteSlowMo,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
testMatch: /.*\.e2e-spec\.ts/,
|
testMatch: /.*\.e2e-spec\.ts/,
|
||||||
|
|
||||||
|
workers: process.env.CI ? 4 : Math.round(cpus().length * 0.75),
|
||||||
|
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
testMatch: /.*\.e2e-spec\.ts/,
|
||||||
|
workers: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'parallel tests',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
testMatch: /.*\.parallel-e2e-spec\.ts/,
|
||||||
|
fullyParallel: true,
|
||||||
|
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
||||||
},
|
},
|
||||||
|
|
||||||
// {
|
// {
|
||||||
@@ -59,4 +86,8 @@ export default defineConfig({
|
|||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
reuseExistingServer: true,
|
reuseExistingServer: true,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
if (playwrightDisableWebserver) {
|
||||||
|
delete config.webServer;
|
||||||
|
}
|
||||||
|
export default defineConfig(config);
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { DateTime } from 'luxon';
|
|||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { readFile, writeFile } from 'node:fs/promises';
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
import { basename, join } from 'node:path';
|
import { basename, join } from 'node:path';
|
||||||
import sharp from 'sharp';
|
|
||||||
import { Socket } from 'socket.io-client';
|
import { Socket } from 'socket.io-client';
|
||||||
import { createUserDto, uuidDto } from 'src/fixtures';
|
import { createUserDto, uuidDto } from 'src/fixtures';
|
||||||
import { makeRandomImage } from 'src/generators';
|
import { makeRandomImage } from 'src/generators';
|
||||||
@@ -41,40 +40,6 @@ const today = DateTime.fromObject({
|
|||||||
}) as DateTime<true>;
|
}) as DateTime<true>;
|
||||||
const yesterday = today.minus({ days: 1 });
|
const yesterday = today.minus({ days: 1 });
|
||||||
|
|
||||||
const createTestImageWithExif = async (filename: string, exifData: Record<string, any>) => {
|
|
||||||
// Generate unique color to ensure different checksums for each image
|
|
||||||
const r = Math.floor(Math.random() * 256);
|
|
||||||
const g = Math.floor(Math.random() * 256);
|
|
||||||
const b = Math.floor(Math.random() * 256);
|
|
||||||
|
|
||||||
// Create a 100x100 solid color JPEG using Sharp
|
|
||||||
const imageBytes = await sharp({
|
|
||||||
create: {
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
channels: 3,
|
|
||||||
background: { r, g, b },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.jpeg({ quality: 90 })
|
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
// Add random suffix to filename to avoid collisions
|
|
||||||
const uniqueFilename = filename.replace('.jpg', `-${randomBytes(4).toString('hex')}.jpg`);
|
|
||||||
const filepath = join(tempDir, uniqueFilename);
|
|
||||||
await writeFile(filepath, imageBytes);
|
|
||||||
|
|
||||||
// Filter out undefined values before writing EXIF
|
|
||||||
const cleanExifData = Object.fromEntries(Object.entries(exifData).filter(([, value]) => value !== undefined));
|
|
||||||
|
|
||||||
await exiftool.write(filepath, cleanExifData);
|
|
||||||
|
|
||||||
// Re-read the image bytes after EXIF has been written
|
|
||||||
const finalImageBytes = await readFile(filepath);
|
|
||||||
|
|
||||||
return { filepath, imageBytes: finalImageBytes, filename: uniqueFilename };
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('/asset', () => {
|
describe('/asset', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
let websocket: Socket;
|
let websocket: Socket;
|
||||||
@@ -1249,411 +1214,6 @@ describe('/asset', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('EXIF metadata extraction', () => {
|
|
||||||
describe('Additional date tag extraction', () => {
|
|
||||||
describe('Date-time vs time-only tag handling', () => {
|
|
||||||
it('should fall back to file timestamps when only time-only tags are available', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('time-only-fallback.jpg', {
|
|
||||||
TimeCreated: '2023:11:15 14:30:00', // Time-only tag, should not be used for dateTimeOriginal
|
|
||||||
// Exclude all date-time tags to force fallback to file timestamps
|
|
||||||
SubSecDateTimeOriginal: undefined,
|
|
||||||
DateTimeOriginal: undefined,
|
|
||||||
SubSecCreateDate: undefined,
|
|
||||||
SubSecMediaCreateDate: undefined,
|
|
||||||
CreateDate: undefined,
|
|
||||||
MediaCreateDate: undefined,
|
|
||||||
CreationDate: undefined,
|
|
||||||
DateTimeCreated: undefined,
|
|
||||||
GPSDateTime: undefined,
|
|
||||||
DateTimeUTC: undefined,
|
|
||||||
SonyDateTime2: undefined,
|
|
||||||
GPSDateStamp: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const oldDate = new Date('2020-01-01T00:00:00.000Z');
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
fileCreatedAt: oldDate.toISOString(),
|
|
||||||
fileModifiedAt: oldDate.toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
// Should fall back to file timestamps, which we set to 2020-01-01
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2020-01-01T00:00:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should prefer DateTimeOriginal over time-only tags', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('datetime-over-time.jpg', {
|
|
||||||
DateTimeOriginal: '2023:10:10 10:00:00', // Should be preferred
|
|
||||||
TimeCreated: '2023:11:15 14:30:00', // Should be ignored (time-only)
|
|
||||||
});
|
|
||||||
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
// Should use DateTimeOriginal, not TimeCreated
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2023-10-10T10:00:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GPSDateTime tag extraction', () => {
|
|
||||||
it('should extract GPSDateTime with GPS coordinates', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('gps-datetime.jpg', {
|
|
||||||
GPSDateTime: '2023:11:15 12:30:00Z',
|
|
||||||
GPSLatitude: 37.7749,
|
|
||||||
GPSLongitude: -122.4194,
|
|
||||||
// Exclude other date tags
|
|
||||||
SubSecDateTimeOriginal: undefined,
|
|
||||||
DateTimeOriginal: undefined,
|
|
||||||
SubSecCreateDate: undefined,
|
|
||||||
SubSecMediaCreateDate: undefined,
|
|
||||||
CreateDate: undefined,
|
|
||||||
MediaCreateDate: undefined,
|
|
||||||
CreationDate: undefined,
|
|
||||||
DateTimeCreated: undefined,
|
|
||||||
TimeCreated: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
expect(assetInfo.exifInfo?.latitude).toBeCloseTo(37.7749, 4);
|
|
||||||
expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-122.4194, 4);
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2023-11-15T12:30:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CreateDate tag extraction', () => {
|
|
||||||
it('should extract CreateDate when available', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('create-date.jpg', {
|
|
||||||
CreateDate: '2023:11:15 10:30:00',
|
|
||||||
// Exclude other higher priority date tags
|
|
||||||
SubSecDateTimeOriginal: undefined,
|
|
||||||
DateTimeOriginal: undefined,
|
|
||||||
SubSecCreateDate: undefined,
|
|
||||||
SubSecMediaCreateDate: undefined,
|
|
||||||
MediaCreateDate: undefined,
|
|
||||||
CreationDate: undefined,
|
|
||||||
DateTimeCreated: undefined,
|
|
||||||
TimeCreated: undefined,
|
|
||||||
GPSDateTime: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2023-11-15T10:30:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GPSDateStamp tag extraction', () => {
|
|
||||||
it('should fall back to file timestamps when only date-only tags are available', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp.jpg', {
|
|
||||||
GPSDateStamp: '2023:11:15', // Date-only tag, should not be used for dateTimeOriginal
|
|
||||||
// Note: NOT including GPSTimeStamp to avoid automatic GPSDateTime creation
|
|
||||||
GPSLatitude: 51.5074,
|
|
||||||
GPSLongitude: -0.1278,
|
|
||||||
// Explicitly exclude all testable date-time tags to force fallback to file timestamps
|
|
||||||
DateTimeOriginal: undefined,
|
|
||||||
CreateDate: undefined,
|
|
||||||
CreationDate: undefined,
|
|
||||||
GPSDateTime: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const oldDate = new Date('2020-01-01T00:00:00.000Z');
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
fileCreatedAt: oldDate.toISOString(),
|
|
||||||
fileModifiedAt: oldDate.toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
expect(assetInfo.exifInfo?.latitude).toBeCloseTo(51.5074, 4);
|
|
||||||
expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-0.1278, 4);
|
|
||||||
// Should fall back to file timestamps, which we set to 2020-01-01
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2020-01-01T00:00:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
|
||||||
* NOTE: The following EXIF date tags are NOT effectively usable with JPEG test files:
|
|
||||||
*
|
|
||||||
* NOT WRITABLE to JPEG:
|
|
||||||
* - MediaCreateDate: Can be read from video files but not written to JPEG
|
|
||||||
* - DateTimeCreated: Read-only tag in JPEG format
|
|
||||||
* - DateTimeUTC: Cannot be written to JPEG files
|
|
||||||
* - SonyDateTime2: Proprietary Sony tag, not writable to JPEG
|
|
||||||
* - SubSecMediaCreateDate: Tag not defined for JPEG format
|
|
||||||
* - SourceImageCreateTime: Non-standard insta360 tag, not writable to JPEG
|
|
||||||
*
|
|
||||||
* WRITABLE but NOT READABLE from JPEG:
|
|
||||||
* - SubSecDateTimeOriginal: Can be written but not read back from JPEG
|
|
||||||
* - SubSecCreateDate: Can be written but not read back from JPEG
|
|
||||||
*
|
|
||||||
* EFFECTIVELY TESTABLE TAGS (writable and readable):
|
|
||||||
* - DateTimeOriginal ✓
|
|
||||||
* - CreateDate ✓
|
|
||||||
* - CreationDate ✓
|
|
||||||
* - GPSDateTime ✓
|
|
||||||
*
|
|
||||||
* The metadata service correctly handles non-readable tags and will fall back to
|
|
||||||
* file timestamps when only non-readable tags are present.
|
|
||||||
*/
|
|
||||||
|
|
||||||
describe('Date tag priority order', () => {
|
|
||||||
it('should respect the complete date tag priority order', async () => {
|
|
||||||
// Test cases using only EFFECTIVELY TESTABLE tags (writable AND readable from JPEG)
|
|
||||||
const testCases = [
|
|
||||||
{
|
|
||||||
name: 'DateTimeOriginal has highest priority among testable tags',
|
|
||||||
exifData: {
|
|
||||||
DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
|
|
||||||
CreateDate: '2023:05:05 05:00:00', // TESTABLE
|
|
||||||
CreationDate: '2023:07:07 07:00:00', // TESTABLE
|
|
||||||
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
|
|
||||||
},
|
|
||||||
expectedDate: '2023-04-04T04:00:00.000Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'CreationDate when DateTimeOriginal missing',
|
|
||||||
exifData: {
|
|
||||||
CreationDate: '2023:05:05 05:00:00', // TESTABLE
|
|
||||||
CreateDate: '2023:07:07 07:00:00', // TESTABLE
|
|
||||||
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
|
|
||||||
},
|
|
||||||
expectedDate: '2023-05-05T05:00:00.000Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'CreationDate when standard EXIF tags missing',
|
|
||||||
exifData: {
|
|
||||||
CreationDate: '2023:07:07 07:00:00', // TESTABLE
|
|
||||||
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
|
|
||||||
},
|
|
||||||
expectedDate: '2023-07-07T07:00:00.000Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'GPSDateTime when no other testable date tags present',
|
|
||||||
exifData: {
|
|
||||||
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
|
|
||||||
Make: 'SONY',
|
|
||||||
},
|
|
||||||
expectedDate: '2023-10-10T10:00:00.000Z',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const testCase of testCases) {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif(
|
|
||||||
`${testCase.name.replaceAll(/\s+/g, '-').toLowerCase()}.jpg`,
|
|
||||||
testCase.exifData,
|
|
||||||
);
|
|
||||||
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal, `Failed for: ${testCase.name}`).toBeDefined();
|
|
||||||
expect(
|
|
||||||
new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime(),
|
|
||||||
`Date mismatch for: ${testCase.name}`,
|
|
||||||
).toBe(new Date(testCase.expectedDate).getTime());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Edge cases for date tag handling', () => {
|
|
||||||
it('should fall back to file timestamps with GPSDateStamp alone', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp-only.jpg', {
|
|
||||||
GPSDateStamp: '2023:08:08', // Date-only tag, should not be used for dateTimeOriginal
|
|
||||||
// Intentionally no GPSTimeStamp
|
|
||||||
// Exclude all other date tags
|
|
||||||
SubSecDateTimeOriginal: undefined,
|
|
||||||
DateTimeOriginal: undefined,
|
|
||||||
SubSecCreateDate: undefined,
|
|
||||||
SubSecMediaCreateDate: undefined,
|
|
||||||
CreateDate: undefined,
|
|
||||||
MediaCreateDate: undefined,
|
|
||||||
CreationDate: undefined,
|
|
||||||
DateTimeCreated: undefined,
|
|
||||||
TimeCreated: undefined,
|
|
||||||
GPSDateTime: undefined,
|
|
||||||
DateTimeUTC: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const oldDate = new Date('2020-01-01T00:00:00.000Z');
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
fileCreatedAt: oldDate.toISOString(),
|
|
||||||
fileModifiedAt: oldDate.toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
// Should fall back to file timestamps, which we set to 2020-01-01
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2020-01-01T00:00:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle all testable date tags present to verify complete priority order', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('all-testable-date-tags.jpg', {
|
|
||||||
// All TESTABLE date tags to JPEG format (writable AND readable)
|
|
||||||
DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
|
|
||||||
CreateDate: '2023:05:05 05:00:00', // TESTABLE
|
|
||||||
CreationDate: '2023:07:07 07:00:00', // TESTABLE
|
|
||||||
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
|
|
||||||
// Note: Excluded non-testable tags:
|
|
||||||
// SubSec tags: writable but not readable from JPEG
|
|
||||||
// Non-writable tags: MediaCreateDate, DateTimeCreated, DateTimeUTC, SonyDateTime2, etc.
|
|
||||||
// Time-only/date-only tags: already excluded from EXIF_DATE_TAGS
|
|
||||||
});
|
|
||||||
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
// Should use DateTimeOriginal as it has the highest priority among testable tags
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2023-04-04T04:00:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use CreationDate when SubSec tags are missing', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('creation-date-priority.jpg', {
|
|
||||||
CreationDate: '2023:07:07 07:00:00', // WRITABLE
|
|
||||||
GPSDateTime: '2023:10:10 10:00:00', // WRITABLE
|
|
||||||
// Note: DateTimeCreated, DateTimeUTC, SonyDateTime2 are NOT writable to JPEG
|
|
||||||
// Note: TimeCreated and GPSDateStamp are excluded from EXIF_DATE_TAGS (time-only/date-only)
|
|
||||||
// Exclude SubSec and standard EXIF tags
|
|
||||||
SubSecDateTimeOriginal: undefined,
|
|
||||||
DateTimeOriginal: undefined,
|
|
||||||
SubSecCreateDate: undefined,
|
|
||||||
CreateDate: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
// Should use CreationDate when available
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2023-07-07T07:00:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should skip invalid date formats and use next valid tag', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('invalid-date-handling.jpg', {
|
|
||||||
// Note: Testing invalid date handling with only WRITABLE tags
|
|
||||||
GPSDateTime: '2023:10:10 10:00:00', // WRITABLE - Valid date
|
|
||||||
CreationDate: '2023:13:13 13:00:00', // WRITABLE - Valid date
|
|
||||||
// Note: TimeCreated excluded (time-only), DateTimeCreated not writable to JPEG
|
|
||||||
// Exclude other date tags
|
|
||||||
SubSecDateTimeOriginal: undefined,
|
|
||||||
DateTimeOriginal: undefined,
|
|
||||||
SubSecCreateDate: undefined,
|
|
||||||
CreateDate: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
// Should skip invalid dates and use the first valid one (GPSDateTime)
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2023-10-10T10:00:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /assets/exist', () => {
|
describe('POST /assets/exist', () => {
|
||||||
it('ignores invalid deviceAssetIds', async () => {
|
it('ignores invalid deviceAssetIds', async () => {
|
||||||
const response = await utils.checkExistingAssets(user1.accessToken, {
|
const response = await utils.checkExistingAssets(user1.accessToken, {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { JobCommand, JobName, LoginResponseDto, updateConfig } from '@immich/sdk';
|
import { LoginResponseDto, QueueCommand, QueueName, updateConfig } from '@immich/sdk';
|
||||||
import { cpSync, rmSync } from 'node:fs';
|
import { cpSync, rmSync } from 'node:fs';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { basename } from 'node:path';
|
import { basename } from 'node:path';
|
||||||
@@ -17,28 +17,28 @@ describe('/jobs', () => {
|
|||||||
|
|
||||||
describe('PUT /jobs', () => {
|
describe('PUT /jobs', () => {
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||||
command: JobCommand.Resume,
|
command: QueueCommand.Resume,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||||
command: JobCommand.Resume,
|
command: QueueCommand.Resume,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.FaceDetection, {
|
await utils.queueCommand(admin.accessToken, QueueName.FaceDetection, {
|
||||||
command: JobCommand.Resume,
|
command: QueueCommand.Resume,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.SmartSearch, {
|
await utils.queueCommand(admin.accessToken, QueueName.SmartSearch, {
|
||||||
command: JobCommand.Resume,
|
command: QueueCommand.Resume,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.DuplicateDetection, {
|
await utils.queueCommand(admin.accessToken, QueueName.DuplicateDetection, {
|
||||||
command: JobCommand.Resume,
|
command: QueueCommand.Resume,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,8 +59,8 @@ describe('/jobs', () => {
|
|||||||
it('should queue metadata extraction for missing assets', async () => {
|
it('should queue metadata extraction for missing assets', async () => {
|
||||||
const path = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`;
|
const path = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`;
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||||
command: JobCommand.Pause,
|
command: QueueCommand.Pause,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,20 +77,20 @@ describe('/jobs', () => {
|
|||||||
expect(asset.exifInfo?.make).toBeNull();
|
expect(asset.exifInfo?.make).toBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||||
command: JobCommand.Empty,
|
command: QueueCommand.Empty,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||||
command: JobCommand.Resume,
|
command: QueueCommand.Resume,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||||
command: JobCommand.Start,
|
command: QueueCommand.Start,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,8 +124,8 @@ describe('/jobs', () => {
|
|||||||
|
|
||||||
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, path);
|
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, path);
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||||
command: JobCommand.Start,
|
command: QueueCommand.Start,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,8 +144,8 @@ describe('/jobs', () => {
|
|||||||
it('should queue thumbnail extraction for assets missing thumbs', async () => {
|
it('should queue thumbnail extraction for assets missing thumbs', async () => {
|
||||||
const path = `${testAssetDir}/albums/nature/tanners_ridge.jpg`;
|
const path = `${testAssetDir}/albums/nature/tanners_ridge.jpg`;
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||||
command: JobCommand.Pause,
|
command: QueueCommand.Pause,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,32 +153,32 @@ describe('/jobs', () => {
|
|||||||
assetData: { bytes: await readFile(path), filename: basename(path) },
|
assetData: { bytes: await readFile(path), filename: basename(path) },
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||||
|
|
||||||
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
|
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
|
||||||
expect(assetBefore.thumbhash).toBeNull();
|
expect(assetBefore.thumbhash).toBeNull();
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||||
command: JobCommand.Empty,
|
command: QueueCommand.Empty,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||||
command: JobCommand.Resume,
|
command: QueueCommand.Resume,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||||
command: JobCommand.Start,
|
command: QueueCommand.Start,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||||
|
|
||||||
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
|
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
|
||||||
expect(assetAfter.thumbhash).not.toBeNull();
|
expect(assetAfter.thumbhash).not.toBeNull();
|
||||||
@@ -193,26 +193,26 @@ describe('/jobs', () => {
|
|||||||
assetData: { bytes: await readFile(path), filename: basename(path) },
|
assetData: { bytes: await readFile(path), filename: basename(path) },
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||||
|
|
||||||
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
|
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
|
||||||
|
|
||||||
cpSync(`${testAssetDir}/albums/nature/notocactus_minimus.jpg`, path);
|
cpSync(`${testAssetDir}/albums/nature/notocactus_minimus.jpg`, path);
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||||
command: JobCommand.Resume,
|
command: QueueCommand.Resume,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// This runs the missing thumbnail job
|
// This runs the missing thumbnail job
|
||||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||||
command: JobCommand.Start,
|
command: QueueCommand.Start,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||||
|
|
||||||
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
|
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
|
||||||
|
|
||||||
|
|||||||
@@ -1006,7 +1006,7 @@ describe('/libraries', () => {
|
|||||||
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
|
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => {
|
it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => {
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
ownerId: admin.userId,
|
ownerId: admin.userId,
|
||||||
importPaths: [`${testAssetDirInternal}/temp/xmp`],
|
importPaths: [`${testAssetDirInternal}/temp/xmp`],
|
||||||
|
|||||||
172
e2e/src/api/specs/maintenance.e2e-spec.ts
Normal file
172
e2e/src/api/specs/maintenance.e2e-spec.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { LoginResponseDto } from '@immich/sdk';
|
||||||
|
import { createUserDto } from 'src/fixtures';
|
||||||
|
import { errorDto } from 'src/responses';
|
||||||
|
import { app, utils } from 'src/utils';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('/admin/maintenance', () => {
|
||||||
|
let cookie: string | undefined;
|
||||||
|
let admin: LoginResponseDto;
|
||||||
|
let nonAdmin: LoginResponseDto;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await utils.resetDatabase();
|
||||||
|
admin = await utils.adminSetup();
|
||||||
|
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// => outside of maintenance mode
|
||||||
|
|
||||||
|
describe('GET ~/server/config', async () => {
|
||||||
|
it('should indicate we are out of maintenance mode', async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.maintenanceMode).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /login', async () => {
|
||||||
|
it('should not work out of maintenance mode', async () => {
|
||||||
|
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest('Not in maintenance mode'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// => enter maintenance mode
|
||||||
|
|
||||||
|
describe.sequential('POST /', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const { status, body } = await request(app).post('/admin/maintenance').send({
|
||||||
|
action: 'end',
|
||||||
|
});
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body).toEqual(errorDto.unauthorized);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only work for admins', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.post('/admin/maintenance')
|
||||||
|
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
|
||||||
|
.send({ action: 'end' });
|
||||||
|
expect(status).toBe(403);
|
||||||
|
expect(body).toEqual(errorDto.forbidden);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be a no-op if try to exit maintenance mode', async () => {
|
||||||
|
const { status } = await request(app)
|
||||||
|
.post('/admin/maintenance')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({ action: 'end' });
|
||||||
|
expect(status).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enter maintenance mode', async () => {
|
||||||
|
const { status, headers } = await request(app)
|
||||||
|
.post('/admin/maintenance')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({
|
||||||
|
action: 'start',
|
||||||
|
});
|
||||||
|
expect(status).toBe(201);
|
||||||
|
|
||||||
|
cookie = headers['set-cookie'][0].split(';')[0];
|
||||||
|
expect(cookie).toEqual(
|
||||||
|
expect.stringMatching(/^immich_maintenance_token=[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*$/),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { body } = await request(app).get('/server/config');
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 5e2,
|
||||||
|
timeout: 1e4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// => in maintenance mode
|
||||||
|
|
||||||
|
describe.sequential('in maintenance mode', () => {
|
||||||
|
describe('GET ~/server/config', async () => {
|
||||||
|
it('should indicate we are in maintenance mode', async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.maintenanceMode).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /login', async () => {
|
||||||
|
it('should fail without cookie or token in body', async () => {
|
||||||
|
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body).toEqual(errorDto.unauthorizedWithMessage('Missing JWT Token'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should succeed with cookie', async () => {
|
||||||
|
const { status, body } = await request(app).post('/admin/maintenance/login').set('cookie', cookie!).send({});
|
||||||
|
expect(status).toBe(201);
|
||||||
|
expect(body).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
username: 'Immich Admin',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should succeed with token', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.post('/admin/maintenance/login')
|
||||||
|
.send({
|
||||||
|
token: cookie!.split('=')[1].trim(),
|
||||||
|
});
|
||||||
|
expect(status).toBe(201);
|
||||||
|
expect(body).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
username: 'Immich Admin',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /', async () => {
|
||||||
|
it('should be a no-op if try to enter maintenance mode', async () => {
|
||||||
|
const { status } = await request(app)
|
||||||
|
.post('/admin/maintenance')
|
||||||
|
.set('cookie', cookie!)
|
||||||
|
.send({ action: 'start' });
|
||||||
|
expect(status).toBe(201);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// => exit maintenance mode
|
||||||
|
|
||||||
|
describe.sequential('POST /', () => {
|
||||||
|
it('should exit maintenance mode', async () => {
|
||||||
|
const { status } = await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
|
||||||
|
action: 'end',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(201);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { body } = await request(app).get('/server/config');
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 5e2,
|
||||||
|
timeout: 1e4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -113,6 +113,7 @@ describe('/server', () => {
|
|||||||
importFaces: false,
|
importFaces: false,
|
||||||
oauth: false,
|
oauth: false,
|
||||||
oauthAutoLaunch: false,
|
oauthAutoLaunch: false,
|
||||||
|
ocr: false,
|
||||||
passwordLogin: true,
|
passwordLogin: true,
|
||||||
search: true,
|
search: true,
|
||||||
sidecar: true,
|
sidecar: true,
|
||||||
@@ -135,6 +136,7 @@ describe('/server', () => {
|
|||||||
externalDomain: '',
|
externalDomain: '',
|
||||||
publicUsers: true,
|
publicUsers: true,
|
||||||
isOnboarded: false,
|
isOnboarded: false,
|
||||||
|
maintenanceMode: false,
|
||||||
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||||
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
JobName,
|
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
|
QueueName,
|
||||||
createStack,
|
createStack,
|
||||||
deleteUserAdmin,
|
deleteUserAdmin,
|
||||||
getMyUser,
|
getMyUser,
|
||||||
@@ -328,7 +328,7 @@ describe('/admin/users', () => {
|
|||||||
{ headers: asBearerAuth(user.accessToken) },
|
{ headers: asBearerAuth(user.accessToken) },
|
||||||
);
|
);
|
||||||
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.BackgroundTask);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.BackgroundTask);
|
||||||
|
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.delete(`/admin/users/${user.userId}`)
|
.delete(`/admin/users/${user.userId}`)
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Script to generate test images with additional EXIF date tags
|
|
||||||
* This creates actual JPEG images with embedded metadata for testing
|
|
||||||
* Images are generated into e2e/test-assets/metadata/dates/
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
|
||||||
import { writeFileSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import sharp from 'sharp';
|
|
||||||
|
|
||||||
interface TestImage {
|
|
||||||
filename: string;
|
|
||||||
description: string;
|
|
||||||
exifTags: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const testImages: TestImage[] = [
|
|
||||||
{
|
|
||||||
filename: 'time-created.jpg',
|
|
||||||
description: 'Image with TimeCreated tag',
|
|
||||||
exifTags: {
|
|
||||||
TimeCreated: '2023:11:15 14:30:00',
|
|
||||||
Make: 'Canon',
|
|
||||||
Model: 'EOS R5',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
filename: 'gps-datetime.jpg',
|
|
||||||
description: 'Image with GPSDateTime and coordinates',
|
|
||||||
exifTags: {
|
|
||||||
GPSDateTime: '2023:11:15 12:30:00Z',
|
|
||||||
GPSLatitude: '37.7749',
|
|
||||||
GPSLongitude: '-122.4194',
|
|
||||||
GPSLatitudeRef: 'N',
|
|
||||||
GPSLongitudeRef: 'W',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
filename: 'datetime-utc.jpg',
|
|
||||||
description: 'Image with DateTimeUTC tag',
|
|
||||||
exifTags: {
|
|
||||||
DateTimeUTC: '2023:11:15 10:30:00',
|
|
||||||
Make: 'Nikon',
|
|
||||||
Model: 'D850',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
filename: 'gps-datestamp.jpg',
|
|
||||||
description: 'Image with GPSDateStamp and GPSTimeStamp',
|
|
||||||
exifTags: {
|
|
||||||
GPSDateStamp: '2023:11:15',
|
|
||||||
GPSTimeStamp: '08:30:00',
|
|
||||||
GPSLatitude: '51.5074',
|
|
||||||
GPSLongitude: '-0.1278',
|
|
||||||
GPSLatitudeRef: 'N',
|
|
||||||
GPSLongitudeRef: 'W',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
filename: 'sony-datetime2.jpg',
|
|
||||||
description: 'Sony camera image with SonyDateTime2 tag',
|
|
||||||
exifTags: {
|
|
||||||
SonyDateTime2: '2023:11:15 06:30:00',
|
|
||||||
Make: 'SONY',
|
|
||||||
Model: 'ILCE-7RM5',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
filename: 'date-priority-test.jpg',
|
|
||||||
description: 'Image with multiple date tags to test priority',
|
|
||||||
exifTags: {
|
|
||||||
SubSecDateTimeOriginal: '2023:01:01 01:00:00',
|
|
||||||
DateTimeOriginal: '2023:02:02 02:00:00',
|
|
||||||
SubSecCreateDate: '2023:03:03 03:00:00',
|
|
||||||
CreateDate: '2023:04:04 04:00:00',
|
|
||||||
CreationDate: '2023:05:05 05:00:00',
|
|
||||||
DateTimeCreated: '2023:06:06 06:00:00',
|
|
||||||
TimeCreated: '2023:07:07 07:00:00',
|
|
||||||
GPSDateTime: '2023:08:08 08:00:00',
|
|
||||||
DateTimeUTC: '2023:09:09 09:00:00',
|
|
||||||
GPSDateStamp: '2023:10:10',
|
|
||||||
SonyDateTime2: '2023:11:11 11:00:00',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
filename: 'new-tags-only.jpg',
|
|
||||||
description: 'Image with only additional date tags (no standard tags)',
|
|
||||||
exifTags: {
|
|
||||||
TimeCreated: '2023:12:01 15:45:30',
|
|
||||||
GPSDateTime: '2023:12:01 13:45:30Z',
|
|
||||||
DateTimeUTC: '2023:12:01 13:45:30',
|
|
||||||
GPSDateStamp: '2023:12:01',
|
|
||||||
SonyDateTime2: '2023:12:01 08:45:30',
|
|
||||||
GPSLatitude: '40.7128',
|
|
||||||
GPSLongitude: '-74.0060',
|
|
||||||
GPSLatitudeRef: 'N',
|
|
||||||
GPSLongitudeRef: 'W',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const generateTestImages = async (): Promise<void> => {
|
|
||||||
// Target directory: e2e/test-assets/metadata/dates/
|
|
||||||
// Current file is in: e2e/src/
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
const targetDir = join(__dirname, '..', 'test-assets', 'metadata', 'dates');
|
|
||||||
|
|
||||||
console.log('Generating test images with additional EXIF date tags...');
|
|
||||||
console.log(`Target directory: ${targetDir}`);
|
|
||||||
|
|
||||||
for (const image of testImages) {
|
|
||||||
try {
|
|
||||||
const imagePath = join(targetDir, image.filename);
|
|
||||||
|
|
||||||
// Create unique JPEG file using Sharp
|
|
||||||
const r = Math.floor(Math.random() * 256);
|
|
||||||
const g = Math.floor(Math.random() * 256);
|
|
||||||
const b = Math.floor(Math.random() * 256);
|
|
||||||
|
|
||||||
const jpegData = await sharp({
|
|
||||||
create: {
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
channels: 3,
|
|
||||||
background: { r, g, b },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.jpeg({ quality: 90 })
|
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
writeFileSync(imagePath, jpegData);
|
|
||||||
|
|
||||||
// Build exiftool command to add EXIF data
|
|
||||||
const exifArgs = Object.entries(image.exifTags)
|
|
||||||
.map(([tag, value]) => `-${tag}="${value}"`)
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
const command = `exiftool ${exifArgs} -overwrite_original "${imagePath}"`;
|
|
||||||
|
|
||||||
console.log(`Creating ${image.filename}: ${image.description}`);
|
|
||||||
execSync(command, { stdio: 'pipe' });
|
|
||||||
|
|
||||||
// Verify the tags were written
|
|
||||||
const verifyCommand = `exiftool -json "${imagePath}"`;
|
|
||||||
const result = execSync(verifyCommand, { encoding: 'utf8' });
|
|
||||||
const metadata = JSON.parse(result)[0];
|
|
||||||
|
|
||||||
console.log(` ✓ Created with ${Object.keys(image.exifTags).length} EXIF tags`);
|
|
||||||
|
|
||||||
// Log first date tag found for verification
|
|
||||||
const firstDateTag = Object.keys(image.exifTags).find(
|
|
||||||
(tag) => tag.includes('Date') || tag.includes('Time') || tag.includes('Created'),
|
|
||||||
);
|
|
||||||
if (firstDateTag && metadata[firstDateTag]) {
|
|
||||||
console.log(` ✓ Verified ${firstDateTag}: ${metadata[firstDateTag]}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to create ${image.filename}:`, (error as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\nTest image generation complete!');
|
|
||||||
console.log('Files created in:', targetDir);
|
|
||||||
console.log('\nTo test these images:');
|
|
||||||
console.log(`cd ${targetDir} && exiftool -time:all -gps:all *.jpg`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { generateTestImages };
|
|
||||||
|
|
||||||
// Run the generator if this file is executed directly
|
|
||||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
||||||
generateTestImages().catch(console.error);
|
|
||||||
}
|
|
||||||
37
e2e/src/generators/timeline.ts
Normal file
37
e2e/src/generators/timeline.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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';
|
||||||
183
e2e/src/generators/timeline/distribution-patterns.ts
Normal file
183
e2e/src/generators/timeline/distribution-patterns.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { generateConsecutiveDays, generateDayAssets } from 'src/generators/timeline/model-objects';
|
||||||
|
import { SeededRandom, selectRandomDays } from 'src/generators/timeline/utils';
|
||||||
|
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
|
||||||
111
e2e/src/generators/timeline/images.ts
Normal file
111
e2e/src/generators/timeline/images.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
import { SeededRandom } from 'src/generators/timeline/utils';
|
||||||
|
|
||||||
|
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 & 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;
|
||||||
|
};
|
||||||
265
e2e/src/generators/timeline/model-objects.ts
Normal file
265
e2e/src/generators/timeline/model-objects.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* 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/generators/timeline/utils';
|
||||||
|
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): string {
|
||||||
|
return `${rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS)}.${rng.nextInt(0, 1000).toString().padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.parseFloat(ratio.split(':')[0]) / Number.parseFloat(ratio.split(':')[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()].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 };
|
||||||
|
}
|
||||||
438
e2e/src/generators/timeline/rest-response.ts
Normal file
438
e2e/src/generators/timeline/rest-response.ts
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
/**
|
||||||
|
* REST API output functions for converting timeline data to API response formats
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
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';
|
||||||
|
import { parseTimeBucketKey } from 'src/generators/timeline/utils';
|
||||||
|
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: [],
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (isFavorite !== undefined && actuallyFavorited !== isFavorite) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
deviceAssetId: `device-${asset.id}`,
|
||||||
|
ownerId: asset.ownerId,
|
||||||
|
owner: owner || defaultOwner,
|
||||||
|
libraryId: `library-${asset.ownerId}`,
|
||||||
|
deviceId: `device-${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 || '0:00:00.00000',
|
||||||
|
exifInfo,
|
||||||
|
livePhotoVideoId: asset.livePhotoVideoId,
|
||||||
|
tags: [],
|
||||||
|
people: [],
|
||||||
|
unassignedFaces: [],
|
||||||
|
stack: asset.stack,
|
||||||
|
isOffline: false,
|
||||||
|
hasMetadata: true,
|
||||||
|
duplicateId: null,
|
||||||
|
resized: true,
|
||||||
|
checksum: asset.checksum,
|
||||||
|
width: exifInfo.exifImageWidth ?? 1,
|
||||||
|
height: exifInfo.exifImageHeight ?? 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()];
|
||||||
|
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()].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,
|
||||||
|
ownerId: albumOwner.id,
|
||||||
|
owner: albumOwner,
|
||||||
|
albumUsers: [], // Empty array for non-shared album
|
||||||
|
shared: false,
|
||||||
|
hasSharedLink: false,
|
||||||
|
isActivityEnabled: true,
|
||||||
|
assetCount: albumAssets.length,
|
||||||
|
assets: albumAssets,
|
||||||
|
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;
|
||||||
|
}
|
||||||
200
e2e/src/generators/timeline/timeline-config.ts
Normal file
200
e2e/src/generators/timeline/timeline-config.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import type { AssetVisibility } from '@immich/sdk';
|
||||||
|
import { DayPattern, MonthDistribution } from 'src/generators/timeline/distribution-patterns';
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
export type AspectRatio = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
ratio: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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: string | 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.isInteger(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
186
e2e/src/generators/timeline/utils.ts
Normal file
186
e2e/src/generators/timeline/utils.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { GENERATION_CONSTANTS, MockTimelineAsset } from 'src/generators/timeline/timeline-config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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'));
|
||||||
|
} else if (unit === 'month') {
|
||||||
|
return date1.year !== date2.year || date1.month !== date2.month;
|
||||||
|
} else {
|
||||||
|
return 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;
|
||||||
|
}
|
||||||
285
e2e/src/mock-network/base-network.ts
Normal file
285
e2e/src/mock-network/base-network.ts
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import { BrowserContext } from '@playwright/test';
|
||||||
|
import { playwrightHost } from 'playwright.config';
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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',
|
||||||
|
'.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',
|
||||||
|
'.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) => {
|
||||||
|
if (request.url().endsWith('albums?shared=true') || request.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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
149
e2e/src/mock-network/timeline-network.ts
Normal file
149
e2e/src/mock-network/timeline-network.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { BrowserContext, Page, Request, Route } from '@playwright/test';
|
||||||
|
import { basename } from 'node:path';
|
||||||
|
import {
|
||||||
|
Changes,
|
||||||
|
getAlbum,
|
||||||
|
getAsset,
|
||||||
|
getTimeBucket,
|
||||||
|
getTimeBuckets,
|
||||||
|
randomPreview,
|
||||||
|
randomThumbnail,
|
||||||
|
TimelineData,
|
||||||
|
} from 'src/generators/timeline';
|
||||||
|
import { sleep } from 'src/web/specs/timeline/utils';
|
||||||
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
} else 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) => {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const pathname = url.pathname;
|
||||||
|
const assetId = basename(pathname);
|
||||||
|
const asset = getAsset(timelineRestData, assetId);
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: asset,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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/albums/**', async (route, request) => {
|
||||||
|
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
|
||||||
|
const match = request.url().match(pattern);
|
||||||
|
if (!match) {
|
||||||
|
return route.continue();
|
||||||
|
}
|
||||||
|
const album = getAlbum(timelineRestData, testContext.adminId, match.groups?.albumId, changes);
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: album,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -7,6 +7,12 @@ export const errorDto = {
|
|||||||
message: 'Authentication required',
|
message: 'Authentication required',
|
||||||
correlationId: expect.any(String),
|
correlationId: expect.any(String),
|
||||||
},
|
},
|
||||||
|
unauthorizedWithMessage: (message: string) => ({
|
||||||
|
error: 'Unauthorized',
|
||||||
|
statusCode: 401,
|
||||||
|
message,
|
||||||
|
correlationId: expect.any(String),
|
||||||
|
}),
|
||||||
forbidden: {
|
forbidden: {
|
||||||
error: 'Forbidden',
|
error: 'Forbidden',
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
AllJobStatusResponseDto,
|
|
||||||
AssetMediaCreateDto,
|
AssetMediaCreateDto,
|
||||||
AssetMediaResponseDto,
|
AssetMediaResponseDto,
|
||||||
AssetResponseDto,
|
AssetResponseDto,
|
||||||
@@ -7,11 +6,13 @@ import {
|
|||||||
CheckExistingAssetsDto,
|
CheckExistingAssetsDto,
|
||||||
CreateAlbumDto,
|
CreateAlbumDto,
|
||||||
CreateLibraryDto,
|
CreateLibraryDto,
|
||||||
JobCommandDto,
|
MaintenanceAction,
|
||||||
JobName,
|
|
||||||
MetadataSearchDto,
|
MetadataSearchDto,
|
||||||
Permission,
|
Permission,
|
||||||
PersonCreateDto,
|
PersonCreateDto,
|
||||||
|
QueueCommandDto,
|
||||||
|
QueueName,
|
||||||
|
QueuesResponseLegacyDto,
|
||||||
SharedLinkCreateDto,
|
SharedLinkCreateDto,
|
||||||
UpdateLibraryDto,
|
UpdateLibraryDto,
|
||||||
UserAdminCreateDto,
|
UserAdminCreateDto,
|
||||||
@@ -27,15 +28,16 @@ import {
|
|||||||
createStack,
|
createStack,
|
||||||
createUserAdmin,
|
createUserAdmin,
|
||||||
deleteAssets,
|
deleteAssets,
|
||||||
getAllJobsStatus,
|
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
getConfig,
|
getConfig,
|
||||||
getConfigDefaults,
|
getConfigDefaults,
|
||||||
|
getQueuesLegacy,
|
||||||
login,
|
login,
|
||||||
|
runQueueCommandLegacy,
|
||||||
scanLibrary,
|
scanLibrary,
|
||||||
searchAssets,
|
searchAssets,
|
||||||
sendJobCommand,
|
|
||||||
setBaseUrl,
|
setBaseUrl,
|
||||||
|
setMaintenanceMode,
|
||||||
signUpAdmin,
|
signUpAdmin,
|
||||||
tagAssets,
|
tagAssets,
|
||||||
updateAdminOnboarding,
|
updateAdminOnboarding,
|
||||||
@@ -52,7 +54,7 @@ import { exec, spawn } from 'node:child_process';
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path, { dirname } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
|
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
@@ -60,6 +62,8 @@ import { io, type Socket } from 'socket.io-client';
|
|||||||
import { loginDto, signupDto } from 'src/fixtures';
|
import { loginDto, signupDto } from 'src/fixtures';
|
||||||
import { makeRandomImage } from 'src/generators';
|
import { makeRandomImage } from 'src/generators';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
|
import { playwrightDbHost, playwrightHost, playwriteBaseUrl } from '../playwright.config';
|
||||||
|
|
||||||
export type { Emitter } from '@socket.io/component-emitter';
|
export type { Emitter } from '@socket.io/component-emitter';
|
||||||
|
|
||||||
type CommandResponse = { stdout: string; stderr: string; exitCode: number | null };
|
type CommandResponse = { stdout: string; stderr: string; exitCode: number | null };
|
||||||
@@ -68,12 +72,12 @@ type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: nu
|
|||||||
type AdminSetupOptions = { onboarding?: boolean };
|
type AdminSetupOptions = { onboarding?: boolean };
|
||||||
type FileData = { bytes?: Buffer; filename: string };
|
type FileData = { bytes?: Buffer; filename: string };
|
||||||
|
|
||||||
const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5435/immich';
|
const dbUrl = `postgres://postgres:postgres@${playwrightDbHost}:5435/immich`;
|
||||||
export const baseUrl = 'http://127.0.0.1:2285';
|
export const baseUrl = playwriteBaseUrl;
|
||||||
export const shareUrl = `${baseUrl}/share`;
|
export const shareUrl = `${baseUrl}/share`;
|
||||||
export const app = `${baseUrl}/api`;
|
export const app = `${baseUrl}/api`;
|
||||||
// TODO move test assets into e2e/assets
|
// TODO move test assets into e2e/assets
|
||||||
export const testAssetDir = path.resolve('./test-assets');
|
export const testAssetDir = resolve(import.meta.dirname, '../test-assets');
|
||||||
export const testAssetDirInternal = '/test-assets';
|
export const testAssetDirInternal = '/test-assets';
|
||||||
export const tempDir = tmpdir();
|
export const tempDir = tmpdir();
|
||||||
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
|
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
|
||||||
@@ -477,10 +481,10 @@ export const utils = {
|
|||||||
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
|
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
|
||||||
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
|
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
jobCommand: async (accessToken: string, jobName: JobName, jobCommandDto: JobCommandDto) =>
|
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
|
||||||
sendJobCommand({ id: jobName, jobCommandDto }, { headers: asBearerAuth(accessToken) }),
|
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
|
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = playwrightHost) =>
|
||||||
await context.addCookies([
|
await context.addCookies([
|
||||||
{
|
{
|
||||||
name: 'immich_access_token',
|
name: 'immich_access_token',
|
||||||
@@ -514,6 +518,42 @@ export const utils = {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
setMaintenanceAuthCookie: async (context: BrowserContext, token: string, domain = '127.0.0.1') =>
|
||||||
|
await context.addCookies([
|
||||||
|
{
|
||||||
|
name: 'immich_maintenance_token',
|
||||||
|
value: token,
|
||||||
|
domain,
|
||||||
|
path: '/',
|
||||||
|
expires: 2_058_028_213,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
|
||||||
|
enterMaintenance: async (accessToken: string) => {
|
||||||
|
let setCookie: string[] | undefined;
|
||||||
|
|
||||||
|
await setMaintenanceMode(
|
||||||
|
{
|
||||||
|
setMaintenanceModeDto: {
|
||||||
|
action: MaintenanceAction.Start,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: asBearerAuth(accessToken),
|
||||||
|
fetch: (...args: Parameters<typeof fetch>) =>
|
||||||
|
fetch(...args).then((response) => {
|
||||||
|
setCookie = response.headers.getSetCookie();
|
||||||
|
return response;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return setCookie;
|
||||||
|
},
|
||||||
|
|
||||||
resetTempFolder: () => {
|
resetTempFolder: () => {
|
||||||
rmSync(`${testAssetDir}/temp`, { recursive: true, force: true });
|
rmSync(`${testAssetDir}/temp`, { recursive: true, force: true });
|
||||||
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
|
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
|
||||||
@@ -524,13 +564,13 @@ export const utils = {
|
|||||||
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
||||||
},
|
},
|
||||||
|
|
||||||
isQueueEmpty: async (accessToken: string, queue: keyof AllJobStatusResponseDto) => {
|
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => {
|
||||||
const queues = await getAllJobsStatus({ headers: asBearerAuth(accessToken) });
|
const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) });
|
||||||
const jobCounts = queues[queue].jobCounts;
|
const jobCounts = queues[queue].jobCounts;
|
||||||
return !jobCounts.active && !jobCounts.waiting;
|
return !jobCounts.active && !jobCounts.waiting;
|
||||||
},
|
},
|
||||||
|
|
||||||
waitForQueueFinish: (accessToken: string, queue: keyof AllJobStatusResponseDto, ms?: number) => {
|
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => {
|
||||||
// eslint-disable-next-line no-async-promise-executor
|
// eslint-disable-next-line no-async-promise-executor
|
||||||
return new Promise<void>(async (resolve, reject) => {
|
return new Promise<void>(async (resolve, reject) => {
|
||||||
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000);
|
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ test.describe('Asset Viewer Navbar', () => {
|
|||||||
await page.goto(`/photos/${asset.id}`);
|
await page.goto(`/photos/${asset.id}`);
|
||||||
await page.waitForSelector('#immich-asset-viewer');
|
await page.waitForSelector('#immich-asset-viewer');
|
||||||
await page.keyboard.press('f');
|
await page.keyboard.press('f');
|
||||||
await expect(page.locator('#notification-list').getByTestId('message')).toHaveText('Added to favorites');
|
await expect(page.getByText('Added to favorites')).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,6 +51,6 @@ test.describe('Slideshow', () => {
|
|||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Exit Slideshow' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Exit Slideshow' })).toBeVisible();
|
||||||
await page.keyboard.press('f');
|
await page.keyboard.press('f');
|
||||||
await expect(page.locator('#notification-list')).not.toBeVisible();
|
await expect(page.getByText('Added to favorites')).not.toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
51
e2e/src/web/specs/maintenance.e2e-spec.ts
Normal file
51
e2e/src/web/specs/maintenance.e2e-spec.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { LoginResponseDto } from '@immich/sdk';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { utils } from 'src/utils';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.describe('Maintenance', () => {
|
||||||
|
let admin: LoginResponseDto;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
utils.initSdk();
|
||||||
|
await utils.resetDatabase();
|
||||||
|
admin = await utils.adminSetup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enter and exit maintenance mode', async ({ context, page }) => {
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
|
await page.goto('/admin/system-settings?isOpen=maintenance');
|
||||||
|
await page.getByRole('button', { name: 'Start maintenance mode' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||||
|
await page.waitForURL('**/admin/system-settings*', { timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
|
||||||
|
const setCookie = await utils.enterMaintenance(admin.accessToken);
|
||||||
|
const cookie = setCookie
|
||||||
|
?.map((cookie) => cookie.split(';')[0].split('='))
|
||||||
|
?.find(([name]) => name === 'immich_maintenance_token');
|
||||||
|
|
||||||
|
expect(cookie).toBeTruthy();
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForURL('**/maintenance?**', {
|
||||||
|
timeout: 1000,
|
||||||
|
});
|
||||||
|
}).toPass({ timeout: 10_000 });
|
||||||
|
|
||||||
|
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto(`/maintenance?${new URLSearchParams({ token: cookie![1] })}`);
|
||||||
|
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||||
|
await page.waitForURL('**/auth/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
864
e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts
Normal file
864
e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts
Normal file
@@ -0,0 +1,864 @@
|
|||||||
|
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/generators/timeline';
|
||||||
|
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
|
||||||
|
import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
|
||||||
|
import { utils } from 'src/utils';
|
||||||
|
import {
|
||||||
|
assetViewerUtils,
|
||||||
|
cancelAllPollers,
|
||||||
|
padYearMonth,
|
||||||
|
pageUtils,
|
||||||
|
poll,
|
||||||
|
thumbnailUtils,
|
||||||
|
timelineUtils,
|
||||||
|
} from 'src/web/specs/timeline/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('-');
|
||||||
|
yearMonths.push(`${year}-${Number(month)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
await setupBaseMockApiRoutes(context, adminUserId);
|
||||||
|
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
cancelAllPollers();
|
||||||
|
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.h-full.w-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('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('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.expectSelectedReadonly(page, asset.id);
|
||||||
|
});
|
||||||
|
test('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.expectSelectedReadonly(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('Done').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.getByText('Trash', { exact: true }).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', { exact: true }).click();
|
||||||
|
await expect(restoreRequest).resolves.toEqual({
|
||||||
|
ids: [assetToTrash.id],
|
||||||
|
});
|
||||||
|
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
|
||||||
|
await page.getByText('Photos', { exact: true }).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('#asset-selection-app-bar').getByLabel('Close').click();
|
||||||
|
await page.getByText('Trash', { exact: true }).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', { exact: true }).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.getByText('Photos', { exact: true }).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('#asset-selection-app-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.getByText('Photos', { exact: true }).click();
|
||||||
|
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||||
|
});
|
||||||
|
test('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('#asset-selection-app-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;
|
||||||
|
};
|
||||||
238
e2e/src/web/specs/timeline/utils.ts
Normal file
238
e2e/src/web/specs/timeline/utils.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { BrowserContext, expect, Page } from '@playwright/test';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { TimelineAssetConfig } from 'src/generators/timeline';
|
||||||
|
|
||||||
|
export const sleep = (ms: number) => {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const padYearMonth = (yearMonth: string) => {
|
||||||
|
const [year, month] = yearMonth.split('-');
|
||||||
|
return `${year}-${month.padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function throttlePage(context: BrowserContext, page: Page) {
|
||||||
|
const session = await context.newCDPSession(page);
|
||||||
|
await session.send('Network.emulateNetworkConditions', {
|
||||||
|
offline: false,
|
||||||
|
downloadThroughput: (1.5 * 1024 * 1024) / 8,
|
||||||
|
uploadThroughput: (750 * 1024) / 8,
|
||||||
|
latency: 40,
|
||||||
|
connectionType: 'cellular3g',
|
||||||
|
});
|
||||||
|
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let activePollsAbortController = new AbortController();
|
||||||
|
|
||||||
|
export const cancelAllPollers = () => {
|
||||||
|
activePollsAbortController.abort();
|
||||||
|
activePollsAbortController = new AbortController();
|
||||||
|
};
|
||||||
|
|
||||||
|
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 signal = activePollsAbortController.signal;
|
||||||
|
|
||||||
|
const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
|
||||||
|
while (!terminate(result) && Date.now() < timeout) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
result = await query();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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`);
|
||||||
|
},
|
||||||
|
selectedAsset(page: Page) {
|
||||||
|
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
|
||||||
|
},
|
||||||
|
async clickAssetId(page: Page, assetId: string) {
|
||||||
|
await thumbnailUtils.withAssetId(page, assetId).click();
|
||||||
|
},
|
||||||
|
async queryThumbnailInViewport(page: Page, collector: (assetId: string) => boolean) {
|
||||||
|
const assetIds: string[] = [];
|
||||||
|
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 expectSelectedReadonly(page: Page, assetId: string) {
|
||||||
|
// todo - need a data attribute for selected
|
||||||
|
await expect(
|
||||||
|
page.locator(
|
||||||
|
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
|
||||||
|
),
|
||||||
|
).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)).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}"]`)
|
||||||
|
.or(page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`))
|
||||||
|
.waitFor();
|
||||||
|
},
|
||||||
|
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||||
|
const activeElement = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
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) {
|
||||||
|
await page.getByTitle(day).hover();
|
||||||
|
await page.locator('[data-group] .w-8').click();
|
||||||
|
},
|
||||||
|
async pauseTestDebug() {
|
||||||
|
console.log('NOTE: pausing test indefinately for debug');
|
||||||
|
await new Promise(() => void 0);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -52,14 +52,18 @@ test.describe('User Administration', () => {
|
|||||||
|
|
||||||
await page.goto(`/admin/users/${user.userId}`);
|
await page.goto(`/admin/users/${user.userId}`);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Edit user' }).click();
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||||
await page.getByText('Admin User').click();
|
await page.getByLabel('Admin User').click();
|
||||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||||
|
|
||||||
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
await expect
|
||||||
expect(updated.isAdmin).toBe(true);
|
.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 }) => {
|
test('revoke admin access', async ({ context, page }) => {
|
||||||
@@ -77,13 +81,17 @@ test.describe('User Administration', () => {
|
|||||||
|
|
||||||
await page.goto(`/admin/users/${user.userId}`);
|
await page.goto(`/admin/users/${user.userId}`);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Edit user' }).click();
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||||
await page.getByText('Admin User').click();
|
await page.getByLabel('Admin User').click();
|
||||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||||
|
|
||||||
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
await expect
|
||||||
expect(updated.isAdmin).toBe(false);
|
.poll(async () => {
|
||||||
|
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||||
|
return userAdmin.isAdmin;
|
||||||
|
})
|
||||||
|
.toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Submodule e2e/test-assets updated: 37f60ea537...163c251744
@@ -9,7 +9,7 @@
|
|||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"target": "es2022",
|
"target": "es2023",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
"add_birthday": "Voeg 'n verjaarsdag by",
|
"add_birthday": "Voeg 'n verjaarsdag by",
|
||||||
"add_endpoint": "Voeg Koppelvlakpunt by",
|
"add_endpoint": "Voeg Koppelvlakpunt by",
|
||||||
"add_exclusion_pattern": "Voeg uitsgluitingspatrone by",
|
"add_exclusion_pattern": "Voeg uitsgluitingspatrone by",
|
||||||
"add_import_path": "Voeg invoerpad by",
|
|
||||||
"add_location": "Voeg ligging by",
|
"add_location": "Voeg ligging by",
|
||||||
"add_more_users": "Voeg meer gebruikers by",
|
"add_more_users": "Voeg meer gebruikers by",
|
||||||
"add_partner": "Voeg vennoot by",
|
"add_partner": "Voeg vennoot by",
|
||||||
@@ -101,7 +100,6 @@
|
|||||||
"job_status": "Werkstatus",
|
"job_status": "Werkstatus",
|
||||||
"library_created": "Biblioteek geskep: {library}",
|
"library_created": "Biblioteek geskep: {library}",
|
||||||
"library_deleted": "Biblioteek verwyder",
|
"library_deleted": "Biblioteek verwyder",
|
||||||
"library_import_path_description": "Spesifiseer 'n leer om in te neem. Hierdie leer, en al die sub leers, gaan deursoek word vir prente en videos.",
|
|
||||||
"library_scanning": "Periodieke Soek",
|
"library_scanning": "Periodieke Soek",
|
||||||
"library_scanning_description": "Stel periodieke deursoek van biblioteek in",
|
"library_scanning_description": "Stel periodieke deursoek van biblioteek in",
|
||||||
"library_scanning_enable_description": "Aktiveer periodieke biblioteekskandering",
|
"library_scanning_enable_description": "Aktiveer periodieke biblioteekskandering",
|
||||||
@@ -170,7 +168,6 @@
|
|||||||
"duplicates": "Duplikate",
|
"duplicates": "Duplikate",
|
||||||
"duration": "Duur",
|
"duration": "Duur",
|
||||||
"edit": "Wysig",
|
"edit": "Wysig",
|
||||||
"edited": "Gewysigd",
|
|
||||||
"search_by_description": "Soek by beskrywing",
|
"search_by_description": "Soek by beskrywing",
|
||||||
"search_by_description_example": "Stapdag in Sapa",
|
"search_by_description_example": "Stapdag in Sapa",
|
||||||
"version": "Weergawe",
|
"version": "Weergawe",
|
||||||
|
|||||||
84
i18n/ar.json
84
i18n/ar.json
@@ -17,7 +17,6 @@
|
|||||||
"add_birthday": "أضف تاريخ الميلاد",
|
"add_birthday": "أضف تاريخ الميلاد",
|
||||||
"add_endpoint": "اضف نقطة نهاية",
|
"add_endpoint": "اضف نقطة نهاية",
|
||||||
"add_exclusion_pattern": "إضافة نمط إستثناء",
|
"add_exclusion_pattern": "إضافة نمط إستثناء",
|
||||||
"add_import_path": "إضافة مسار الإستيراد",
|
|
||||||
"add_location": "إضافة موقع",
|
"add_location": "إضافة موقع",
|
||||||
"add_more_users": "إضافة مستخدمين آخرين",
|
"add_more_users": "إضافة مستخدمين آخرين",
|
||||||
"add_partner": "أضف شريكًا",
|
"add_partner": "أضف شريكًا",
|
||||||
@@ -112,19 +111,18 @@
|
|||||||
"jobs_failed": "{jobCount, plural, other {# فشلت}}",
|
"jobs_failed": "{jobCount, plural, other {# فشلت}}",
|
||||||
"library_created": "تم إنشاء المكتبة: {library}",
|
"library_created": "تم إنشاء المكتبة: {library}",
|
||||||
"library_deleted": "تم حذف المكتبة",
|
"library_deleted": "تم حذف المكتبة",
|
||||||
"library_import_path_description": "حدد مجلدًا للاستيراد. سيتم فحص هذا المجلد، بما في ذلك المجلدات الفرعية، بحثًا عن الصور ومقاطع الفيديو.",
|
|
||||||
"library_scanning": "المسح الدوري",
|
"library_scanning": "المسح الدوري",
|
||||||
"library_scanning_description": "إعداد مسح المكتبة الدوري",
|
"library_scanning_description": "إعداد مسح المكتبة الدوري",
|
||||||
"library_scanning_enable_description": "تفعيل مسح المكتبة الدوري",
|
"library_scanning_enable_description": "تفعيل مسح المكتبة الدوري",
|
||||||
"library_settings": "المكتبة الخارجية",
|
"library_settings": "المكتبة الخارجية",
|
||||||
"library_settings_description": "إدارة إعدادات المكتبة الخارجية",
|
"library_settings_description": "إدارة إعدادات المكتبة الخارجية",
|
||||||
"library_tasks_description": "مسح المكتبات الخارجية للعثور على الأصول الجديدة و/أو المتغيرة",
|
"library_tasks_description": "مسح المكتبات الخارجية للعثور على الأصول الجديدة و/أو المتغيرة",
|
||||||
"library_watching_enable_description": "راقب المكتبات الخارجية لتغييرات الملفات",
|
"library_watching_enable_description": "مراقبة المكتبات الخارجية لاكتشاف تغييرات الملفات",
|
||||||
"library_watching_settings": "مراقبة المكتبات (تجريبي)",
|
"library_watching_settings": "مراقبة المكتبات [تجريبي]",
|
||||||
"library_watching_settings_description": "راقب تلقائيًا التغييرات في الملفات",
|
"library_watching_settings_description": "راقب تلقائيًا التغييرات في الملفات",
|
||||||
"logging_enable_description": "تفعيل تسجيل الأحداث",
|
"logging_enable_description": "تفعيل تسجيل الأحداث",
|
||||||
"logging_level_description": "عند التفعيل، أي مستوى تسجيل سيستخدم.",
|
"logging_level_description": "عند التفعيل، أي مستوى تسجيل سيستخدم.",
|
||||||
"logging_settings": "تسجيل الاحداث",
|
"logging_settings": "السجلات",
|
||||||
"machine_learning_availability_checks": "تحقق من التوفر",
|
"machine_learning_availability_checks": "تحقق من التوفر",
|
||||||
"machine_learning_availability_checks_description": "تحديد خوادم التعلم الآلي المتاحة تلقائيًا وإعطاءها الأولوية",
|
"machine_learning_availability_checks_description": "تحديد خوادم التعلم الآلي المتاحة تلقائيًا وإعطاءها الأولوية",
|
||||||
"machine_learning_availability_checks_enabled": "تفعيل عمليات فحص التوفر",
|
"machine_learning_availability_checks_enabled": "تفعيل عمليات فحص التوفر",
|
||||||
@@ -154,6 +152,18 @@
|
|||||||
"machine_learning_min_detection_score_description": "الحد الأدنى لنقطة الثقة لاكتشاف الوجه، تتراوح من 0 إلى 1. القيم الأقل ستكشف عن المزيد من الوجوه ولكن قد تؤدي إلى نتائج إيجابية خاطئة.",
|
"machine_learning_min_detection_score_description": "الحد الأدنى لنقطة الثقة لاكتشاف الوجه، تتراوح من 0 إلى 1. القيم الأقل ستكشف عن المزيد من الوجوه ولكن قد تؤدي إلى نتائج إيجابية خاطئة.",
|
||||||
"machine_learning_min_recognized_faces": "الحد الأدنى لعدد الوجوه المتعرف عليها",
|
"machine_learning_min_recognized_faces": "الحد الأدنى لعدد الوجوه المتعرف عليها",
|
||||||
"machine_learning_min_recognized_faces_description": "الحد الأدنى لعدد الوجوه المتعرف عليها لإنشاء شخص. زيادة هذا الرقم يجعل التعرف على الوجوه أكثر دقة على حساب زيادة احتمال عدم تعيين الوجه لشخص ما.",
|
"machine_learning_min_recognized_faces_description": "الحد الأدنى لعدد الوجوه المتعرف عليها لإنشاء شخص. زيادة هذا الرقم يجعل التعرف على الوجوه أكثر دقة على حساب زيادة احتمال عدم تعيين الوجه لشخص ما.",
|
||||||
|
"machine_learning_ocr": "التعرف البصري على الحروف",
|
||||||
|
"machine_learning_ocr_description": "استخدم التعلم الآلي للتعرف على النصوص في الصور",
|
||||||
|
"machine_learning_ocr_enabled": "تفعيل التعرف البصري على الحروف",
|
||||||
|
"machine_learning_ocr_enabled_description": "في حال تعطيل هذه الميزة، لن تخضع الصور لعملية التعرف على النصوص.",
|
||||||
|
"machine_learning_ocr_max_resolution": "أقصى دقة",
|
||||||
|
"machine_learning_ocr_max_resolution_description": "سيتم تغيير حجم المعاينات التي تتجاوز هذه الدقة مع الحفاظ على نسبة العرض إلى الارتفاع. القيم الأعلى توفر دقة أكبر، ولكنها تستغرق وقتًا أطول للمعالجة وتستهلك المزيد من الذاكرة.",
|
||||||
|
"machine_learning_ocr_min_detection_score": "الحد الأدنى لدرجة الكشف",
|
||||||
|
"machine_learning_ocr_min_detection_score_description": "لحد الأدنى لدرجة الثقة المطلوبة لاكتشاف النص، وتتراوح قيمتها من 0 إلى 1. ستؤدي القيم الأقل إلى اكتشاف المزيد من النصوص ولكنها قد تؤدي إلى نتائج إيجابية خاطئة.",
|
||||||
|
"machine_learning_ocr_min_recognition_score": "الحد الأدنى لدرجة التعرّف",
|
||||||
|
"machine_learning_ocr_min_score_recognition_description": "الحد الأدنى لدرجة الثقة المطلوبة للنصوص المكتشفة ليتم التعرف عليها، وتتراوح من 0 إلى 1. ستؤدي القيم الأقل إلى التعرف على المزيد من النصوص ولكنها قد تؤدي إلى نتائج إيجابية خاطئة.",
|
||||||
|
"machine_learning_ocr_model": "نموذج التعرف البصري على الحروف",
|
||||||
|
"machine_learning_ocr_model_description": "تتميز نماذج الخوادم بدقة أكبر من نماذج الأجهزة المحمولة، ولكنها تستغرق وقتًا أطول في المعالجة وتستهلك ذاكرة أكبر.",
|
||||||
"machine_learning_settings": "إعدادات التعلم الآلي",
|
"machine_learning_settings": "إعدادات التعلم الآلي",
|
||||||
"machine_learning_settings_description": "إدارة ميزات وإعدادات التعلم الآلي",
|
"machine_learning_settings_description": "إدارة ميزات وإعدادات التعلم الآلي",
|
||||||
"machine_learning_smart_search": "البحث الذكي",
|
"machine_learning_smart_search": "البحث الذكي",
|
||||||
@@ -206,11 +216,13 @@
|
|||||||
"note_cannot_be_changed_later": "ملاحظة: لا يمكن تغيير هذا لاحقًا!",
|
"note_cannot_be_changed_later": "ملاحظة: لا يمكن تغيير هذا لاحقًا!",
|
||||||
"notification_email_from_address": "عنوان المرسل",
|
"notification_email_from_address": "عنوان المرسل",
|
||||||
"notification_email_from_address_description": "عنوان البريد الإلكتروني للمرسل، على سبيل المثال: \"Immich Photo Server noreply@example.com\". تاكد من استخدام عنوان بريد الكتروني يسمح لك بارسال البريد الالكتروني منه.",
|
"notification_email_from_address_description": "عنوان البريد الإلكتروني للمرسل، على سبيل المثال: \"Immich Photo Server noreply@example.com\". تاكد من استخدام عنوان بريد الكتروني يسمح لك بارسال البريد الالكتروني منه.",
|
||||||
"notification_email_host_description": "مضيف خادم البريد الإلكتروني (مثلًا: smtp.immich.app)",
|
"notification_email_host_description": "عنوان خادم البريد الإلكتروني (مثل smtp.immich.app)",
|
||||||
"notification_email_ignore_certificate_errors": "تجاهل أخطاء الشهادة",
|
"notification_email_ignore_certificate_errors": "تجاهل أخطاء الشهادة",
|
||||||
"notification_email_ignore_certificate_errors_description": "تجاهل أخطاء التحقق من صحة شهادة TLS (غير مستحسن)",
|
"notification_email_ignore_certificate_errors_description": "تجاهل أخطاء التحقق من صحة شهادة TLS (غير مستحسن)",
|
||||||
"notification_email_password_description": "كلمة المرور المستخدمة للمصادقة مع خادم البريد الإلكتروني",
|
"notification_email_password_description": "كلمة المرور المستخدمة للمصادقة مع خادم البريد الإلكتروني",
|
||||||
"notification_email_port_description": "منفذ خادم البريد الإلكتروني (مثلاً 25، 465، أو 587)",
|
"notification_email_port_description": "منفذ خادم البريد الإلكتروني (مثلاً 25، 465، أو 587)",
|
||||||
|
"notification_email_secure": "بروتوكول نقل البريد البسيط الآمن SMTPS",
|
||||||
|
"notification_email_secure_description": "استخدم بروتوكول SMTPS (بروتوكول SMTP عبر TLS)",
|
||||||
"notification_email_sent_test_email_button": "إرسال بريد إلكتروني تجريبي وحفظ التعديلات",
|
"notification_email_sent_test_email_button": "إرسال بريد إلكتروني تجريبي وحفظ التعديلات",
|
||||||
"notification_email_setting_description": "إعدادات إرسال إشعارات البريد الإلكتروني",
|
"notification_email_setting_description": "إعدادات إرسال إشعارات البريد الإلكتروني",
|
||||||
"notification_email_test_email": "إرسال بريد تجريبي",
|
"notification_email_test_email": "إرسال بريد تجريبي",
|
||||||
@@ -243,6 +255,7 @@
|
|||||||
"oauth_storage_quota_default_description": "الحصة بالجيجابايت التي سيتم استخدامها عندما لا يتم توفير مطالبة.",
|
"oauth_storage_quota_default_description": "الحصة بالجيجابايت التي سيتم استخدامها عندما لا يتم توفير مطالبة.",
|
||||||
"oauth_timeout": "نفاذ وقت الطلب",
|
"oauth_timeout": "نفاذ وقت الطلب",
|
||||||
"oauth_timeout_description": "نفاذ وقت الطلب بالميلي ثانية",
|
"oauth_timeout_description": "نفاذ وقت الطلب بالميلي ثانية",
|
||||||
|
"ocr_job_description": "استخدم التعلم الآلي للتعرف على النصوص في الصور",
|
||||||
"password_enable_description": "تسجيل الدخول باستخدام البريد الكتروني وكلمة المرور",
|
"password_enable_description": "تسجيل الدخول باستخدام البريد الكتروني وكلمة المرور",
|
||||||
"password_settings": "تسجيل الدخول بكلمة المرور",
|
"password_settings": "تسجيل الدخول بكلمة المرور",
|
||||||
"password_settings_description": "إدارة تسجيل الدخول بكلمة المرور",
|
"password_settings_description": "إدارة تسجيل الدخول بكلمة المرور",
|
||||||
@@ -333,7 +346,7 @@
|
|||||||
"transcoding_max_b_frames": "أقصى عدد من الإطارات B",
|
"transcoding_max_b_frames": "أقصى عدد من الإطارات B",
|
||||||
"transcoding_max_b_frames_description": "القيم الأعلى تعزز كفاءة الضغط، ولكنها تبطئ عملية الترميز. قد لا تكون متوافقة مع التسريع العتادي على الأجهزة القديمة. قيمة 0 تعطل إطارات B، بينما تضبط القيمة -1 هذا القيمة تلقائيًا.",
|
"transcoding_max_b_frames_description": "القيم الأعلى تعزز كفاءة الضغط، ولكنها تبطئ عملية الترميز. قد لا تكون متوافقة مع التسريع العتادي على الأجهزة القديمة. قيمة 0 تعطل إطارات B، بينما تضبط القيمة -1 هذا القيمة تلقائيًا.",
|
||||||
"transcoding_max_bitrate": "الحد الأقصى لمعدل البت",
|
"transcoding_max_bitrate": "الحد الأقصى لمعدل البت",
|
||||||
"transcoding_max_bitrate_description": "يمكن أن يؤدي تعيين الحد الأقصى لمعدل البت إلى جعل أحجام الملفات أكثر قابلية للتنبؤ بها بتكلفة بسيطة بالنسبة للجودة. عند دقة 720 بكسل، تكون القيم النموذجية 2600 كيلو بت لـ VP9 أو HEVC، أو 4500 كيلو بت لـ H.264. معطل إذا تم ضبطه على 0.",
|
"transcoding_max_bitrate_description": "يتيح تعيين معدل البت الأقصى التحكم في حجم الملف مع تأثير طفيف على الجودة.عند دقة 720p، القيم المقترحة هي 2600 كيلوبت/ثانية لـ VP9 أو HEVC، و4500 كيلوبت/ثانية لـ H.264.يتم تعطيل الإعداد عند القيمة 0. إذا لم تُحدَّد وحدة، يُفترض k (كيلوبت/ثانية)؛ لذا فإن 5000، 5000k، و5M متكافئة.",
|
||||||
"transcoding_max_keyframe_interval": "الحد الأقصى للفاصل الزمني للإطار الرئيسي",
|
"transcoding_max_keyframe_interval": "الحد الأقصى للفاصل الزمني للإطار الرئيسي",
|
||||||
"transcoding_max_keyframe_interval_description": "يضبط الحد الأقصى لمسافة الإطار بين الإطارات الرئيسية. تؤدي القيم المنخفضة إلى زيادة سوء كفاءة الضغط، ولكنها تعمل على تحسين أوقات البحث وقد تعمل على تحسين الجودة في المشاهد ذات الحركة السريعة. 0 يضبط هذه القيمة تلقائيًا.",
|
"transcoding_max_keyframe_interval_description": "يضبط الحد الأقصى لمسافة الإطار بين الإطارات الرئيسية. تؤدي القيم المنخفضة إلى زيادة سوء كفاءة الضغط، ولكنها تعمل على تحسين أوقات البحث وقد تعمل على تحسين الجودة في المشاهد ذات الحركة السريعة. 0 يضبط هذه القيمة تلقائيًا.",
|
||||||
"transcoding_optimal_description": "مقاطع الفيديو ذات الدقة الأعلى من الدقة المستهدفة أو بتنسيق غير مقبول",
|
"transcoding_optimal_description": "مقاطع الفيديو ذات الدقة الأعلى من الدقة المستهدفة أو بتنسيق غير مقبول",
|
||||||
@@ -351,7 +364,7 @@
|
|||||||
"transcoding_target_resolution": "القرار المستهدف",
|
"transcoding_target_resolution": "القرار المستهدف",
|
||||||
"transcoding_target_resolution_description": "يمكن أن تحافظ الدقة الأعلى على المزيد من التفاصيل ولكنها تستغرق وقتًا أطول للتشفير، ولها أحجام ملفات أكبر، ويمكن أن تقلل من استجابة التطبيق.",
|
"transcoding_target_resolution_description": "يمكن أن تحافظ الدقة الأعلى على المزيد من التفاصيل ولكنها تستغرق وقتًا أطول للتشفير، ولها أحجام ملفات أكبر، ويمكن أن تقلل من استجابة التطبيق.",
|
||||||
"transcoding_temporal_aq": "التكميم التكيفي الزمني",
|
"transcoding_temporal_aq": "التكميم التكيفي الزمني",
|
||||||
"transcoding_temporal_aq_description": "ينطبق فقط على NVENC. يزيد من جودة المشاهد عالية التفاصيل ومنخفضة الحركة. قد لا يكون متوافقًا مع الأجهزة القديمة.",
|
"transcoding_temporal_aq_description": "ينطبق فقط على NVENC. تعمل \"الكمّية التكيفية الزمنية\" على تحسين جودة المشاهد ذات التفاصيل الدقيقة والحركة البطيئة. قد لا يكون هذا الخيار متوافقًا مع الأجهزة القديمة.",
|
||||||
"transcoding_threads": "الخيوط",
|
"transcoding_threads": "الخيوط",
|
||||||
"transcoding_threads_description": "تؤدي القيم الأعلى إلى تشفير أسرع، ولكنها تترك مساحة أقل للخادم لمعالجة المهام الأخرى أثناء النشاط. يجب ألا تزيد هذه القيمة عن عدد مراكز وحدة المعالجة المركزية. يزيد من الإستغلال إذا تم ضبطه على 0.",
|
"transcoding_threads_description": "تؤدي القيم الأعلى إلى تشفير أسرع، ولكنها تترك مساحة أقل للخادم لمعالجة المهام الأخرى أثناء النشاط. يجب ألا تزيد هذه القيمة عن عدد مراكز وحدة المعالجة المركزية. يزيد من الإستغلال إذا تم ضبطه على 0.",
|
||||||
"transcoding_tone_mapping": "رسم الخرائط النغمية",
|
"transcoding_tone_mapping": "رسم الخرائط النغمية",
|
||||||
@@ -402,11 +415,11 @@
|
|||||||
"advanced_settings_prefer_remote_subtitle": "تكون بعض الأجهزة بطيئة للغاية في تحميل الصور المصغرة من الأصول المحلية. قم بتفعيل هذا الخيار لتحميل الصور البعيدة بدلاً من ذلك.",
|
"advanced_settings_prefer_remote_subtitle": "تكون بعض الأجهزة بطيئة للغاية في تحميل الصور المصغرة من الأصول المحلية. قم بتفعيل هذا الخيار لتحميل الصور البعيدة بدلاً من ذلك.",
|
||||||
"advanced_settings_prefer_remote_title": "تفضل الصور البعيدة",
|
"advanced_settings_prefer_remote_title": "تفضل الصور البعيدة",
|
||||||
"advanced_settings_proxy_headers_subtitle": "عرف عناوين الوكيل التي يستخدمها Immich لارسال كل طلب شبكي",
|
"advanced_settings_proxy_headers_subtitle": "عرف عناوين الوكيل التي يستخدمها Immich لارسال كل طلب شبكي",
|
||||||
"advanced_settings_proxy_headers_title": "عناوين الوكيل",
|
"advanced_settings_proxy_headers_title": "عناوين الوكيل المخصصة [تجريبية]",
|
||||||
"advanced_settings_readonly_mode_subtitle": "تتيح هذه الميزة وضع العرض فقط، حيث يمكن للمستخدم معاينة الصور فقط، بينما يتم تعطيل جميع الخيارات الأخرى مثل تحديد عدة صور، أو مشاركتها، أو بثها، أو حذفها. يمكن تفعيل/تعطيل وضع العرض فقط من خلال صورة المستخدم في الشاشة الرئيسية",
|
"advanced_settings_readonly_mode_subtitle": "تتيح هذه الميزة وضع العرض فقط، حيث يمكن للمستخدم معاينة الصور فقط، بينما يتم تعطيل جميع الخيارات الأخرى مثل تحديد عدة صور، أو مشاركتها، أو بثها، أو حذفها. يمكن تفعيل/تعطيل وضع العرض فقط من خلال صورة المستخدم في الشاشة الرئيسية",
|
||||||
"advanced_settings_readonly_mode_title": "وضع القراءة فقط",
|
"advanced_settings_readonly_mode_title": "وضع القراءة فقط",
|
||||||
"advanced_settings_self_signed_ssl_subtitle": "تخطي التحقق من شهادة SSL لخادم النقطة النهائي. مكلوب للشهادات الموقعة ذاتيا.",
|
"advanced_settings_self_signed_ssl_subtitle": "تخطي التحقق من شهادة SSL لخادم النقطة النهائي. مكلوب للشهادات الموقعة ذاتيا.",
|
||||||
"advanced_settings_self_signed_ssl_title": "السماح بشهادات SSL الموقعة ذاتيًا",
|
"advanced_settings_self_signed_ssl_title": "السماح بشهادات SSL الموقعة ذاتيًا [تجريبية]",
|
||||||
"advanced_settings_sync_remote_deletions_subtitle": "حذف او استعادة تلقائي للاصول على هذا الجهاز عند تنفيذ العملية على الويب",
|
"advanced_settings_sync_remote_deletions_subtitle": "حذف او استعادة تلقائي للاصول على هذا الجهاز عند تنفيذ العملية على الويب",
|
||||||
"advanced_settings_sync_remote_deletions_title": "مزامنة عمليات الحذف عن بعد [تجريبي]",
|
"advanced_settings_sync_remote_deletions_title": "مزامنة عمليات الحذف عن بعد [تجريبي]",
|
||||||
"advanced_settings_tile_subtitle": "إعدادات المستخدم المتقدمة",
|
"advanced_settings_tile_subtitle": "إعدادات المستخدم المتقدمة",
|
||||||
@@ -466,10 +479,14 @@
|
|||||||
"api_key_description": "سيتم عرض هذه القيمة مرة واحدة فقط. يرجى التأكد من نسخها قبل إغلاق النافذة.",
|
"api_key_description": "سيتم عرض هذه القيمة مرة واحدة فقط. يرجى التأكد من نسخها قبل إغلاق النافذة.",
|
||||||
"api_key_empty": "يجب ألا يكون اسم مفتاح API فارغًا",
|
"api_key_empty": "يجب ألا يكون اسم مفتاح API فارغًا",
|
||||||
"api_keys": "مفاتيح API",
|
"api_keys": "مفاتيح API",
|
||||||
|
"app_architecture_variant": "متغير (الهندسة المعمارية)",
|
||||||
"app_bar_signout_dialog_content": "هل أنت متأكد أنك تريد تسجيل الخروج؟",
|
"app_bar_signout_dialog_content": "هل أنت متأكد أنك تريد تسجيل الخروج؟",
|
||||||
"app_bar_signout_dialog_ok": "نعم",
|
"app_bar_signout_dialog_ok": "نعم",
|
||||||
"app_bar_signout_dialog_title": "خروج",
|
"app_bar_signout_dialog_title": "خروج",
|
||||||
|
"app_download_links": "روابط تحميل التطبيق",
|
||||||
"app_settings": "إعدادات التطبيق",
|
"app_settings": "إعدادات التطبيق",
|
||||||
|
"app_stores": "متاجر التطبيقات",
|
||||||
|
"app_update_available": "تحديث التطبيق متاح",
|
||||||
"appears_in": "يظهر في",
|
"appears_in": "يظهر في",
|
||||||
"apply_count": "تطبيق ({count, number})",
|
"apply_count": "تطبيق ({count, number})",
|
||||||
"archive": "الأرشيف",
|
"archive": "الأرشيف",
|
||||||
@@ -553,6 +570,7 @@
|
|||||||
"backup_albums_sync": "مزامنة ألبومات النسخ الاحتياطي",
|
"backup_albums_sync": "مزامنة ألبومات النسخ الاحتياطي",
|
||||||
"backup_all": "الجميع",
|
"backup_all": "الجميع",
|
||||||
"backup_background_service_backup_failed_message": "فشل في النسخ الاحتياطي للأصول. جارٍ إعادة المحاولة…",
|
"backup_background_service_backup_failed_message": "فشل في النسخ الاحتياطي للأصول. جارٍ إعادة المحاولة…",
|
||||||
|
"backup_background_service_complete_notification": "تم الانتهاء من النسخ الاحتياطي للأصول",
|
||||||
"backup_background_service_connection_failed_message": "فشل في الاتصال بالخادم. جارٍ إعادة المحاولة…",
|
"backup_background_service_connection_failed_message": "فشل في الاتصال بالخادم. جارٍ إعادة المحاولة…",
|
||||||
"backup_background_service_current_upload_notification": "تحميل {filename}",
|
"backup_background_service_current_upload_notification": "تحميل {filename}",
|
||||||
"backup_background_service_default_notification": "التحقق من الأصول الجديدة…",
|
"backup_background_service_default_notification": "التحقق من الأصول الجديدة…",
|
||||||
@@ -662,6 +680,8 @@
|
|||||||
"change_password_description": "هذه إما هي المرة الأولى التي تقوم فيها بتسجيل الدخول إلى النظام أو أنه تم تقديم طلب لتغيير كلمة المرور الخاصة بك. الرجاء إدخال كلمة المرور الجديدة أدناه.",
|
"change_password_description": "هذه إما هي المرة الأولى التي تقوم فيها بتسجيل الدخول إلى النظام أو أنه تم تقديم طلب لتغيير كلمة المرور الخاصة بك. الرجاء إدخال كلمة المرور الجديدة أدناه.",
|
||||||
"change_password_form_confirm_password": "تأكيد كلمة المرور",
|
"change_password_form_confirm_password": "تأكيد كلمة المرور",
|
||||||
"change_password_form_description": "مرحبًا {name}،\n\nاما ان تكون هذه هي المرة الأولى التي تقوم فيها بالتسجيل في النظام أو تم تقديم طلب لتغيير كلمة المرور الخاصة بك. الرجاء إدخال كلمة المرور الجديدة أدناه.",
|
"change_password_form_description": "مرحبًا {name}،\n\nاما ان تكون هذه هي المرة الأولى التي تقوم فيها بالتسجيل في النظام أو تم تقديم طلب لتغيير كلمة المرور الخاصة بك. الرجاء إدخال كلمة المرور الجديدة أدناه.",
|
||||||
|
"change_password_form_log_out": "تسجيل الخروج من جميع الأجهزة الأخرى",
|
||||||
|
"change_password_form_log_out_description": "يُنصح بتسجيل الخروج من جميع الأجهزة الأخرى",
|
||||||
"change_password_form_new_password": "كلمة المرور الجديدة",
|
"change_password_form_new_password": "كلمة المرور الجديدة",
|
||||||
"change_password_form_password_mismatch": "كلمة المرور غير مطابقة",
|
"change_password_form_password_mismatch": "كلمة المرور غير مطابقة",
|
||||||
"change_password_form_reenter_new_password": "أعد إدخال كلمة مرور جديدة",
|
"change_password_form_reenter_new_password": "أعد إدخال كلمة مرور جديدة",
|
||||||
@@ -689,7 +709,7 @@
|
|||||||
"client_cert_invalid_msg": "ملف شهادة عميل غير صالحة او كلمة سر غير صحيحة",
|
"client_cert_invalid_msg": "ملف شهادة عميل غير صالحة او كلمة سر غير صحيحة",
|
||||||
"client_cert_remove_msg": "تم ازالة شهادة العميل",
|
"client_cert_remove_msg": "تم ازالة شهادة العميل",
|
||||||
"client_cert_subtitle": "يدعم صيغ PKCS12 (.p12, .pfx)فقط. استيراد/ازالة الشهادات متاح فقط قبل تسجيل الدخول",
|
"client_cert_subtitle": "يدعم صيغ PKCS12 (.p12, .pfx)فقط. استيراد/ازالة الشهادات متاح فقط قبل تسجيل الدخول",
|
||||||
"client_cert_title": "شهادة مستخدم SSL",
|
"client_cert_title": "شهادة مستخدم SSL [تجريبية]",
|
||||||
"clockwise": "باتجاه عقارب الساعة",
|
"clockwise": "باتجاه عقارب الساعة",
|
||||||
"close": "إغلاق",
|
"close": "إغلاق",
|
||||||
"collapse": "طي",
|
"collapse": "طي",
|
||||||
@@ -701,7 +721,6 @@
|
|||||||
"comments_and_likes": "التعليقات والإعجابات",
|
"comments_and_likes": "التعليقات والإعجابات",
|
||||||
"comments_are_disabled": "التعليقات معطلة",
|
"comments_are_disabled": "التعليقات معطلة",
|
||||||
"common_create_new_album": "إنشاء ألبوم جديد",
|
"common_create_new_album": "إنشاء ألبوم جديد",
|
||||||
"common_server_error": "يرجى التحقق من اتصال الشبكة الخاص بك ، والتأكد من أن الجهاز قابل للوصول وإصدارات التطبيق/الجهاز متوافقة.",
|
|
||||||
"completed": "اكتمل",
|
"completed": "اكتمل",
|
||||||
"confirm": "تأكيد",
|
"confirm": "تأكيد",
|
||||||
"confirm_admin_password": "تأكيد كلمة مرور المسؤول",
|
"confirm_admin_password": "تأكيد كلمة مرور المسؤول",
|
||||||
@@ -740,6 +759,7 @@
|
|||||||
"create": "انشاء",
|
"create": "انشاء",
|
||||||
"create_album": "إنشاء ألبوم",
|
"create_album": "إنشاء ألبوم",
|
||||||
"create_album_page_untitled": "بدون اسم",
|
"create_album_page_untitled": "بدون اسم",
|
||||||
|
"create_api_key": "إنشاء مفتاح API",
|
||||||
"create_library": "إنشاء مكتبة",
|
"create_library": "إنشاء مكتبة",
|
||||||
"create_link": "إنشاء رابط",
|
"create_link": "إنشاء رابط",
|
||||||
"create_link_to_share": "إنشاء رابط للمشاركة",
|
"create_link_to_share": "إنشاء رابط للمشاركة",
|
||||||
@@ -769,6 +789,7 @@
|
|||||||
"daily_title_text_date_year": "E ، MMM DD ، yyyy",
|
"daily_title_text_date_year": "E ، MMM DD ، yyyy",
|
||||||
"dark": "معتم",
|
"dark": "معتم",
|
||||||
"dark_theme": "تبديل المظهر الداكن",
|
"dark_theme": "تبديل المظهر الداكن",
|
||||||
|
"date": "تاريخ",
|
||||||
"date_after": "التارخ بعد",
|
"date_after": "التارخ بعد",
|
||||||
"date_and_time": "التاريخ و الوقت",
|
"date_and_time": "التاريخ و الوقت",
|
||||||
"date_before": "التاريخ قبل",
|
"date_before": "التاريخ قبل",
|
||||||
@@ -871,8 +892,6 @@
|
|||||||
"edit_description_prompt": "الرجاء اختيار وصف جديد:",
|
"edit_description_prompt": "الرجاء اختيار وصف جديد:",
|
||||||
"edit_exclusion_pattern": "تعديل نمط الاستبعاد",
|
"edit_exclusion_pattern": "تعديل نمط الاستبعاد",
|
||||||
"edit_faces": "تعديل الوجوه",
|
"edit_faces": "تعديل الوجوه",
|
||||||
"edit_import_path": "تعديل مسار الاستيراد",
|
|
||||||
"edit_import_paths": "تعديل مسارات الاستيراد",
|
|
||||||
"edit_key": "تعديل المفتاح",
|
"edit_key": "تعديل المفتاح",
|
||||||
"edit_link": "تغيير الرابط",
|
"edit_link": "تغيير الرابط",
|
||||||
"edit_location": "تعديل الموقع",
|
"edit_location": "تعديل الموقع",
|
||||||
@@ -883,7 +902,6 @@
|
|||||||
"edit_tag": "تعديل العلامة",
|
"edit_tag": "تعديل العلامة",
|
||||||
"edit_title": "تعديل العنوان",
|
"edit_title": "تعديل العنوان",
|
||||||
"edit_user": "تعديل المستخدم",
|
"edit_user": "تعديل المستخدم",
|
||||||
"edited": "تم التعديل",
|
|
||||||
"editor": "محرر",
|
"editor": "محرر",
|
||||||
"editor_close_without_save_prompt": "لن يتم حفظ التغييرات",
|
"editor_close_without_save_prompt": "لن يتم حفظ التغييرات",
|
||||||
"editor_close_without_save_title": "إغلاق المحرر؟",
|
"editor_close_without_save_title": "إغلاق المحرر؟",
|
||||||
@@ -945,7 +963,6 @@
|
|||||||
"failed_to_stack_assets": "فشل في تكديس المحتويات",
|
"failed_to_stack_assets": "فشل في تكديس المحتويات",
|
||||||
"failed_to_unstack_assets": "فشل في فصل المحتويات",
|
"failed_to_unstack_assets": "فشل في فصل المحتويات",
|
||||||
"failed_to_update_notification_status": "فشل في تحديث حالة الإشعار",
|
"failed_to_update_notification_status": "فشل في تحديث حالة الإشعار",
|
||||||
"import_path_already_exists": "مسار الاستيراد هذا موجود مسبقًا.",
|
|
||||||
"incorrect_email_or_password": "بريد أو كلمة مرور غير صحيحة",
|
"incorrect_email_or_password": "بريد أو كلمة مرور غير صحيحة",
|
||||||
"paths_validation_failed": "فشل في التحقق من {paths, plural, one {# مسار} other {# مسارات}}",
|
"paths_validation_failed": "فشل في التحقق من {paths, plural, one {# مسار} other {# مسارات}}",
|
||||||
"profile_picture_transparent_pixels": "لا يمكن أن تحتوي صور الملف الشخصي على أجزاء/بكسلات شفافة. يرجى التكبير و/أو تحريك الصورة.",
|
"profile_picture_transparent_pixels": "لا يمكن أن تحتوي صور الملف الشخصي على أجزاء/بكسلات شفافة. يرجى التكبير و/أو تحريك الصورة.",
|
||||||
@@ -955,7 +972,6 @@
|
|||||||
"unable_to_add_assets_to_shared_link": "تعذر إضافة المحتويات إلى الرابط المشترك",
|
"unable_to_add_assets_to_shared_link": "تعذر إضافة المحتويات إلى الرابط المشترك",
|
||||||
"unable_to_add_comment": "تعذر إضافة التعليق",
|
"unable_to_add_comment": "تعذر إضافة التعليق",
|
||||||
"unable_to_add_exclusion_pattern": "تعذر إضافة نمط الإستبعاد",
|
"unable_to_add_exclusion_pattern": "تعذر إضافة نمط الإستبعاد",
|
||||||
"unable_to_add_import_path": "تعذر إضافة مسار الإستيراد",
|
|
||||||
"unable_to_add_partners": "تعذر إضافة الشركاء",
|
"unable_to_add_partners": "تعذر إضافة الشركاء",
|
||||||
"unable_to_add_remove_archive": "تعذر {archived, select, true {إزالة المحتوى من} other {إضافة المحتوى إلى}} الأرشيف",
|
"unable_to_add_remove_archive": "تعذر {archived, select, true {إزالة المحتوى من} other {إضافة المحتوى إلى}} الأرشيف",
|
||||||
"unable_to_add_remove_favorites": "تعذر {favorite, select, true {إضافة المحتوى إلى} other {إزالة المحتوى من}} المفضلة",
|
"unable_to_add_remove_favorites": "تعذر {favorite, select, true {إضافة المحتوى إلى} other {إزالة المحتوى من}} المفضلة",
|
||||||
@@ -978,12 +994,10 @@
|
|||||||
"unable_to_delete_asset": "غير قادر على حذف المحتوى",
|
"unable_to_delete_asset": "غير قادر على حذف المحتوى",
|
||||||
"unable_to_delete_assets": "حدث خطأ أثناء حذف المحتويات",
|
"unable_to_delete_assets": "حدث خطأ أثناء حذف المحتويات",
|
||||||
"unable_to_delete_exclusion_pattern": "غير قادر على حذف نمط الاستبعاد",
|
"unable_to_delete_exclusion_pattern": "غير قادر على حذف نمط الاستبعاد",
|
||||||
"unable_to_delete_import_path": "غير قادر على حذف مسار الاستيراد",
|
|
||||||
"unable_to_delete_shared_link": "غير قادر على حذف الرابط المشترك",
|
"unable_to_delete_shared_link": "غير قادر على حذف الرابط المشترك",
|
||||||
"unable_to_delete_user": "غير قادر على حذف المستخدم",
|
"unable_to_delete_user": "غير قادر على حذف المستخدم",
|
||||||
"unable_to_download_files": "غير قادر على تنزيل الملفات",
|
"unable_to_download_files": "غير قادر على تنزيل الملفات",
|
||||||
"unable_to_edit_exclusion_pattern": "غير قادر على تعديل نمط الاستبعاد",
|
"unable_to_edit_exclusion_pattern": "غير قادر على تعديل نمط الاستبعاد",
|
||||||
"unable_to_edit_import_path": "غير قادر على تحرير مسار الاستيراد",
|
|
||||||
"unable_to_empty_trash": "غير قادر على إفراغ سلة المهملات",
|
"unable_to_empty_trash": "غير قادر على إفراغ سلة المهملات",
|
||||||
"unable_to_enter_fullscreen": "غير قادر على الدخول إلى وضع ملء الشاشة",
|
"unable_to_enter_fullscreen": "غير قادر على الدخول إلى وضع ملء الشاشة",
|
||||||
"unable_to_exit_fullscreen": "غير قادر على الخروج من وضع ملء الشاشة",
|
"unable_to_exit_fullscreen": "غير قادر على الخروج من وضع ملء الشاشة",
|
||||||
@@ -1039,6 +1053,7 @@
|
|||||||
"exif_bottom_sheet_description_error": "خطأ في تحديث الوصف",
|
"exif_bottom_sheet_description_error": "خطأ في تحديث الوصف",
|
||||||
"exif_bottom_sheet_details": "تفاصيل",
|
"exif_bottom_sheet_details": "تفاصيل",
|
||||||
"exif_bottom_sheet_location": "موقع",
|
"exif_bottom_sheet_location": "موقع",
|
||||||
|
"exif_bottom_sheet_no_description": "لا يوجد وصف",
|
||||||
"exif_bottom_sheet_people": "الناس",
|
"exif_bottom_sheet_people": "الناس",
|
||||||
"exif_bottom_sheet_person_add_person": "اضف اسما",
|
"exif_bottom_sheet_person_add_person": "اضف اسما",
|
||||||
"exit_slideshow": "خروج من العرض التقديمي",
|
"exit_slideshow": "خروج من العرض التقديمي",
|
||||||
@@ -1077,6 +1092,7 @@
|
|||||||
"features_setting_description": "إدارة ميزات التطبيق",
|
"features_setting_description": "إدارة ميزات التطبيق",
|
||||||
"file_name": "إسم الملف",
|
"file_name": "إسم الملف",
|
||||||
"file_name_or_extension": "اسم الملف أو امتداده",
|
"file_name_or_extension": "اسم الملف أو امتداده",
|
||||||
|
"file_size": "حجم الملف",
|
||||||
"filename": "اسم الملف",
|
"filename": "اسم الملف",
|
||||||
"filetype": "نوع الملف",
|
"filetype": "نوع الملف",
|
||||||
"filter": "تصفية",
|
"filter": "تصفية",
|
||||||
@@ -1116,11 +1132,10 @@
|
|||||||
"hash_asset": "عمل Hash للأصل (للملف)",
|
"hash_asset": "عمل Hash للأصل (للملف)",
|
||||||
"hashed_assets": "أصول (ملفات) تم عمل Hash لها",
|
"hashed_assets": "أصول (ملفات) تم عمل Hash لها",
|
||||||
"hashing": "يتم عمل Hash",
|
"hashing": "يتم عمل Hash",
|
||||||
"header_settings_add_header_tip": "اضاف راس",
|
"header_settings_add_header_tip": "إضافة رأس الصفحة",
|
||||||
"header_settings_field_validator_msg": "القيمة لا يمكن ان تكون فارغة",
|
"header_settings_field_validator_msg": "القيمة لا يمكن ان تكون فارغة",
|
||||||
"header_settings_header_name_input": "اسم الرأس",
|
"header_settings_header_name_input": "اسم الرأس",
|
||||||
"header_settings_header_value_input": "قيمة الرأس",
|
"header_settings_header_value_input": "قيمة الرأس",
|
||||||
"headers_settings_tile_subtitle": "قم بتعريف رؤوس الوكيل التي يجب أن يرسلها التطبيق مع كل طلب شبكة",
|
|
||||||
"headers_settings_tile_title": "رؤوس وكيل مخصصة",
|
"headers_settings_tile_title": "رؤوس وكيل مخصصة",
|
||||||
"hi_user": "مرحبا {name} ({email})",
|
"hi_user": "مرحبا {name} ({email})",
|
||||||
"hide_all_people": "إخفاء جميع الأشخاص",
|
"hide_all_people": "إخفاء جميع الأشخاص",
|
||||||
@@ -1241,6 +1256,7 @@
|
|||||||
"local_media_summary": "ملخص الملفات المحلية",
|
"local_media_summary": "ملخص الملفات المحلية",
|
||||||
"local_network": "شبكة محلية",
|
"local_network": "شبكة محلية",
|
||||||
"local_network_sheet_info": "سيتصل التطبيق بالخادم من خلال عنوان URL هذا عند استخدام شبكة Wi-Fi المحددة",
|
"local_network_sheet_info": "سيتصل التطبيق بالخادم من خلال عنوان URL هذا عند استخدام شبكة Wi-Fi المحددة",
|
||||||
|
"location": "موقع",
|
||||||
"location_permission": "اذن الموقع",
|
"location_permission": "اذن الموقع",
|
||||||
"location_permission_content": "من أجل استخدام ميزة التبديل التلقائي، يحتاج Immich إلى إذن موقع دقيق حتى يتمكن من قراءة اسم شبكة Wi-Fi الحالية",
|
"location_permission_content": "من أجل استخدام ميزة التبديل التلقائي، يحتاج Immich إلى إذن موقع دقيق حتى يتمكن من قراءة اسم شبكة Wi-Fi الحالية",
|
||||||
"location_picker_choose_on_map": "اختر على الخريطة",
|
"location_picker_choose_on_map": "اختر على الخريطة",
|
||||||
@@ -1345,6 +1361,8 @@
|
|||||||
"minute": "دقيقة",
|
"minute": "دقيقة",
|
||||||
"minutes": "دقائق",
|
"minutes": "دقائق",
|
||||||
"missing": "المفقودة",
|
"missing": "المفقودة",
|
||||||
|
"mobile_app": "تطبيق الجوال",
|
||||||
|
"mobile_app_download_onboarding_note": "قم بتنزيل التطبيق المصاحب للهاتف المحمول باستخدام الخيارات التالية",
|
||||||
"model": "نموذج",
|
"model": "نموذج",
|
||||||
"month": "شهر",
|
"month": "شهر",
|
||||||
"monthly_title_text_date_format": "ط ط ط",
|
"monthly_title_text_date_format": "ط ط ط",
|
||||||
@@ -1363,6 +1381,8 @@
|
|||||||
"my_albums": "ألبوماتي",
|
"my_albums": "ألبوماتي",
|
||||||
"name": "الاسم",
|
"name": "الاسم",
|
||||||
"name_or_nickname": "الاسم أو اللقب",
|
"name_or_nickname": "الاسم أو اللقب",
|
||||||
|
"navigate": "التنقل",
|
||||||
|
"navigate_to_time": "انتقل إلى الوقت",
|
||||||
"network_requirement_photos_upload": "استخدام بيانات الهاتف المحمول لعمل نسخة احتياطية للصور",
|
"network_requirement_photos_upload": "استخدام بيانات الهاتف المحمول لعمل نسخة احتياطية للصور",
|
||||||
"network_requirement_videos_upload": "استخدام بيانات الهاتف المحمول لعمل نسخة احتياطية لمقاطع الفيديو",
|
"network_requirement_videos_upload": "استخدام بيانات الهاتف المحمول لعمل نسخة احتياطية لمقاطع الفيديو",
|
||||||
"network_requirements": "متطلبات الشبكة",
|
"network_requirements": "متطلبات الشبكة",
|
||||||
@@ -1372,6 +1392,7 @@
|
|||||||
"never": "أبداً",
|
"never": "أبداً",
|
||||||
"new_album": "البوم جديد",
|
"new_album": "البوم جديد",
|
||||||
"new_api_key": "مفتاح API جديد",
|
"new_api_key": "مفتاح API جديد",
|
||||||
|
"new_date_range": "نطاق تاريخ جديد",
|
||||||
"new_password": "كلمة المرور الجديدة",
|
"new_password": "كلمة المرور الجديدة",
|
||||||
"new_person": "شخص جديد",
|
"new_person": "شخص جديد",
|
||||||
"new_pin_code": "رمز PIN الجديد",
|
"new_pin_code": "رمز PIN الجديد",
|
||||||
@@ -1422,6 +1443,9 @@
|
|||||||
"notifications": "إشعارات",
|
"notifications": "إشعارات",
|
||||||
"notifications_setting_description": "إدارة الإشعارات",
|
"notifications_setting_description": "إدارة الإشعارات",
|
||||||
"oauth": "OAuth",
|
"oauth": "OAuth",
|
||||||
|
"obtainium_configurator": "مُهيئ Obtainium",
|
||||||
|
"obtainium_configurator_instructions": "استخدم Obtainium لتثبيت تطبيق Android وتحديثه مباشرةً من صفحة إصدارات Immich على GitHub. أنشئ مفتاح API واختر الإصدار المناسب لإنشاء رابط تهيئة Obtainium الخاص بك",
|
||||||
|
"ocr": "التعرف البصري على الحروف",
|
||||||
"official_immich_resources": "الموارد الرسمية لشركة Immich",
|
"official_immich_resources": "الموارد الرسمية لشركة Immich",
|
||||||
"offline": "غير متصل",
|
"offline": "غير متصل",
|
||||||
"offset": "ازاحة",
|
"offset": "ازاحة",
|
||||||
@@ -1526,6 +1550,9 @@
|
|||||||
"play_memories": "تشغيل الذكريات",
|
"play_memories": "تشغيل الذكريات",
|
||||||
"play_motion_photo": "تشغيل الصور المتحركة",
|
"play_motion_photo": "تشغيل الصور المتحركة",
|
||||||
"play_or_pause_video": "تشغيل الفيديو أو إيقافه مؤقتًا",
|
"play_or_pause_video": "تشغيل الفيديو أو إيقافه مؤقتًا",
|
||||||
|
"play_original_video": "تشغيل الفيديو الأصلي",
|
||||||
|
"play_original_video_setting_description": "تفضيل تشغيل مقاطع الفيديو الأصلية بدلاً من مقاطع الفيديو المحولة. إذا لم يكن الملف الأصلي متوافقًا، فقد لا يتم تشغيله بشكل صحيح.",
|
||||||
|
"play_transcoded_video": "تشغيل الفيديو المُعاد ترميزه",
|
||||||
"please_auth_to_access": "الرجاء القيام بالمصادقة للوصول",
|
"please_auth_to_access": "الرجاء القيام بالمصادقة للوصول",
|
||||||
"port": "المنفذ",
|
"port": "المنفذ",
|
||||||
"preferences_settings_subtitle": "ادارة تفضيلات التطبيق",
|
"preferences_settings_subtitle": "ادارة تفضيلات التطبيق",
|
||||||
@@ -1543,13 +1570,9 @@
|
|||||||
"privacy": "الخصوصية",
|
"privacy": "الخصوصية",
|
||||||
"profile": "حساب تعريفي",
|
"profile": "حساب تعريفي",
|
||||||
"profile_drawer_app_logs": "السجلات",
|
"profile_drawer_app_logs": "السجلات",
|
||||||
"profile_drawer_client_out_of_date_major": "تطبيق الهاتف المحمول قديم.يرجى التحديث إلى أحدث إصدار رئيسي.",
|
|
||||||
"profile_drawer_client_out_of_date_minor": "تطبيق الهاتف المحمول قديم.يرجى التحديث إلى أحدث إصدار صغير.",
|
|
||||||
"profile_drawer_client_server_up_to_date": "العميل والخادم محدثان",
|
"profile_drawer_client_server_up_to_date": "العميل والخادم محدثان",
|
||||||
"profile_drawer_github": "Github",
|
"profile_drawer_github": "Github",
|
||||||
"profile_drawer_readonly_mode": "تم تفعيل وضع القراءة فقط. اضغط مطولا على رمز صورة المستخدم للخروج.",
|
"profile_drawer_readonly_mode": "تم تفعيل وضع القراءة فقط. اضغط مطولا على رمز صورة المستخدم للخروج.",
|
||||||
"profile_drawer_server_out_of_date_major": "الخادم قديم.يرجى التحديث إلى أحدث إصدار رئيسي.",
|
|
||||||
"profile_drawer_server_out_of_date_minor": "الخادم قديم.يرجى التحديث إلى أحدث إصدار صغير.",
|
|
||||||
"profile_image_of_user": "صورة الملف الشخصي لـ {user}",
|
"profile_image_of_user": "صورة الملف الشخصي لـ {user}",
|
||||||
"profile_picture_set": "مجموعة الصور الشخصية.",
|
"profile_picture_set": "مجموعة الصور الشخصية.",
|
||||||
"public_album": "الألبوم العام",
|
"public_album": "الألبوم العام",
|
||||||
@@ -1666,6 +1689,7 @@
|
|||||||
"reset_sqlite_confirmation": "هل أنت متأكد من رغبتك في إعادة ضبط قاعدة بيانات SQLite؟ ستحتاج إلى تسجيل الخروج ثم تسجيل الدخول مرة أخرى لإعادة مزامنة البيانات",
|
"reset_sqlite_confirmation": "هل أنت متأكد من رغبتك في إعادة ضبط قاعدة بيانات SQLite؟ ستحتاج إلى تسجيل الخروج ثم تسجيل الدخول مرة أخرى لإعادة مزامنة البيانات",
|
||||||
"reset_sqlite_success": "تم إعادة تعيين قاعدة بيانات SQLite بنجاح",
|
"reset_sqlite_success": "تم إعادة تعيين قاعدة بيانات SQLite بنجاح",
|
||||||
"reset_to_default": "إعادة التعيين إلى الافتراضي",
|
"reset_to_default": "إعادة التعيين إلى الافتراضي",
|
||||||
|
"resolution": "دقة",
|
||||||
"resolve_duplicates": "معالجة النسخ المكررة",
|
"resolve_duplicates": "معالجة النسخ المكررة",
|
||||||
"resolved_all_duplicates": "تم حل جميع التكرارات",
|
"resolved_all_duplicates": "تم حل جميع التكرارات",
|
||||||
"restore": "الاستعاده من سلة المهملات",
|
"restore": "الاستعاده من سلة المهملات",
|
||||||
@@ -1684,6 +1708,7 @@
|
|||||||
"running": "قيد التشغيل",
|
"running": "قيد التشغيل",
|
||||||
"save": "حفظ",
|
"save": "حفظ",
|
||||||
"save_to_gallery": "حفظ الى المعرض",
|
"save_to_gallery": "حفظ الى المعرض",
|
||||||
|
"saved": "تم الحفظ",
|
||||||
"saved_api_key": "تم حفظ مفتاح الـ API",
|
"saved_api_key": "تم حفظ مفتاح الـ API",
|
||||||
"saved_profile": "تم حفظ الملف",
|
"saved_profile": "تم حفظ الملف",
|
||||||
"saved_settings": "تم حفظ الإعدادات",
|
"saved_settings": "تم حفظ الإعدادات",
|
||||||
@@ -1700,6 +1725,9 @@
|
|||||||
"search_by_description_example": "يوم المشي لمسافات طويلة في سابا",
|
"search_by_description_example": "يوم المشي لمسافات طويلة في سابا",
|
||||||
"search_by_filename": "البحث بإسم الملف أو نوعه",
|
"search_by_filename": "البحث بإسم الملف أو نوعه",
|
||||||
"search_by_filename_example": "كـ IMG_1234.JPG أو PNG",
|
"search_by_filename_example": "كـ IMG_1234.JPG أو PNG",
|
||||||
|
"search_by_ocr": "البحث عن طريق التعرف البصري على الحروف",
|
||||||
|
"search_by_ocr_example": "لاتيه",
|
||||||
|
"search_camera_lens_model": "بحث نموذج العدسة...",
|
||||||
"search_camera_make": "البحث حسب الشركة المصنعة للكاميرا...",
|
"search_camera_make": "البحث حسب الشركة المصنعة للكاميرا...",
|
||||||
"search_camera_model": "البحث حسب موديل الكاميرا...",
|
"search_camera_model": "البحث حسب موديل الكاميرا...",
|
||||||
"search_city": "البحث حسب المدينة...",
|
"search_city": "البحث حسب المدينة...",
|
||||||
@@ -1716,6 +1744,7 @@
|
|||||||
"search_filter_location_title": "اختر الموقع",
|
"search_filter_location_title": "اختر الموقع",
|
||||||
"search_filter_media_type": "نوع الوسائط",
|
"search_filter_media_type": "نوع الوسائط",
|
||||||
"search_filter_media_type_title": "اختر نوع الوسائط",
|
"search_filter_media_type_title": "اختر نوع الوسائط",
|
||||||
|
"search_filter_ocr": "البحث عن طريق التعرف البصري على الحروف",
|
||||||
"search_filter_people_title": "اختر الاشخاص",
|
"search_filter_people_title": "اختر الاشخاص",
|
||||||
"search_for": "البحث عن",
|
"search_for": "البحث عن",
|
||||||
"search_for_existing_person": "البحث عن شخص موجود",
|
"search_for_existing_person": "البحث عن شخص موجود",
|
||||||
@@ -1778,6 +1807,7 @@
|
|||||||
"server_online": "الخادم متصل",
|
"server_online": "الخادم متصل",
|
||||||
"server_privacy": "خصوصية الخادم",
|
"server_privacy": "خصوصية الخادم",
|
||||||
"server_stats": "إحصائيات الخادم",
|
"server_stats": "إحصائيات الخادم",
|
||||||
|
"server_update_available": "تحديث الخادم متاح",
|
||||||
"server_version": "إصدار الخادم",
|
"server_version": "إصدار الخادم",
|
||||||
"set": "تحديد",
|
"set": "تحديد",
|
||||||
"set_as_album_cover": "تحديد كغلاف للألبوم",
|
"set_as_album_cover": "تحديد كغلاف للألبوم",
|
||||||
@@ -1806,6 +1836,8 @@
|
|||||||
"setting_notifications_subtitle": "اضبط تفضيلات الإخطار",
|
"setting_notifications_subtitle": "اضبط تفضيلات الإخطار",
|
||||||
"setting_notifications_total_progress_subtitle": "التقدم التحميل العام (تم القيام به/إجمالي الأصول)",
|
"setting_notifications_total_progress_subtitle": "التقدم التحميل العام (تم القيام به/إجمالي الأصول)",
|
||||||
"setting_notifications_total_progress_title": "إظهار النسخ الاحتياطي الخلفية التقدم المحرز",
|
"setting_notifications_total_progress_title": "إظهار النسخ الاحتياطي الخلفية التقدم المحرز",
|
||||||
|
"setting_video_viewer_auto_play_subtitle": "بدء تشغيل مقاطع الفيديو تلقائيًا عند فتحها",
|
||||||
|
"setting_video_viewer_auto_play_title": "تشغيل الفيديوهات تلقائيًا",
|
||||||
"setting_video_viewer_looping_title": "تكرار مقطع فيديو تلقائيًا",
|
"setting_video_viewer_looping_title": "تكرار مقطع فيديو تلقائيًا",
|
||||||
"setting_video_viewer_original_video_subtitle": "عند بث فيديو من الخادم، شغّل النسخة الأصلية حتى مع توفر ترميز بديل. قد يؤدي ذلك إلى تقطيع اثناء العرض . تُشغّل الفيديوهات المتوفرة محليًا بجودة أصلية بغض النظر عن هذا الإعداد.",
|
"setting_video_viewer_original_video_subtitle": "عند بث فيديو من الخادم، شغّل النسخة الأصلية حتى مع توفر ترميز بديل. قد يؤدي ذلك إلى تقطيع اثناء العرض . تُشغّل الفيديوهات المتوفرة محليًا بجودة أصلية بغض النظر عن هذا الإعداد.",
|
||||||
"setting_video_viewer_original_video_title": "اجبار عرض الفديو الاصلي",
|
"setting_video_viewer_original_video_title": "اجبار عرض الفديو الاصلي",
|
||||||
@@ -1985,6 +2017,7 @@
|
|||||||
"theme_setting_three_stage_loading_title": "تمكين تحميل ثلاث مراحل",
|
"theme_setting_three_stage_loading_title": "تمكين تحميل ثلاث مراحل",
|
||||||
"they_will_be_merged_together": "سيتم دمجهم معًا",
|
"they_will_be_merged_together": "سيتم دمجهم معًا",
|
||||||
"third_party_resources": "موارد الطرف الثالث",
|
"third_party_resources": "موارد الطرف الثالث",
|
||||||
|
"time": "وقت",
|
||||||
"time_based_memories": "ذكريات استنادًا للوقت",
|
"time_based_memories": "ذكريات استنادًا للوقت",
|
||||||
"timeline": "الخط الزمني",
|
"timeline": "الخط الزمني",
|
||||||
"timezone": "المنطقة الزمنية",
|
"timezone": "المنطقة الزمنية",
|
||||||
@@ -2017,6 +2050,7 @@
|
|||||||
"troubleshoot": "استكشاف المشاكل",
|
"troubleshoot": "استكشاف المشاكل",
|
||||||
"type": "النوع",
|
"type": "النوع",
|
||||||
"unable_to_change_pin_code": "تفيير رمز PIN غير ممكن",
|
"unable_to_change_pin_code": "تفيير رمز PIN غير ممكن",
|
||||||
|
"unable_to_check_version": "تعذر التحقق من إصدار التطبيق أو الخادم",
|
||||||
"unable_to_setup_pin_code": "انشاء رمز PIN غير ممكن",
|
"unable_to_setup_pin_code": "انشاء رمز PIN غير ممكن",
|
||||||
"unarchive": "أخرج من الأرشيف",
|
"unarchive": "أخرج من الأرشيف",
|
||||||
"unarchive_action_prompt": "{count} ازيل من الارشيف",
|
"unarchive_action_prompt": "{count} ازيل من الارشيف",
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
"add_birthday": "Doğum günü əlavə et",
|
"add_birthday": "Doğum günü əlavə et",
|
||||||
"add_endpoint": "Son nöqtə əlavə et",
|
"add_endpoint": "Son nöqtə əlavə et",
|
||||||
"add_exclusion_pattern": "Çıxarma nümunəsi əlavə et",
|
"add_exclusion_pattern": "Çıxarma nümunəsi əlavə et",
|
||||||
"add_import_path": "İdxal yolu əlavə et",
|
|
||||||
"add_location": "Məkan əlavə et",
|
"add_location": "Məkan əlavə et",
|
||||||
"add_more_users": "Daha çox istifadəçi əlavə et",
|
"add_more_users": "Daha çox istifadəçi əlavə et",
|
||||||
"add_partner": "Partnyor əlavə et",
|
"add_partner": "Partnyor əlavə et",
|
||||||
@@ -85,7 +84,6 @@
|
|||||||
"jobs_failed": "{jobCount, plural, other {# uğursuz}}",
|
"jobs_failed": "{jobCount, plural, other {# uğursuz}}",
|
||||||
"library_created": "{library} kitabxanası yaradıldı",
|
"library_created": "{library} kitabxanası yaradıldı",
|
||||||
"library_deleted": "Kitabxana silindi",
|
"library_deleted": "Kitabxana silindi",
|
||||||
"library_import_path_description": "İdxal olunacaq qovluöu seçin. Bu qovluq, alt qovluqlar daxil olmaqla şəkil və videolar üçün skan ediləcəkdir.",
|
|
||||||
"library_scanning": "Periodik skan",
|
"library_scanning": "Periodik skan",
|
||||||
"library_scanning_description": "Periodik kitabxana skanını confiqurasiya et",
|
"library_scanning_description": "Periodik kitabxana skanını confiqurasiya et",
|
||||||
"library_scanning_enable_description": "Periodik kitabxana skanını aktivləşdir",
|
"library_scanning_enable_description": "Periodik kitabxana skanını aktivləşdir",
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
"add_birthday": "Дадаць дзень нараджэння",
|
"add_birthday": "Дадаць дзень нараджэння",
|
||||||
"add_endpoint": "Дадаць кропку доступу",
|
"add_endpoint": "Дадаць кропку доступу",
|
||||||
"add_exclusion_pattern": "Дадаць шаблон выключэння",
|
"add_exclusion_pattern": "Дадаць шаблон выключэння",
|
||||||
"add_import_path": "Дадаць шлях імпарту",
|
|
||||||
"add_location": "Дадайце месца",
|
"add_location": "Дадайце месца",
|
||||||
"add_more_users": "Дадаць больш карыстальнікаў",
|
"add_more_users": "Дадаць больш карыстальнікаў",
|
||||||
"add_partner": "Дадаць партнёра",
|
"add_partner": "Дадаць партнёра",
|
||||||
@@ -318,8 +317,6 @@
|
|||||||
"edit_description": "Рэдагаваць апісанне",
|
"edit_description": "Рэдагаваць апісанне",
|
||||||
"edit_description_prompt": "Выберыце новае апісанне:",
|
"edit_description_prompt": "Выберыце новае апісанне:",
|
||||||
"edit_faces": "Рэдагаваць твары",
|
"edit_faces": "Рэдагаваць твары",
|
||||||
"edit_import_path": "Рэдагаваць шлях імпарту",
|
|
||||||
"edit_import_paths": "Рэдагаваць шляхі імпарту",
|
|
||||||
"edit_key": "Рэдагаваць ключ",
|
"edit_key": "Рэдагаваць ключ",
|
||||||
"edit_link": "Рэдагаваць спасылку",
|
"edit_link": "Рэдагаваць спасылку",
|
||||||
"edit_location": "Рэдагаваць месцазнаходжанне",
|
"edit_location": "Рэдагаваць месцазнаходжанне",
|
||||||
@@ -329,7 +326,6 @@
|
|||||||
"edit_tag": "Рэдагаваць тэг",
|
"edit_tag": "Рэдагаваць тэг",
|
||||||
"edit_title": "Рэдагаваць загаловак",
|
"edit_title": "Рэдагаваць загаловак",
|
||||||
"edit_user": "Рэдагаваць карыстальніка",
|
"edit_user": "Рэдагаваць карыстальніка",
|
||||||
"edited": "Адрэдагавана",
|
|
||||||
"editor": "Рэдактар",
|
"editor": "Рэдактар",
|
||||||
"editor_close_without_save_prompt": "Змены не будуць захаваны",
|
"editor_close_without_save_prompt": "Змены не будуць захаваны",
|
||||||
"editor_close_without_save_title": "Закрыць рэдактар?",
|
"editor_close_without_save_title": "Закрыць рэдактар?",
|
||||||
@@ -399,6 +395,8 @@
|
|||||||
"partner_list_user_photos": "Фота карыстальніка {user}",
|
"partner_list_user_photos": "Фота карыстальніка {user}",
|
||||||
"pause": "Прыпыніць",
|
"pause": "Прыпыніць",
|
||||||
"people": "Людзі",
|
"people": "Людзі",
|
||||||
|
"permanent_deletion_warning": "Папярэджанне аб канчатковым выдаленні",
|
||||||
|
"permanent_deletion_warning_setting_description": "Паказаць папярэджанне пры канчатковым выдаленні рэсурсаў",
|
||||||
"permission_onboarding_back": "Назад",
|
"permission_onboarding_back": "Назад",
|
||||||
"permission_onboarding_continue_anyway": "Усё адно працягнуць",
|
"permission_onboarding_continue_anyway": "Усё адно працягнуць",
|
||||||
"photos": "Фота",
|
"photos": "Фота",
|
||||||
|
|||||||
107
i18n/bg.json
107
i18n/bg.json
@@ -17,7 +17,6 @@
|
|||||||
"add_birthday": "Добави дата на раждане",
|
"add_birthday": "Добави дата на раждане",
|
||||||
"add_endpoint": "Добави крайна точка",
|
"add_endpoint": "Добави крайна точка",
|
||||||
"add_exclusion_pattern": "Добави модел за изключване",
|
"add_exclusion_pattern": "Добави модел за изключване",
|
||||||
"add_import_path": "Добави път за импортиране",
|
|
||||||
"add_location": "Дoбави местоположение",
|
"add_location": "Дoбави местоположение",
|
||||||
"add_more_users": "Добави още потребители",
|
"add_more_users": "Добави още потребители",
|
||||||
"add_partner": "Добави партньор",
|
"add_partner": "Добави партньор",
|
||||||
@@ -32,7 +31,9 @@
|
|||||||
"add_to_album_toggle": "Сменете избора за {album}",
|
"add_to_album_toggle": "Сменете избора за {album}",
|
||||||
"add_to_albums": "Добавяне в албуми",
|
"add_to_albums": "Добавяне в албуми",
|
||||||
"add_to_albums_count": "Добавяне в албуми ({count})",
|
"add_to_albums_count": "Добавяне в албуми ({count})",
|
||||||
|
"add_to_bottom_bar": "Добави към",
|
||||||
"add_to_shared_album": "Добави към споделен албум",
|
"add_to_shared_album": "Добави към споделен албум",
|
||||||
|
"add_upload_to_stack": "Добави качените в група",
|
||||||
"add_url": "Добави URL",
|
"add_url": "Добави URL",
|
||||||
"added_to_archive": "Добавено към архива",
|
"added_to_archive": "Добавено към архива",
|
||||||
"added_to_favorites": "Добавени към любимите ви",
|
"added_to_favorites": "Добавени към любимите ви",
|
||||||
@@ -90,7 +91,7 @@
|
|||||||
"image_prefer_embedded_preview_setting_description": "Използване на вградените миниатюри в RAW снимките като входни за обработка на изображенията, когато има такива. Това може да доведе до по-точни цветове за някои изображения, но качеството на прегледите зависи от камерата и изображението може да има повече компресионни артефакти.",
|
"image_prefer_embedded_preview_setting_description": "Използване на вградените миниатюри в RAW снимките като входни за обработка на изображенията, когато има такива. Това може да доведе до по-точни цветове за някои изображения, но качеството на прегледите зависи от камерата и изображението може да има повече компресионни артефакти.",
|
||||||
"image_prefer_wide_gamut": "Предпочитане на широка гама",
|
"image_prefer_wide_gamut": "Предпочитане на широка гама",
|
||||||
"image_prefer_wide_gamut_setting_description": "Използване на Display P3 за миниатюри. Това запазва по-добре жизнеността на изображенията с широки цветови пространства, но изображенията може да изглеждат по различен начин на стари устройства със стара версия на браузъра. sRGB изображенията се запазват като sRGB, за да се избегнат цветови промени.",
|
"image_prefer_wide_gamut_setting_description": "Използване на Display P3 за миниатюри. Това запазва по-добре жизнеността на изображенията с широки цветови пространства, но изображенията може да изглеждат по различен начин на стари устройства със стара версия на браузъра. sRGB изображенията се запазват като sRGB, за да се избегнат цветови промени.",
|
||||||
"image_preview_description": "Среден размер на изображението с премахнати метаданни, използвано при преглед на един актив и за машинно обучение",
|
"image_preview_description": "Среден размер на изображението с премахнати метаданни, използвано при преглед на един елемент и за машинно обучение",
|
||||||
"image_preview_quality_description": "Качество на предварителния преглед от 1 до 100. По-високата стойност е по-добра, но води до по-големи файлове и може да намали бързодействието на приложението. Задаването на ниска стойност може да повлияе на качеството на машинното обучение.",
|
"image_preview_quality_description": "Качество на предварителния преглед от 1 до 100. По-високата стойност е по-добра, но води до по-големи файлове и може да намали бързодействието на приложението. Задаването на ниска стойност може да повлияе на качеството на машинното обучение.",
|
||||||
"image_preview_title": "Настройки на прегледа",
|
"image_preview_title": "Настройки на прегледа",
|
||||||
"image_quality": "Качество",
|
"image_quality": "Качество",
|
||||||
@@ -111,15 +112,14 @@
|
|||||||
"jobs_failed": "{jobCount, plural, other {# неуспешни}}",
|
"jobs_failed": "{jobCount, plural, other {# неуспешни}}",
|
||||||
"library_created": "Създадена библиотека: {library}",
|
"library_created": "Създадена библиотека: {library}",
|
||||||
"library_deleted": "Библиотека е изтрита",
|
"library_deleted": "Библиотека е изтрита",
|
||||||
"library_import_path_description": "Посочете папка за импортиране. Тази папка, включително подпапките, ще бъдат сканирани за изображения и видеоклипове.",
|
|
||||||
"library_scanning": "Периодично сканиране",
|
"library_scanning": "Периодично сканиране",
|
||||||
"library_scanning_description": "Конфигурирай периодично сканиране на библиотеката",
|
"library_scanning_description": "Конфигурирай периодично сканиране на библиотеката",
|
||||||
"library_scanning_enable_description": "Включване на периодичното сканиране на библиотеката",
|
"library_scanning_enable_description": "Включване на периодичното сканиране на библиотеката",
|
||||||
"library_settings": "Външна библиотека",
|
"library_settings": "Външна библиотека",
|
||||||
"library_settings_description": "Управление на настройките за външна библиотека",
|
"library_settings_description": "Управление на настройките за външна библиотека",
|
||||||
"library_tasks_description": "Сканирайте външни библиотеки за нови и/или променени активи",
|
"library_tasks_description": "Сканирайте външни библиотеки за нови и/или променени елементи",
|
||||||
"library_watching_enable_description": "Наблюдаване за промяна на файловете във външната библиотека",
|
"library_watching_enable_description": "Наблюдаване за промяна на файловете във външната библиотека",
|
||||||
"library_watching_settings": "Наблюдаване на библиотеката (ЕКСПЕРИМЕНТАЛНО)",
|
"library_watching_settings": "Наблюдаване на библиотеката [ЕКСПЕРИМЕНТАЛНО]",
|
||||||
"library_watching_settings_description": "Автоматично наблюдавай за променени файлове",
|
"library_watching_settings_description": "Автоматично наблюдавай за променени файлове",
|
||||||
"logging_enable_description": "Включване на запис (логове)",
|
"logging_enable_description": "Включване на запис (логове)",
|
||||||
"logging_level_description": "Когато е включено, какво ниво на записване да се използва.",
|
"logging_level_description": "Когато е включено, какво ниво на записване да се използва.",
|
||||||
@@ -150,9 +150,21 @@
|
|||||||
"machine_learning_max_recognition_distance": "Максимално разстояние за разпознаване",
|
"machine_learning_max_recognition_distance": "Максимално разстояние за разпознаване",
|
||||||
"machine_learning_max_recognition_distance_description": "Максимално разстояние между две лица, за да се считат за едно и също лице, в диапазона 0-2. Намаляването му може да предотврати определянето на две лица като едно и също лице, а увеличаването му може да предотврати определянето на едно и също лице като две различни лица. Имайте предвид, че е по-лесно да се слеят две лица, отколкото да се раздели едно лице на две, така че по възможност изберете по-ниска стойност.",
|
"machine_learning_max_recognition_distance_description": "Максимално разстояние между две лица, за да се считат за едно и също лице, в диапазона 0-2. Намаляването му може да предотврати определянето на две лица като едно и също лице, а увеличаването му може да предотврати определянето на едно и също лице като две различни лица. Имайте предвид, че е по-лесно да се слеят две лица, отколкото да се раздели едно лице на две, така че по възможност изберете по-ниска стойност.",
|
||||||
"machine_learning_min_detection_score": "Минимална оценка за откриване",
|
"machine_learning_min_detection_score": "Минимална оценка за откриване",
|
||||||
"machine_learning_min_detection_score_description": "Минимална оценка на доверието, за да бъде считано лице като открито - от 0 до 1. По-ниските стойности ще открият повече лица, но може да доведат до фалшиви положителни резултати.",
|
"machine_learning_min_detection_score_description": "Минимална оценка на доверие, за да бъде считано лице като открито - от 0 до 1. По-ниските стойности ще открият повече лица, но може да доведат до фалшиви положителни резултати.",
|
||||||
"machine_learning_min_recognized_faces": "Минимум разпознати лица",
|
"machine_learning_min_recognized_faces": "Минимум разпознати лица",
|
||||||
"machine_learning_min_recognized_faces_description": "Минималният брой разпознати лица, необходими за създаването на лице. Увеличаването му прави разпознаването на лица по-прецизно за сметка на увеличаването на вероятността дадено лице да не бъде причислено към лице.",
|
"machine_learning_min_recognized_faces_description": "Минималният брой разпознати лица, необходими за създаването на лице. Увеличаването му прави разпознаването на лица по-прецизно за сметка на увеличаването на вероятността дадено лице да не бъде причислено към лице.",
|
||||||
|
"machine_learning_ocr": "Разпознаване на текст",
|
||||||
|
"machine_learning_ocr_description": "Използвайте машинно обучение за разпознаване на текст в изображенията",
|
||||||
|
"machine_learning_ocr_enabled": "Включи разпознаване на текст",
|
||||||
|
"machine_learning_ocr_enabled_description": "Ако е забранено, няма да се прави разпознаване на текст в изображенията.",
|
||||||
|
"machine_learning_ocr_max_resolution": "Максимална резолюция",
|
||||||
|
"machine_learning_ocr_max_resolution_description": "Изображения с резолюция над зададената ще бъдат преоразмерени при запазване на пропорцията. Голяма стойност позволява по-прецизно разпознаване, но обработката използва повече време и повече памет.",
|
||||||
|
"machine_learning_ocr_min_detection_score": "Минимална оценка за откриванe",
|
||||||
|
"machine_learning_ocr_min_detection_score_description": "Минималната оценка на доверие за откриване на текст може да бъде между 0 и 1. По-ниска стойност ще открива повече текст, но може да доведе до грешни резултати.",
|
||||||
|
"machine_learning_ocr_min_recognition_score": "Минимална оценкa за откриване",
|
||||||
|
"machine_learning_ocr_min_score_recognition_description": "Минимална оценка на доверие, за да бъде считан текст като открит - от 0 до 1. По-ниските стойности ще открият повече текст, но може да доведат до фалшиви положителни резултати.",
|
||||||
|
"machine_learning_ocr_model": "Модел за разпознаване на текст",
|
||||||
|
"machine_learning_ocr_model_description": "Сървърните модели са по-точни от мобилните модели, но изискват повече време и използват повече памет.",
|
||||||
"machine_learning_settings": "Настройки на машинното обучение",
|
"machine_learning_settings": "Настройки на машинното обучение",
|
||||||
"machine_learning_settings_description": "Управление на функциите и настройките за машинно обучение",
|
"machine_learning_settings_description": "Управление на функциите и настройките за машинно обучение",
|
||||||
"machine_learning_smart_search": "Интелигентно Търсене",
|
"machine_learning_smart_search": "Интелигентно Търсене",
|
||||||
@@ -178,7 +190,7 @@
|
|||||||
"memory_cleanup_job": "Почистване на паметта",
|
"memory_cleanup_job": "Почистване на паметта",
|
||||||
"memory_generate_job": "Генериране на паметта",
|
"memory_generate_job": "Генериране на паметта",
|
||||||
"metadata_extraction_job": "Извличане на метаданни",
|
"metadata_extraction_job": "Извличане на метаданни",
|
||||||
"metadata_extraction_job_description": "Извличане на метаданни от всеки от елемент, като GPS локация, лица и резолюция на файловете",
|
"metadata_extraction_job_description": "Извличане на метаданни от всеки елемент, като GPS локация, лица и резолюция на файловете",
|
||||||
"metadata_faces_import_setting": "Включи импорт на лице",
|
"metadata_faces_import_setting": "Включи импорт на лице",
|
||||||
"metadata_faces_import_setting_description": "Импортирай лица от EXIF данни и помощни файлове",
|
"metadata_faces_import_setting_description": "Импортирай лица от EXIF данни и помощни файлове",
|
||||||
"metadata_settings": "Опции за метаданни",
|
"metadata_settings": "Опции за метаданни",
|
||||||
@@ -210,6 +222,8 @@
|
|||||||
"notification_email_ignore_certificate_errors_description": "Игнорирай грешки свързани с валидация на TLS сертификат (не се препоръчва)",
|
"notification_email_ignore_certificate_errors_description": "Игнорирай грешки свързани с валидация на TLS сертификат (не се препоръчва)",
|
||||||
"notification_email_password_description": "Парола използвана за удостоверяване пред сървъра за електронна поща",
|
"notification_email_password_description": "Парола използвана за удостоверяване пред сървъра за електронна поща",
|
||||||
"notification_email_port_description": "Порт на сървъра за електронна поща (например 25, 465 или 587)",
|
"notification_email_port_description": "Порт на сървъра за електронна поща (например 25, 465 или 587)",
|
||||||
|
"notification_email_secure": "SMTPS",
|
||||||
|
"notification_email_secure_description": "Използвай SMTPS (SMTP по TLS)",
|
||||||
"notification_email_sent_test_email_button": "Изпрати тестов имейл и запази",
|
"notification_email_sent_test_email_button": "Изпрати тестов имейл и запази",
|
||||||
"notification_email_setting_description": "Настройки за изпращане на имейл известия",
|
"notification_email_setting_description": "Настройки за изпращане на имейл известия",
|
||||||
"notification_email_test_email": "Изпрати тестов имейл",
|
"notification_email_test_email": "Изпрати тестов имейл",
|
||||||
@@ -242,6 +256,7 @@
|
|||||||
"oauth_storage_quota_default_description": "Квота в GiB, която да се използва, когато не е посочено друго.",
|
"oauth_storage_quota_default_description": "Квота в GiB, която да се използва, когато не е посочено друго.",
|
||||||
"oauth_timeout": "Време на изчакване при заявка",
|
"oauth_timeout": "Време на изчакване при заявка",
|
||||||
"oauth_timeout_description": "Време за изчакване на отговор на заявка, в милисекунди",
|
"oauth_timeout_description": "Време за изчакване на отговор на заявка, в милисекунди",
|
||||||
|
"ocr_job_description": "Използване на машинно обучение за разпознаване на текст в изображенията",
|
||||||
"password_enable_description": "Влизане с имейл и парола",
|
"password_enable_description": "Влизане с имейл и парола",
|
||||||
"password_settings": "Вписване с парола",
|
"password_settings": "Вписване с парола",
|
||||||
"password_settings_description": "Управление на настройките за влизане с парола",
|
"password_settings_description": "Управление на настройките за влизане с парола",
|
||||||
@@ -332,7 +347,7 @@
|
|||||||
"transcoding_max_b_frames": "Максимални B-фрейма",
|
"transcoding_max_b_frames": "Максимални B-фрейма",
|
||||||
"transcoding_max_b_frames_description": "По-високите стойности подобряват ефективността на компресията, но забавят разкодирането. Може да не е съвместим с хардуерното ускорение на по-стари устройства. 0 деактивира B-фрейма, докато -1 задава тази стойност автоматично.",
|
"transcoding_max_b_frames_description": "По-високите стойности подобряват ефективността на компресията, но забавят разкодирането. Може да не е съвместим с хардуерното ускорение на по-стари устройства. 0 деактивира B-фрейма, докато -1 задава тази стойност автоматично.",
|
||||||
"transcoding_max_bitrate": "Максимален битрейт",
|
"transcoding_max_bitrate": "Максимален битрейт",
|
||||||
"transcoding_max_bitrate_description": "Задаването на максимален битрейт може да направи размерите на файловете по-предвидими при незначителни разлики за качеството. При 720p типичните стойности са 2600 kbit/s за VP9 или HEVC или 4500 kbit/s за H.264. Деактивирано, ако е зададено на 0.",
|
"transcoding_max_bitrate_description": "Задаването на максимален битрейт може да направи размерите на файловете по-предвидими при незначителни разлики за качеството. При 720p типичните стойности са 2600 kbit/s за VP9 или HEVC или 4500 kbit/s за H.264. Деактивирано, ако е зададено на 0. Когато не е зададена мерна единица, подразбира се k (kbit/s); така 5000, 5000k и 5M (Mbit/s) са еквивалентни.",
|
||||||
"transcoding_max_keyframe_interval": "Максимален интервал между ключовите кадри",
|
"transcoding_max_keyframe_interval": "Максимален интервал между ключовите кадри",
|
||||||
"transcoding_max_keyframe_interval_description": "Задава максималното разстояние между ключовите кадри. По-ниските стойности влошават ефективността на компресията, но подобряват времето за търсене и могат да подобрят качеството в сцени с бързо движение. 0 задава тази стойност автоматично.",
|
"transcoding_max_keyframe_interval_description": "Задава максималното разстояние между ключовите кадри. По-ниските стойности влошават ефективността на компресията, но подобряват времето за търсене и могат да подобрят качеството в сцени с бързо движение. 0 задава тази стойност автоматично.",
|
||||||
"transcoding_optimal_description": "Видеоклипове с по-висока от целевата разделителна способност или не в приетия формат",
|
"transcoding_optimal_description": "Видеоклипове с по-висока от целевата разделителна способност или не в приетия формат",
|
||||||
@@ -350,7 +365,7 @@
|
|||||||
"transcoding_target_resolution": "Целева резолюция",
|
"transcoding_target_resolution": "Целева резолюция",
|
||||||
"transcoding_target_resolution_description": "По-високите разделителни способности могат да представят повече детайли, но отнемат повече време за разкодиране, имат по-големи размери на файловете и могат да намалят отзивчивостта на приложението.",
|
"transcoding_target_resolution_description": "По-високите разделителни способности могат да представят повече детайли, но отнемат повече време за разкодиране, имат по-големи размери на файловете и могат да намалят отзивчивостта на приложението.",
|
||||||
"transcoding_temporal_aq": "Темпорален AQ",
|
"transcoding_temporal_aq": "Темпорален AQ",
|
||||||
"transcoding_temporal_aq_description": "Само за NVENC. Повишава качеството на сцени с висока детайлност и ниско ниво на движение. Може да не е съвместимо с по-стари устройства.",
|
"transcoding_temporal_aq_description": "Само за NVENC. Повишава качеството на сцени с висока детайлност и малко движение. Може да не е съвместимо с по-стари устройства.",
|
||||||
"transcoding_threads": "Нишки",
|
"transcoding_threads": "Нишки",
|
||||||
"transcoding_threads_description": "По-високите стойности водят до по-бързо разкодиране, но оставят по-малко място за сървъра да обработва други задачи, докато е активен. Тази стойност не трябва да надвишава броя на процесорните ядра. Увеличава максимално използването, ако е зададено на 0.",
|
"transcoding_threads_description": "По-високите стойности водят до по-бързо разкодиране, но оставят по-малко място за сървъра да обработва други задачи, докато е активен. Тази стойност не трябва да надвишава броя на процесорните ядра. Увеличава максимално използването, ако е зададено на 0.",
|
||||||
"transcoding_tone_mapping": "Тонално картографиране",
|
"transcoding_tone_mapping": "Тонално картографиране",
|
||||||
@@ -401,11 +416,11 @@
|
|||||||
"advanced_settings_prefer_remote_subtitle": "Някои устройства са твърде бавни за да генерират миниатюри. Активирай тази опция за да се зареждат винаги от сървъра.",
|
"advanced_settings_prefer_remote_subtitle": "Някои устройства са твърде бавни за да генерират миниатюри. Активирай тази опция за да се зареждат винаги от сървъра.",
|
||||||
"advanced_settings_prefer_remote_title": "Предпочитай изображенията на сървъра",
|
"advanced_settings_prefer_remote_title": "Предпочитай изображенията на сървъра",
|
||||||
"advanced_settings_proxy_headers_subtitle": "Дефиниране на прокси хедъри, които Immich трябва да изпраща с всяка мрежова заявка",
|
"advanced_settings_proxy_headers_subtitle": "Дефиниране на прокси хедъри, които Immich трябва да изпраща с всяка мрежова заявка",
|
||||||
"advanced_settings_proxy_headers_title": "Прокси хедъри",
|
"advanced_settings_proxy_headers_title": "Прокси хедъри [ЕКСПЕРИМЕНТАЛНО]",
|
||||||
"advanced_settings_readonly_mode_subtitle": "Активира режима \"само за четене\", при който снимките могат да бъдат разглеждани, но неща като избор на няколко изображения, споделяне, изтриване са забранени. Активиране/деактивиране на режима само за четене става от картинката-аватар на потребителя от основния екран",
|
"advanced_settings_readonly_mode_subtitle": "Активира режима \"само за четене\", при който снимките могат да бъдат разглеждани, но неща като избор на няколко изображения, споделяне, изтриване са забранени. Активиране/деактивиране на режима само за четене става от картинката-аватар на потребителя от основния екран",
|
||||||
"advanced_settings_readonly_mode_title": "Режим само за четене",
|
"advanced_settings_readonly_mode_title": "Режим само за четене",
|
||||||
"advanced_settings_self_signed_ssl_subtitle": "Пропуска проверката на SSL-сертификата на сървъра. Изисква се при самоподписани сертификати.",
|
"advanced_settings_self_signed_ssl_subtitle": "Пропуска проверката на SSL-сертификата на сървъра. Изисква се при самоподписани сертификати.",
|
||||||
"advanced_settings_self_signed_ssl_title": "Разреши самоподписани SSL сертификати",
|
"advanced_settings_self_signed_ssl_title": "Разреши самоподписани SSL сертификати [ЕКСПЕРИМЕНТАЛНО]",
|
||||||
"advanced_settings_sync_remote_deletions_subtitle": "Автоматично изтрии или възстанови обект на това устройство, когато действието е извършено през уеб-интерфейса",
|
"advanced_settings_sync_remote_deletions_subtitle": "Автоматично изтрии или възстанови обект на това устройство, когато действието е извършено през уеб-интерфейса",
|
||||||
"advanced_settings_sync_remote_deletions_title": "Синхронизация на дистанционни изтривания [ЕКСПЕРИМЕНТАЛНО]",
|
"advanced_settings_sync_remote_deletions_title": "Синхронизация на дистанционни изтривания [ЕКСПЕРИМЕНТАЛНО]",
|
||||||
"advanced_settings_tile_subtitle": "Разширени потребителски настройки",
|
"advanced_settings_tile_subtitle": "Разширени потребителски настройки",
|
||||||
@@ -414,6 +429,7 @@
|
|||||||
"age_months": "Възраст {months, plural, one {# месец} other {# месеци}}",
|
"age_months": "Възраст {months, plural, one {# месец} other {# месеци}}",
|
||||||
"age_year_months": "Възраст 1 година, {months, plural, one {# месец} other {# месеци}}",
|
"age_year_months": "Възраст 1 година, {months, plural, one {# месец} other {# месеци}}",
|
||||||
"age_years": "{years, plural, other {Година #}}",
|
"age_years": "{years, plural, other {Година #}}",
|
||||||
|
"album": "Албум",
|
||||||
"album_added": "Албумът е добавен",
|
"album_added": "Албумът е добавен",
|
||||||
"album_added_notification_setting_description": "Получавайте известие по имейл, когато бъдете добавени към споделен албум",
|
"album_added_notification_setting_description": "Получавайте известие по имейл, когато бъдете добавени към споделен албум",
|
||||||
"album_cover_updated": "Обложката на албума е актуализирана",
|
"album_cover_updated": "Обложката на албума е актуализирана",
|
||||||
@@ -459,16 +475,21 @@
|
|||||||
"allow_edits": "Позволяване на редакции",
|
"allow_edits": "Позволяване на редакции",
|
||||||
"allow_public_user_to_download": "Позволете на публичен потребител да може да изтегля",
|
"allow_public_user_to_download": "Позволете на публичен потребител да може да изтегля",
|
||||||
"allow_public_user_to_upload": "Позволете на публичния потребител да може да качва",
|
"allow_public_user_to_upload": "Позволете на публичния потребител да може да качва",
|
||||||
|
"allowed": "Разрешено",
|
||||||
"alt_text_qr_code": "Изображение на QR код",
|
"alt_text_qr_code": "Изображение на QR код",
|
||||||
"anti_clockwise": "Обратно на часовниковата стрелка",
|
"anti_clockwise": "Обратно на часовниковата стрелка",
|
||||||
"api_key": "API ключ",
|
"api_key": "API ключ",
|
||||||
"api_key_description": "Тази стойност ще бъде показана само веднъж. Моля, не забравяйте да го копирате, преди да затворите прозореца.",
|
"api_key_description": "Тази стойност ще бъде показана само веднъж. Моля, не забравяйте да го копирате, преди да затворите прозореца.",
|
||||||
"api_key_empty": "Името на вашия API ключ не трябва да е празно",
|
"api_key_empty": "Името на вашия API ключ не трябва да е празно",
|
||||||
"api_keys": "API ключове",
|
"api_keys": "API ключове",
|
||||||
|
"app_architecture_variant": "Вариант (Ахитектура)",
|
||||||
"app_bar_signout_dialog_content": "Наистина ли искате да излезете?",
|
"app_bar_signout_dialog_content": "Наистина ли искате да излезете?",
|
||||||
"app_bar_signout_dialog_ok": "Да",
|
"app_bar_signout_dialog_ok": "Да",
|
||||||
"app_bar_signout_dialog_title": "Излез от профила",
|
"app_bar_signout_dialog_title": "Излез от профила",
|
||||||
|
"app_download_links": "Линкове за сваляне на приложението",
|
||||||
"app_settings": "Настройки ма приложението",
|
"app_settings": "Настройки ма приложението",
|
||||||
|
"app_stores": "Магазини за приложения",
|
||||||
|
"app_update_available": "Налична е нова версия",
|
||||||
"appears_in": "Излиза в",
|
"appears_in": "Излиза в",
|
||||||
"apply_count": "Приложи ({count, number})",
|
"apply_count": "Приложи ({count, number})",
|
||||||
"archive": "Архив",
|
"archive": "Архив",
|
||||||
@@ -552,6 +573,7 @@
|
|||||||
"backup_albums_sync": "Синхронизиране на архивите",
|
"backup_albums_sync": "Синхронизиране на архивите",
|
||||||
"backup_all": "Всичко",
|
"backup_all": "Всичко",
|
||||||
"backup_background_service_backup_failed_message": "Неуспешно архивиране. Нов опит…",
|
"backup_background_service_backup_failed_message": "Неуспешно архивиране. Нов опит…",
|
||||||
|
"backup_background_service_complete_notification": "Завърши архивирането на обектите",
|
||||||
"backup_background_service_connection_failed_message": "Неуспешно свързване към сървъра. Нов опит…",
|
"backup_background_service_connection_failed_message": "Неуспешно свързване към сървъра. Нов опит…",
|
||||||
"backup_background_service_current_upload_notification": "Зареждам {filename}",
|
"backup_background_service_current_upload_notification": "Зареждам {filename}",
|
||||||
"backup_background_service_default_notification": "Търсене на нови обекти…",
|
"backup_background_service_default_notification": "Търсене на нови обекти…",
|
||||||
@@ -661,6 +683,8 @@
|
|||||||
"change_password_description": "Това е или първият път, когато влизате в системата, или е направена заявка за промяна на паролата ви. Моля, въведете новата парола по-долу.",
|
"change_password_description": "Това е или първият път, когато влизате в системата, или е направена заявка за промяна на паролата ви. Моля, въведете новата парола по-долу.",
|
||||||
"change_password_form_confirm_password": "Потвърди паролата",
|
"change_password_form_confirm_password": "Потвърди паролата",
|
||||||
"change_password_form_description": "Здравейте {name},\n\nТова или е първото ви вписване в системата или има подадена заявка за смяна на паролата. Моля, въведете нова парола в полето по-долу.",
|
"change_password_form_description": "Здравейте {name},\n\nТова или е първото ви вписване в системата или има подадена заявка за смяна на паролата. Моля, въведете нова парола в полето по-долу.",
|
||||||
|
"change_password_form_log_out": "Излизане от профила на всички други устройства",
|
||||||
|
"change_password_form_log_out_description": "Препоръчваме да се излезе от профила на всички други устройства",
|
||||||
"change_password_form_new_password": "Нова парола",
|
"change_password_form_new_password": "Нова парола",
|
||||||
"change_password_form_password_mismatch": "Паролите не съвпадат",
|
"change_password_form_password_mismatch": "Паролите не съвпадат",
|
||||||
"change_password_form_reenter_new_password": "Повтори новата парола",
|
"change_password_form_reenter_new_password": "Повтори новата парола",
|
||||||
@@ -687,8 +711,8 @@
|
|||||||
"client_cert_import_success_msg": "Клиентския сертификат е импортиран",
|
"client_cert_import_success_msg": "Клиентския сертификат е импортиран",
|
||||||
"client_cert_invalid_msg": "Невалиден сертификат или грешна парола",
|
"client_cert_invalid_msg": "Невалиден сертификат или грешна парола",
|
||||||
"client_cert_remove_msg": "Клиентския сертификат е премахнат",
|
"client_cert_remove_msg": "Клиентския сертификат е премахнат",
|
||||||
"client_cert_subtitle": "Поддържа се само формат PKCS12 (.p12, .pfx). Импорт и премахване на сертификат може само преди вписване в системата",
|
"client_cert_subtitle": "Поддържа се само формат PKCS12 (.p12, .pfx). Импорт/премахване на сертификат може само преди вписване в системата",
|
||||||
"client_cert_title": "Клиентски SSL сертификат",
|
"client_cert_title": "Клиентски SSL сертификат [ЕКСПЕРИМЕНТАЛНО]",
|
||||||
"clockwise": "По часовниковата стрелка",
|
"clockwise": "По часовниковата стрелка",
|
||||||
"close": "Затвори",
|
"close": "Затвори",
|
||||||
"collapse": "Свиване",
|
"collapse": "Свиване",
|
||||||
@@ -700,7 +724,6 @@
|
|||||||
"comments_and_likes": "Коментари и харесвания",
|
"comments_and_likes": "Коментари и харесвания",
|
||||||
"comments_are_disabled": "Коментарите са деактивирани",
|
"comments_are_disabled": "Коментарите са деактивирани",
|
||||||
"common_create_new_album": "Създай нов албум",
|
"common_create_new_album": "Създай нов албум",
|
||||||
"common_server_error": "Моля, проверете мрежовата връзка, убедете се, че сървъра е достъпен и версиите на сървъра и приложението са съвместими.",
|
|
||||||
"completed": "Завършено",
|
"completed": "Завършено",
|
||||||
"confirm": "Потвърди",
|
"confirm": "Потвърди",
|
||||||
"confirm_admin_password": "Потвърждаване на паролата на администратора",
|
"confirm_admin_password": "Потвърждаване на паролата на администратора",
|
||||||
@@ -739,6 +762,7 @@
|
|||||||
"create": "Създай",
|
"create": "Създай",
|
||||||
"create_album": "Създай албум",
|
"create_album": "Създай албум",
|
||||||
"create_album_page_untitled": "Без заглавие",
|
"create_album_page_untitled": "Без заглавие",
|
||||||
|
"create_api_key": "Създайте API ключ",
|
||||||
"create_library": "Създай библиотека",
|
"create_library": "Създай библиотека",
|
||||||
"create_link": "Създай линк",
|
"create_link": "Създай линк",
|
||||||
"create_link_to_share": "Създаване на линк за споделяне",
|
"create_link_to_share": "Създаване на линк за споделяне",
|
||||||
@@ -768,6 +792,7 @@
|
|||||||
"daily_title_text_date_year": "E, dd MMM yyyy",
|
"daily_title_text_date_year": "E, dd MMM yyyy",
|
||||||
"dark": "Тъмен",
|
"dark": "Тъмен",
|
||||||
"dark_theme": "Тъмна тема",
|
"dark_theme": "Тъмна тема",
|
||||||
|
"date": "Дата",
|
||||||
"date_after": "Дата след",
|
"date_after": "Дата след",
|
||||||
"date_and_time": "Дата и час",
|
"date_and_time": "Дата и час",
|
||||||
"date_before": "Дата преди",
|
"date_before": "Дата преди",
|
||||||
@@ -870,8 +895,6 @@
|
|||||||
"edit_description_prompt": "Моля, избери ново описание:",
|
"edit_description_prompt": "Моля, избери ново описание:",
|
||||||
"edit_exclusion_pattern": "Редактиране на шаблон за изключване",
|
"edit_exclusion_pattern": "Редактиране на шаблон за изключване",
|
||||||
"edit_faces": "Редактиране на лица",
|
"edit_faces": "Редактиране на лица",
|
||||||
"edit_import_path": "Редактиране на пътя за импортиране",
|
|
||||||
"edit_import_paths": "Редактиране на пътища за импортиране",
|
|
||||||
"edit_key": "Редактиране на ключ",
|
"edit_key": "Редактиране на ключ",
|
||||||
"edit_link": "Редактиране на линк",
|
"edit_link": "Редактиране на линк",
|
||||||
"edit_location": "Редактиране на местоположението",
|
"edit_location": "Редактиране на местоположението",
|
||||||
@@ -882,7 +905,6 @@
|
|||||||
"edit_tag": "Редактирай таг",
|
"edit_tag": "Редактирай таг",
|
||||||
"edit_title": "Редактиране на заглавието",
|
"edit_title": "Редактиране на заглавието",
|
||||||
"edit_user": "Редактиране на потребител",
|
"edit_user": "Редактиране на потребител",
|
||||||
"edited": "Редактирано",
|
|
||||||
"editor": "Редактор",
|
"editor": "Редактор",
|
||||||
"editor_close_without_save_prompt": "Промените няма да бъдат запазени",
|
"editor_close_without_save_prompt": "Промените няма да бъдат запазени",
|
||||||
"editor_close_without_save_title": "Затваряне на редактора?",
|
"editor_close_without_save_title": "Затваряне на редактора?",
|
||||||
@@ -944,7 +966,6 @@
|
|||||||
"failed_to_stack_assets": "Неуспешно подреждане на обекти",
|
"failed_to_stack_assets": "Неуспешно подреждане на обекти",
|
||||||
"failed_to_unstack_assets": "Неуспешно премахване на подредбата на обекти",
|
"failed_to_unstack_assets": "Неуспешно премахване на подредбата на обекти",
|
||||||
"failed_to_update_notification_status": "Неуспешно обновяване на състоянието на известията",
|
"failed_to_update_notification_status": "Неуспешно обновяване на състоянието на известията",
|
||||||
"import_path_already_exists": "Този път за импортиране вече съществува.",
|
|
||||||
"incorrect_email_or_password": "Неправилен имейл или парола",
|
"incorrect_email_or_password": "Неправилен имейл или парола",
|
||||||
"paths_validation_failed": "{paths, plural, one {# път} other {# пътища}} не преминаха валидация",
|
"paths_validation_failed": "{paths, plural, one {# път} other {# пътища}} не преминаха валидация",
|
||||||
"profile_picture_transparent_pixels": "Профилните снимки не могат да имат прозрачни пиксели. Моля, увеличете и/или преместете изображението.",
|
"profile_picture_transparent_pixels": "Профилните снимки не могат да имат прозрачни пиксели. Моля, увеличете и/или преместете изображението.",
|
||||||
@@ -954,7 +975,6 @@
|
|||||||
"unable_to_add_assets_to_shared_link": "Неуспешно добавяне на обекти в споделен линк",
|
"unable_to_add_assets_to_shared_link": "Неуспешно добавяне на обекти в споделен линк",
|
||||||
"unable_to_add_comment": "Неуспешно добавяне на коментар",
|
"unable_to_add_comment": "Неуспешно добавяне на коментар",
|
||||||
"unable_to_add_exclusion_pattern": "Неуспешно добавяне на шаблон за изключение",
|
"unable_to_add_exclusion_pattern": "Неуспешно добавяне на шаблон за изключение",
|
||||||
"unable_to_add_import_path": "Неуспешно добавяне на път за импортиране",
|
|
||||||
"unable_to_add_partners": "Неуспешно добавяне на партньори",
|
"unable_to_add_partners": "Неуспешно добавяне на партньори",
|
||||||
"unable_to_add_remove_archive": "Неуспешно {archived, select, true {премахване на обект от} other {добавяне на обект в}} архива",
|
"unable_to_add_remove_archive": "Неуспешно {archived, select, true {премахване на обект от} other {добавяне на обект в}} архива",
|
||||||
"unable_to_add_remove_favorites": "Неуспешно {favorite, select, true {добавяне на обект в} other {премахване на обект от}} любими",
|
"unable_to_add_remove_favorites": "Неуспешно {favorite, select, true {добавяне на обект в} other {премахване на обект от}} любими",
|
||||||
@@ -977,12 +997,10 @@
|
|||||||
"unable_to_delete_asset": "Не може да изтрие файла",
|
"unable_to_delete_asset": "Не може да изтрие файла",
|
||||||
"unable_to_delete_assets": "Грешка при изтриване на файлове",
|
"unable_to_delete_assets": "Грешка при изтриване на файлове",
|
||||||
"unable_to_delete_exclusion_pattern": "Не може да изтрие шаблон за изключване",
|
"unable_to_delete_exclusion_pattern": "Не може да изтрие шаблон за изключване",
|
||||||
"unable_to_delete_import_path": "Пътят за импортиране не може да се изтрие",
|
|
||||||
"unable_to_delete_shared_link": "Споделената връзка не може да се изтрие",
|
"unable_to_delete_shared_link": "Споделената връзка не може да се изтрие",
|
||||||
"unable_to_delete_user": "Не може да изтрие потребител",
|
"unable_to_delete_user": "Не може да изтрие потребител",
|
||||||
"unable_to_download_files": "Не могат да се изтеглят файловете",
|
"unable_to_download_files": "Не могат да се изтеглят файловете",
|
||||||
"unable_to_edit_exclusion_pattern": "Не може да се редактира шаблон за изключване",
|
"unable_to_edit_exclusion_pattern": "Не може да се редактира шаблон за изключване",
|
||||||
"unable_to_edit_import_path": "Пътят за импортиране не може да се редактира",
|
|
||||||
"unable_to_empty_trash": "Неуспешно изпразване на кошчето",
|
"unable_to_empty_trash": "Неуспешно изпразване на кошчето",
|
||||||
"unable_to_enter_fullscreen": "Не може да се отвори в цял екран",
|
"unable_to_enter_fullscreen": "Не може да се отвори в цял екран",
|
||||||
"unable_to_exit_fullscreen": "Не може да излезе от цял екран",
|
"unable_to_exit_fullscreen": "Не може да излезе от цял екран",
|
||||||
@@ -1038,6 +1056,7 @@
|
|||||||
"exif_bottom_sheet_description_error": "Неуспешно обновяване на описание",
|
"exif_bottom_sheet_description_error": "Неуспешно обновяване на описание",
|
||||||
"exif_bottom_sheet_details": "ПОДРОБНОСТИ",
|
"exif_bottom_sheet_details": "ПОДРОБНОСТИ",
|
||||||
"exif_bottom_sheet_location": "МЯСТО",
|
"exif_bottom_sheet_location": "МЯСТО",
|
||||||
|
"exif_bottom_sheet_no_description": "Няма описание",
|
||||||
"exif_bottom_sheet_people": "ХОРА",
|
"exif_bottom_sheet_people": "ХОРА",
|
||||||
"exif_bottom_sheet_person_add_person": "Добави име",
|
"exif_bottom_sheet_person_add_person": "Добави име",
|
||||||
"exit_slideshow": "Изход от слайдшоуто",
|
"exit_slideshow": "Изход от слайдшоуто",
|
||||||
@@ -1076,6 +1095,7 @@
|
|||||||
"features_setting_description": "Управление на функциите на приложението",
|
"features_setting_description": "Управление на функциите на приложението",
|
||||||
"file_name": "Име на файла",
|
"file_name": "Име на файла",
|
||||||
"file_name_or_extension": "Име на файл или разширение",
|
"file_name_or_extension": "Име на файл или разширение",
|
||||||
|
"file_size": "Размер на файла",
|
||||||
"filename": "Име на файл",
|
"filename": "Име на файл",
|
||||||
"filetype": "Тип на файл",
|
"filetype": "Тип на файл",
|
||||||
"filter": "Филтър",
|
"filter": "Филтър",
|
||||||
@@ -1119,7 +1139,6 @@
|
|||||||
"header_settings_field_validator_msg": "Недопустимо е да няма стойност",
|
"header_settings_field_validator_msg": "Недопустимо е да няма стойност",
|
||||||
"header_settings_header_name_input": "Име на заглавието",
|
"header_settings_header_name_input": "Име на заглавието",
|
||||||
"header_settings_header_value_input": "Стойност на заглавието",
|
"header_settings_header_value_input": "Стойност на заглавието",
|
||||||
"headers_settings_tile_subtitle": "Дефиниране на прокси заглавия, които приложението трябва да изпраща с всяка мрежова заявка",
|
|
||||||
"headers_settings_tile_title": "Потребителски прокси заглавия",
|
"headers_settings_tile_title": "Потребителски прокси заглавия",
|
||||||
"hi_user": "Здравей, {name} {email}",
|
"hi_user": "Здравей, {name} {email}",
|
||||||
"hide_all_people": "Скрий всички хора",
|
"hide_all_people": "Скрий всички хора",
|
||||||
@@ -1172,6 +1191,8 @@
|
|||||||
"import_path": "Път за импортиране",
|
"import_path": "Път за импортиране",
|
||||||
"in_albums": "В {count, plural, one {# албум} other {# албума}}",
|
"in_albums": "В {count, plural, one {# албум} other {# албума}}",
|
||||||
"in_archive": "В архив",
|
"in_archive": "В архив",
|
||||||
|
"in_year": "{year} г.",
|
||||||
|
"in_year_selector": "През",
|
||||||
"include_archived": "Включване на архивирани",
|
"include_archived": "Включване на архивирани",
|
||||||
"include_shared_albums": "Включване на споделени албуми",
|
"include_shared_albums": "Включване на споделени албуми",
|
||||||
"include_shared_partner_assets": "Включване на споделените с партньор елементи",
|
"include_shared_partner_assets": "Включване на споделените с партньор елементи",
|
||||||
@@ -1208,6 +1229,7 @@
|
|||||||
"language_setting_description": "Изберете предпочитан език",
|
"language_setting_description": "Изберете предпочитан език",
|
||||||
"large_files": "Големи файлове",
|
"large_files": "Големи файлове",
|
||||||
"last": "Последен",
|
"last": "Последен",
|
||||||
|
"last_months": "{count, plural, one {Последния месец} other {Последните # месеца}}",
|
||||||
"last_seen": "Последно видяно",
|
"last_seen": "Последно видяно",
|
||||||
"latest_version": "Последна версия",
|
"latest_version": "Последна версия",
|
||||||
"latitude": "Ширина",
|
"latitude": "Ширина",
|
||||||
@@ -1240,6 +1262,7 @@
|
|||||||
"local_media_summary": "Обобщение на локалните файлове",
|
"local_media_summary": "Обобщение на локалните файлове",
|
||||||
"local_network": "Локална мрежа",
|
"local_network": "Локална мрежа",
|
||||||
"local_network_sheet_info": "Приложението ще се свърже със сървъра на този URL, когато устройството е свързано към зададената Wi-Fi мрежа",
|
"local_network_sheet_info": "Приложението ще се свърже със сървъра на този URL, когато устройството е свързано към зададената Wi-Fi мрежа",
|
||||||
|
"location": "Място",
|
||||||
"location_permission": "Разрешение за местоположение",
|
"location_permission": "Разрешение за местоположение",
|
||||||
"location_permission_content": "За да работи функцията автоматично превключване, Immich се нуждае от разрешение за точно местоположение, за да може да чете името на текущата Wi-Fi мрежа",
|
"location_permission_content": "За да работи функцията автоматично превключване, Immich се нуждае от разрешение за точно местоположение, за да може да чете името на текущата Wi-Fi мрежа",
|
||||||
"location_picker_choose_on_map": "Избери на карта",
|
"location_picker_choose_on_map": "Избери на карта",
|
||||||
@@ -1289,6 +1312,10 @@
|
|||||||
"main_menu": "Главно меню",
|
"main_menu": "Главно меню",
|
||||||
"make": "Марка",
|
"make": "Марка",
|
||||||
"manage_geolocation": "Управление на местоположенията",
|
"manage_geolocation": "Управление на местоположенията",
|
||||||
|
"manage_media_access_rationale": "Това разрешение е необходимо за правилно преместване на обекти в кошчето и за възстановяване от там.",
|
||||||
|
"manage_media_access_settings": "Отвори Настройки",
|
||||||
|
"manage_media_access_subtitle": "Разрешете приложението Immich да управлява и мести медийни файлове.",
|
||||||
|
"manage_media_access_title": "Управление на медийни файлове",
|
||||||
"manage_shared_links": "Управление на споделени връзки",
|
"manage_shared_links": "Управление на споделени връзки",
|
||||||
"manage_sharing_with_partners": "Управление на споделянето с партньори",
|
"manage_sharing_with_partners": "Управление на споделянето с партньори",
|
||||||
"manage_the_app_settings": "Управление на настройките на приложението",
|
"manage_the_app_settings": "Управление на настройките на приложението",
|
||||||
@@ -1344,12 +1371,15 @@
|
|||||||
"minute": "Минута",
|
"minute": "Минута",
|
||||||
"minutes": "Минути",
|
"minutes": "Минути",
|
||||||
"missing": "Липсващи",
|
"missing": "Липсващи",
|
||||||
|
"mobile_app": "Мобилно приложение",
|
||||||
|
"mobile_app_download_onboarding_note": "Свалете мобилното приложение Immich с някоя от следните опции",
|
||||||
"model": "Модел",
|
"model": "Модел",
|
||||||
"month": "Месец",
|
"month": "Месец",
|
||||||
"monthly_title_text_date_format": "MMMM г",
|
"monthly_title_text_date_format": "MMMM г",
|
||||||
"more": "Още",
|
"more": "Още",
|
||||||
"move": "Премести",
|
"move": "Премести",
|
||||||
"move_off_locked_folder": "Извади от заключената папка",
|
"move_off_locked_folder": "Извади от заключената папка",
|
||||||
|
"move_to": "Премести към",
|
||||||
"move_to_lock_folder_action_prompt": "{count} са добавени в заключената папка",
|
"move_to_lock_folder_action_prompt": "{count} са добавени в заключената папка",
|
||||||
"move_to_locked_folder": "Премести в заключена папка",
|
"move_to_locked_folder": "Премести в заключена папка",
|
||||||
"move_to_locked_folder_confirmation": "Тези снимки и видеа ще бъдат изтрити от всички албуми и ще са достъпни само в заключената папка",
|
"move_to_locked_folder_confirmation": "Тези снимки и видеа ще бъдат изтрити от всички албуми и ще са достъпни само в заключената папка",
|
||||||
@@ -1362,6 +1392,8 @@
|
|||||||
"my_albums": "Мои албуми",
|
"my_albums": "Мои албуми",
|
||||||
"name": "Име",
|
"name": "Име",
|
||||||
"name_or_nickname": "Име или прякор",
|
"name_or_nickname": "Име или прякор",
|
||||||
|
"navigate": "Придвижване",
|
||||||
|
"navigate_to_time": "Придвижване до момент във времето",
|
||||||
"network_requirement_photos_upload": "Използвай мобилни данни за архивиране на снимки",
|
"network_requirement_photos_upload": "Използвай мобилни данни за архивиране на снимки",
|
||||||
"network_requirement_videos_upload": "Използвай мобилни данни за архивиране на видео",
|
"network_requirement_videos_upload": "Използвай мобилни данни за архивиране на видео",
|
||||||
"network_requirements": "Изисквания към мрежата",
|
"network_requirements": "Изисквания към мрежата",
|
||||||
@@ -1371,11 +1403,13 @@
|
|||||||
"never": "Никога",
|
"never": "Никога",
|
||||||
"new_album": "Нов Албум",
|
"new_album": "Нов Албум",
|
||||||
"new_api_key": "Нов API ключ",
|
"new_api_key": "Нов API ключ",
|
||||||
|
"new_date_range": "Нов период от време",
|
||||||
"new_password": "Нова парола",
|
"new_password": "Нова парола",
|
||||||
"new_person": "Нов човек",
|
"new_person": "Нов човек",
|
||||||
"new_pin_code": "Нов PIN код",
|
"new_pin_code": "Нов PIN код",
|
||||||
"new_pin_code_subtitle": "Това е първи достъп до заключена папка. Създайте PIN код за защитен достъп до тази страница",
|
"new_pin_code_subtitle": "Това е първи достъп до заключена папка. Създайте PIN код за защитен достъп до тази страница",
|
||||||
"new_timeline": "Нова времева линия",
|
"new_timeline": "Нова времева линия",
|
||||||
|
"new_update": "Ново обновление",
|
||||||
"new_user_created": "Създаден нов потребител",
|
"new_user_created": "Създаден нов потребител",
|
||||||
"new_version_available": "НАЛИЧНА НОВА ВЕРСИЯ",
|
"new_version_available": "НАЛИЧНА НОВА ВЕРСИЯ",
|
||||||
"newest_first": "Най-новите първи",
|
"newest_first": "Най-новите първи",
|
||||||
@@ -1391,6 +1425,7 @@
|
|||||||
"no_cast_devices_found": "Няма намерени устройства за предаване",
|
"no_cast_devices_found": "Няма намерени устройства за предаване",
|
||||||
"no_checksum_local": "Липсват контролни суми - не може да се получат локални обекти",
|
"no_checksum_local": "Липсват контролни суми - не може да се получат локални обекти",
|
||||||
"no_checksum_remote": "Липсват контролни суми - не може да се получат обекти от сървъра",
|
"no_checksum_remote": "Липсват контролни суми - не може да се получат обекти от сървъра",
|
||||||
|
"no_devices": "Няма оторизирани устройства",
|
||||||
"no_duplicates_found": "Не бяха открити дубликати.",
|
"no_duplicates_found": "Не бяха открити дубликати.",
|
||||||
"no_exif_info_available": "Няма exif информация",
|
"no_exif_info_available": "Няма exif информация",
|
||||||
"no_explore_results_message": "Качете още снимки, за да разгледате колекцията си.",
|
"no_explore_results_message": "Качете още снимки, за да разгледате колекцията си.",
|
||||||
@@ -1407,6 +1442,7 @@
|
|||||||
"no_results_description": "Опитайте със синоним или по-обща ключова дума",
|
"no_results_description": "Опитайте със синоним или по-обща ключова дума",
|
||||||
"no_shared_albums_message": "Създайте албум, за да споделяте снимки и видеоклипове с хората в мрежата си",
|
"no_shared_albums_message": "Създайте албум, за да споделяте снимки и видеоклипове с хората в мрежата си",
|
||||||
"no_uploads_in_progress": "Няма качване в момента",
|
"no_uploads_in_progress": "Няма качване в момента",
|
||||||
|
"not_allowed": "Не е разрешено",
|
||||||
"not_available": "Неналично",
|
"not_available": "Неналично",
|
||||||
"not_in_any_album": "Не е в никой албум",
|
"not_in_any_album": "Не е в никой албум",
|
||||||
"not_selected": "Не е избрано",
|
"not_selected": "Не е избрано",
|
||||||
@@ -1421,6 +1457,9 @@
|
|||||||
"notifications": "Известия",
|
"notifications": "Известия",
|
||||||
"notifications_setting_description": "Управление на известията",
|
"notifications_setting_description": "Управление на известията",
|
||||||
"oauth": "OAuth",
|
"oauth": "OAuth",
|
||||||
|
"obtainium_configurator": "Конфигуратор за получаване",
|
||||||
|
"obtainium_configurator_instructions": "Използвайте Obtainium за инсталация и обновяване на приложението за Android директно от GitHub на Immich. Създайте API ключ и изберете вариант за да създадете Obtainium конфигурационен линк",
|
||||||
|
"ocr": "Оптично разпознаване на текст",
|
||||||
"official_immich_resources": "Официална информация за Immich",
|
"official_immich_resources": "Официална информация за Immich",
|
||||||
"offline": "Офлайн",
|
"offline": "Офлайн",
|
||||||
"offset": "Отместване",
|
"offset": "Отместване",
|
||||||
@@ -1514,6 +1553,8 @@
|
|||||||
"photos_count": "{count, plural, one {{count, number} Снимка} other {{count, number} Снимки}}",
|
"photos_count": "{count, plural, one {{count, number} Снимка} other {{count, number} Снимки}}",
|
||||||
"photos_from_previous_years": "Снимки от предходни години",
|
"photos_from_previous_years": "Снимки от предходни години",
|
||||||
"pick_a_location": "Избери локация",
|
"pick_a_location": "Избери локация",
|
||||||
|
"pick_custom_range": "Произволен период",
|
||||||
|
"pick_date_range": "Изберете период",
|
||||||
"pin_code_changed_successfully": "Успешно сменен PIN код",
|
"pin_code_changed_successfully": "Успешно сменен PIN код",
|
||||||
"pin_code_reset_successfully": "Успешно нулиран PIN код",
|
"pin_code_reset_successfully": "Успешно нулиран PIN код",
|
||||||
"pin_code_setup_successfully": "Успешно зададен PIN код",
|
"pin_code_setup_successfully": "Успешно зададен PIN код",
|
||||||
@@ -1525,6 +1566,9 @@
|
|||||||
"play_memories": "Възпроизвеждане на спомени",
|
"play_memories": "Възпроизвеждане на спомени",
|
||||||
"play_motion_photo": "Възпроизведи Motion Photo",
|
"play_motion_photo": "Възпроизведи Motion Photo",
|
||||||
"play_or_pause_video": "Възпроизвеждане или пауза на видео",
|
"play_or_pause_video": "Възпроизвеждане или пауза на видео",
|
||||||
|
"play_original_video": "Пусни оригиналното видео",
|
||||||
|
"play_original_video_setting_description": "Предпочитане на показване на оригиналното видео, вместо транскодирани. Ако формата на оригиналния файл не се поддържа, възпроизвеждането може да бъде неправилно.",
|
||||||
|
"play_transcoded_video": "Покажи транскодирано видео",
|
||||||
"please_auth_to_access": "Моля, удостовери за достъп",
|
"please_auth_to_access": "Моля, удостовери за достъп",
|
||||||
"port": "Порт",
|
"port": "Порт",
|
||||||
"preferences_settings_subtitle": "Управление на предпочитанията на приложението",
|
"preferences_settings_subtitle": "Управление на предпочитанията на приложението",
|
||||||
@@ -1542,13 +1586,9 @@
|
|||||||
"privacy": "Поверителност",
|
"privacy": "Поверителност",
|
||||||
"profile": "Профил",
|
"profile": "Профил",
|
||||||
"profile_drawer_app_logs": "Дневник",
|
"profile_drawer_app_logs": "Дневник",
|
||||||
"profile_drawer_client_out_of_date_major": "Мобилното приложение е остаряло. Моля, актуализирайте до най-новата основна версия.",
|
|
||||||
"profile_drawer_client_out_of_date_minor": "Мобилното приложение е остаряло. Моля, актуализирайте до най-новата версия.",
|
|
||||||
"profile_drawer_client_server_up_to_date": "Клиента и сървъра са обновени",
|
"profile_drawer_client_server_up_to_date": "Клиента и сървъра са обновени",
|
||||||
"profile_drawer_github": "GitHub",
|
"profile_drawer_github": "GitHub",
|
||||||
"profile_drawer_readonly_mode": "Режима само за четене е активиран. С дълго натискане върху картиката-аватар на потребителя ще деактивирате само за четене.",
|
"profile_drawer_readonly_mode": "Режима само за четене е активиран. С дълго натискане върху картиката-аватар на потребителя ще деактивирате само за четене.",
|
||||||
"profile_drawer_server_out_of_date_major": "Версията на сървъра е остаряла. Моля, актуализирайте поне до последната главна версия.",
|
|
||||||
"profile_drawer_server_out_of_date_minor": "Версията на сървъра е остаряла. Моля, актуализирайте до последната версия.",
|
|
||||||
"profile_image_of_user": "Профилна снимка на {user}",
|
"profile_image_of_user": "Профилна снимка на {user}",
|
||||||
"profile_picture_set": "Профилната снимка е сложена.",
|
"profile_picture_set": "Профилната снимка е сложена.",
|
||||||
"public_album": "Публичен албум",
|
"public_album": "Публичен албум",
|
||||||
@@ -1665,6 +1705,7 @@
|
|||||||
"reset_sqlite_confirmation": "Наистина ли искате да нулирате базата данни SQLite? Ще трябва да излезете от системата и да се впишете отново за нова синхронизация на данните",
|
"reset_sqlite_confirmation": "Наистина ли искате да нулирате базата данни SQLite? Ще трябва да излезете от системата и да се впишете отново за нова синхронизация на данните",
|
||||||
"reset_sqlite_success": "Успешно нулиране на базата данни SQLite",
|
"reset_sqlite_success": "Успешно нулиране на базата данни SQLite",
|
||||||
"reset_to_default": "Връщане на фабрични настройки",
|
"reset_to_default": "Връщане на фабрични настройки",
|
||||||
|
"resolution": "Резолюция",
|
||||||
"resolve_duplicates": "Реши дубликатите",
|
"resolve_duplicates": "Реши дубликатите",
|
||||||
"resolved_all_duplicates": "Всички дубликати са решени",
|
"resolved_all_duplicates": "Всички дубликати са решени",
|
||||||
"restore": "Възстановяване",
|
"restore": "Възстановяване",
|
||||||
@@ -1683,6 +1724,7 @@
|
|||||||
"running": "Изпълняване",
|
"running": "Изпълняване",
|
||||||
"save": "Запази",
|
"save": "Запази",
|
||||||
"save_to_gallery": "Запази в галерията",
|
"save_to_gallery": "Запази в галерията",
|
||||||
|
"saved": "Записано",
|
||||||
"saved_api_key": "Запазен API Key",
|
"saved_api_key": "Запазен API Key",
|
||||||
"saved_profile": "Запазен профил",
|
"saved_profile": "Запазен профил",
|
||||||
"saved_settings": "Запазени настройки",
|
"saved_settings": "Запазени настройки",
|
||||||
@@ -1699,6 +1741,9 @@
|
|||||||
"search_by_description_example": "Разходка в Сапа",
|
"search_by_description_example": "Разходка в Сапа",
|
||||||
"search_by_filename": "Търси по име на файла или разширение",
|
"search_by_filename": "Търси по име на файла или разширение",
|
||||||
"search_by_filename_example": "например IMG_1234.JPG или PNG",
|
"search_by_filename_example": "например IMG_1234.JPG или PNG",
|
||||||
|
"search_by_ocr": "Търсене на текст",
|
||||||
|
"search_by_ocr_example": "Lattе",
|
||||||
|
"search_camera_lens_model": "Търсене на модел на обектива...",
|
||||||
"search_camera_make": "Търси производител на камерата...",
|
"search_camera_make": "Търси производител на камерата...",
|
||||||
"search_camera_model": "Търси модел на камерата...",
|
"search_camera_model": "Търси модел на камерата...",
|
||||||
"search_city": "Търси град...",
|
"search_city": "Търси град...",
|
||||||
@@ -1715,6 +1760,7 @@
|
|||||||
"search_filter_location_title": "Избери място",
|
"search_filter_location_title": "Избери място",
|
||||||
"search_filter_media_type": "Тип на файла",
|
"search_filter_media_type": "Тип на файла",
|
||||||
"search_filter_media_type_title": "Избери тип на файла",
|
"search_filter_media_type_title": "Избери тип на файла",
|
||||||
|
"search_filter_ocr": "Търсене нa текст",
|
||||||
"search_filter_people_title": "Избери хора",
|
"search_filter_people_title": "Избери хора",
|
||||||
"search_for": "Търси за",
|
"search_for": "Търси за",
|
||||||
"search_for_existing_person": "Търси съществуващ човек",
|
"search_for_existing_person": "Търси съществуващ човек",
|
||||||
@@ -1777,6 +1823,7 @@
|
|||||||
"server_online": "Сървър онлайн",
|
"server_online": "Сървър онлайн",
|
||||||
"server_privacy": "Поверителност на сървъра",
|
"server_privacy": "Поверителност на сървъра",
|
||||||
"server_stats": "Статус на сървъра",
|
"server_stats": "Статус на сървъра",
|
||||||
|
"server_update_available": "Налична е нова версия за сървъра",
|
||||||
"server_version": "Версия на сървъра",
|
"server_version": "Версия на сървъра",
|
||||||
"set": "Задай",
|
"set": "Задай",
|
||||||
"set_as_album_cover": "Задаване като обложка на албум",
|
"set_as_album_cover": "Задаване като обложка на албум",
|
||||||
@@ -1805,6 +1852,8 @@
|
|||||||
"setting_notifications_subtitle": "Настройка на известията",
|
"setting_notifications_subtitle": "Настройка на известията",
|
||||||
"setting_notifications_total_progress_subtitle": "Общ напредък на зареждане (готово/всички обекти)",
|
"setting_notifications_total_progress_subtitle": "Общ напредък на зареждане (готово/всички обекти)",
|
||||||
"setting_notifications_total_progress_title": "Показване на общия напредък на архивиране във фонов режим",
|
"setting_notifications_total_progress_title": "Показване на общия напредък на архивиране във фонов режим",
|
||||||
|
"setting_video_viewer_auto_play_subtitle": "Автоматично започни възпроизвеждане на видео при отваряне",
|
||||||
|
"setting_video_viewer_auto_play_title": "Автоматично възпроизвеждане на видео",
|
||||||
"setting_video_viewer_looping_title": "Циклично",
|
"setting_video_viewer_looping_title": "Циклично",
|
||||||
"setting_video_viewer_original_video_subtitle": "При показване на видео от сървъра показвай оригиналния файл, дори и да има транскодирана версия. Може да използва буфериране. Локално наличните видеа се показват винаги в оригинал, независимо от тази настройка.",
|
"setting_video_viewer_original_video_subtitle": "При показване на видео от сървъра показвай оригиналния файл, дори и да има транскодирана версия. Може да използва буфериране. Локално наличните видеа се показват винаги в оригинал, независимо от тази настройка.",
|
||||||
"setting_video_viewer_original_video_title": "Само оригинално видео",
|
"setting_video_viewer_original_video_title": "Само оригинално видео",
|
||||||
@@ -1968,7 +2017,7 @@
|
|||||||
"template": "Шаблон",
|
"template": "Шаблон",
|
||||||
"theme": "Тема",
|
"theme": "Тема",
|
||||||
"theme_selection": "Избор на тема",
|
"theme_selection": "Избор на тема",
|
||||||
"theme_selection_description": "Автоматично задаване на светла или тъмна тема въз основа на системните предпочитания на вашия браузър",
|
"theme_selection_description": "Автоматично задаване на светла или тъмна тема спрямо системните предпочитания на браузъра ви",
|
||||||
"theme_setting_asset_list_storage_indicator_title": "Показвай индикатор за хранилището в заглавията на обектите",
|
"theme_setting_asset_list_storage_indicator_title": "Показвай индикатор за хранилището в заглавията на обектите",
|
||||||
"theme_setting_asset_list_tiles_per_row_title": "Брой обекти на ред ({count})",
|
"theme_setting_asset_list_tiles_per_row_title": "Брой обекти на ред ({count})",
|
||||||
"theme_setting_colorful_interface_subtitle": "Нанеси основен цвят върху фоновите повърхности.",
|
"theme_setting_colorful_interface_subtitle": "Нанеси основен цвят върху фоновите повърхности.",
|
||||||
@@ -1984,7 +2033,9 @@
|
|||||||
"theme_setting_three_stage_loading_title": "Включи три-степенно зареждане",
|
"theme_setting_three_stage_loading_title": "Включи три-степенно зареждане",
|
||||||
"they_will_be_merged_together": "Те ще бъдат обединени",
|
"they_will_be_merged_together": "Те ще бъдат обединени",
|
||||||
"third_party_resources": "Ресурси от трети страни",
|
"third_party_resources": "Ресурси от трети страни",
|
||||||
|
"time": "Време",
|
||||||
"time_based_memories": "Спомени, базирани на времето",
|
"time_based_memories": "Спомени, базирани на времето",
|
||||||
|
"time_based_memories_duration": "Продължителност в секунди за показване на всяка картина.",
|
||||||
"timeline": "Хронология",
|
"timeline": "Хронология",
|
||||||
"timezone": "Часова зона",
|
"timezone": "Часова зона",
|
||||||
"to_archive": "Архивирай",
|
"to_archive": "Архивирай",
|
||||||
@@ -2016,6 +2067,7 @@
|
|||||||
"troubleshoot": "Отстраняване на проблеми",
|
"troubleshoot": "Отстраняване на проблеми",
|
||||||
"type": "Тип",
|
"type": "Тип",
|
||||||
"unable_to_change_pin_code": "Невъзможна промяна на PIN кода",
|
"unable_to_change_pin_code": "Невъзможна промяна на PIN кода",
|
||||||
|
"unable_to_check_version": "Невъзможна проверка на версията на приложението или сървъра",
|
||||||
"unable_to_setup_pin_code": "Неуспешно задаване на PIN кода",
|
"unable_to_setup_pin_code": "Неуспешно задаване на PIN кода",
|
||||||
"unarchive": "Разархивирай",
|
"unarchive": "Разархивирай",
|
||||||
"unarchive_action_prompt": "{count} са премахнати от Архива",
|
"unarchive_action_prompt": "{count} са премахнати от Архива",
|
||||||
@@ -2124,6 +2176,7 @@
|
|||||||
"welcome": "Добре дошли",
|
"welcome": "Добре дошли",
|
||||||
"welcome_to_immich": "Добре дошли в Immich",
|
"welcome_to_immich": "Добре дошли в Immich",
|
||||||
"wifi_name": "Wi-Fi мрежа",
|
"wifi_name": "Wi-Fi мрежа",
|
||||||
|
"workflow": "Работен процес",
|
||||||
"wrong_pin_code": "Грешен PIN код",
|
"wrong_pin_code": "Грешен PIN код",
|
||||||
"year": "Година",
|
"year": "Година",
|
||||||
"years_ago": "преди {years, plural, one {# година} other {# години}}",
|
"years_ago": "преди {years, plural, one {# година} other {# години}}",
|
||||||
|
|||||||
20
i18n/bi.json
20
i18n/bi.json
@@ -12,12 +12,28 @@
|
|||||||
"add_a_name": "Putem nam blo hem",
|
"add_a_name": "Putem nam blo hem",
|
||||||
"add_a_title": "Putem wan name blo hem",
|
"add_a_title": "Putem wan name blo hem",
|
||||||
"add_exclusion_pattern": "Putem wan paten wae hemi karem aot",
|
"add_exclusion_pattern": "Putem wan paten wae hemi karem aot",
|
||||||
"add_import_path": "Putem wan pat blo import",
|
|
||||||
"add_location": "Putem wan place blo hem",
|
"add_location": "Putem wan place blo hem",
|
||||||
"add_more_users": "Putem mor man",
|
"add_more_users": "Putem mor man",
|
||||||
"readonly_mode_enabled": "Mod blo yu no save janjem i on",
|
"readonly_mode_enabled": "Mod blo yu no save janjem i on",
|
||||||
"reassigned_assets_to_new_person": "Janjem{count, plural, one {# asset} other {# assets}} blo nu man",
|
"reassigned_assets_to_new_person": "Janjem{count, plural, one {# asset} other {# assets}} blo nu man",
|
||||||
"reassing_hint": "janjem ol sumtin yu bin joos i go blo wan man",
|
"reassing_hint": "janjem ol sumtin yu bin joos i go blo wan man",
|
||||||
"recent-albums": "album i no old tu mas",
|
"recent-albums": "album i no old tu mas",
|
||||||
"recent_searches": "lukabout wea i no old tu mas"
|
"recent_searches": "lukabout wea i no old tu mas",
|
||||||
|
"time_based_memories_duration": "hao mus second blo wan wan imij i stap lo scrin.",
|
||||||
|
"timezone": "taemzon",
|
||||||
|
"to_change_password": "janjem pasword",
|
||||||
|
"to_login": "Login",
|
||||||
|
"to_multi_select": "to jusem mani",
|
||||||
|
"to_parent": "go lo parent",
|
||||||
|
"to_select": "to selectem",
|
||||||
|
"to_trash": "toti",
|
||||||
|
"toggle_settings": "sho settings",
|
||||||
|
"total": "Total",
|
||||||
|
"trash": "Toti",
|
||||||
|
"trash_action_prompt": "{count} igo lo plaes lo toti",
|
||||||
|
"trash_all": "Putem ol i go lo toti",
|
||||||
|
"trash_count": "Toti {count, number}",
|
||||||
|
"trash_emptied": "basket blo toti i empti nomo",
|
||||||
|
"trash_no_results_message": "Foto mo video lo basket blo toti yu save lukem lo plaes ia.",
|
||||||
|
"trash_page_delete_all": "Delete oli ol"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
"add_birthday": "একটি জন্মদিন যোগ করুন",
|
"add_birthday": "একটি জন্মদিন যোগ করুন",
|
||||||
"add_endpoint": "এন্ডপয়েন্ট যোগ করুন",
|
"add_endpoint": "এন্ডপয়েন্ট যোগ করুন",
|
||||||
"add_exclusion_pattern": "বহির্ভূতকরণ নমুনা",
|
"add_exclusion_pattern": "বহির্ভূতকরণ নমুনা",
|
||||||
"add_import_path": "ইমপোর্ট করার পাথ যুক্ত করুন",
|
|
||||||
"add_location": "অবস্থান যুক্ত করুন",
|
"add_location": "অবস্থান যুক্ত করুন",
|
||||||
"add_more_users": "আরো ব্যবহারকারী যুক্ত করুন",
|
"add_more_users": "আরো ব্যবহারকারী যুক্ত করুন",
|
||||||
"add_partner": "অংশীদার যোগ করুন",
|
"add_partner": "অংশীদার যোগ করুন",
|
||||||
@@ -111,7 +110,6 @@
|
|||||||
"jobs_failed": "{jobCount, plural, other {# ব্যর্থ}}",
|
"jobs_failed": "{jobCount, plural, other {# ব্যর্থ}}",
|
||||||
"library_created": "লাইব্রেরি তৈরি করা হয়েছেঃ {library}",
|
"library_created": "লাইব্রেরি তৈরি করা হয়েছেঃ {library}",
|
||||||
"library_deleted": "লাইব্রেরি মুছে ফেলা হয়েছে",
|
"library_deleted": "লাইব্রেরি মুছে ফেলা হয়েছে",
|
||||||
"library_import_path_description": "ইম্পোর্ট/যোগ করার জন্য একটি ফোল্ডার নির্দিষ্ট করুন। সাবফোল্ডার সহ এই ফোল্ডারটি ছবি এবং ভিডিওর জন্য স্ক্যান করা হবে।",
|
|
||||||
"library_scanning": "পর্যায়ক্রমিক স্ক্যানিং",
|
"library_scanning": "পর্যায়ক্রমিক স্ক্যানিং",
|
||||||
"library_scanning_description": "পর্যায়ক্রমিক লাইব্রেরি স্ক্যানিং কনফিগার করুন",
|
"library_scanning_description": "পর্যায়ক্রমিক লাইব্রেরি স্ক্যানিং কনফিগার করুন",
|
||||||
"library_scanning_enable_description": "পর্যায়ক্রমিক লাইব্রেরি স্ক্যানিং সক্ষম করুন",
|
"library_scanning_enable_description": "পর্যায়ক্রমিক লাইব্রেরি স্ক্যানিং সক্ষম করুন",
|
||||||
|
|||||||
1
i18n/br.json
Normal file
1
i18n/br.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
98
i18n/ca.json
98
i18n/ca.json
@@ -17,7 +17,6 @@
|
|||||||
"add_birthday": "Afegeix la data de naixement",
|
"add_birthday": "Afegeix la data de naixement",
|
||||||
"add_endpoint": "afegir endpoint",
|
"add_endpoint": "afegir endpoint",
|
||||||
"add_exclusion_pattern": "Afegir un patró d'exclusió",
|
"add_exclusion_pattern": "Afegir un patró d'exclusió",
|
||||||
"add_import_path": "Afegir una ruta d'importació",
|
|
||||||
"add_location": "Afegir la ubicació",
|
"add_location": "Afegir la ubicació",
|
||||||
"add_more_users": "Afegir més usuaris",
|
"add_more_users": "Afegir més usuaris",
|
||||||
"add_partner": "Afegir company/a",
|
"add_partner": "Afegir company/a",
|
||||||
@@ -32,6 +31,7 @@
|
|||||||
"add_to_album_toggle": "Commutar selecció de {album}",
|
"add_to_album_toggle": "Commutar selecció de {album}",
|
||||||
"add_to_albums": "Afegir als àlbums",
|
"add_to_albums": "Afegir als àlbums",
|
||||||
"add_to_albums_count": "Afegir als àlbums ({count})",
|
"add_to_albums_count": "Afegir als àlbums ({count})",
|
||||||
|
"add_to_bottom_bar": "Afegir a",
|
||||||
"add_to_shared_album": "Afegir a un àlbum compartit",
|
"add_to_shared_album": "Afegir a un àlbum compartit",
|
||||||
"add_upload_to_stack": "Afegeix la càrrega a la pila",
|
"add_upload_to_stack": "Afegeix la càrrega a la pila",
|
||||||
"add_url": "Afegir URL",
|
"add_url": "Afegir URL",
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
"confirm_delete_library": "Esteu segurs que voleu eliminar la llibreria {library}?",
|
"confirm_delete_library": "Esteu segurs que voleu eliminar la llibreria {library}?",
|
||||||
"confirm_delete_library_assets": "Esteu segurs que voleu esborrar aquesta llibreria? Això esborrarà {count, plural, one {# contained asset} other {all # contained assets}} d'Immich i no es podrà desfer. Els fitxers romandran al disc.",
|
"confirm_delete_library_assets": "Esteu segurs que voleu esborrar aquesta llibreria? Això esborrarà {count, plural, one {# contained asset} other {all # contained assets}} d'Immich i no es podrà desfer. Els fitxers romandran al disc.",
|
||||||
"confirm_email_below": "Per a confirmar, escriviu \"{email}\" a sota",
|
"confirm_email_below": "Per a confirmar, escriviu \"{email}\" a sota",
|
||||||
"confirm_reprocess_all_faces": "Esteu segur que voleu reprocessar totes les cares? Això també esborrarà la gent que heu anomenat.",
|
"confirm_reprocess_all_faces": "Esteu segurs que voleu reprocessar totes les cares? Això també esborrarà la gent que heu anomenat.",
|
||||||
"confirm_user_password_reset": "Esteu segur que voleu reinicialitzar la contrasenya de l'usuari {user}?",
|
"confirm_user_password_reset": "Esteu segur que voleu reinicialitzar la contrasenya de l'usuari {user}?",
|
||||||
"confirm_user_pin_code_reset": "Esteu segur que voleu restablir el codi PIN de {user}?",
|
"confirm_user_pin_code_reset": "Esteu segur que voleu restablir el codi PIN de {user}?",
|
||||||
"create_job": "Crear tasca",
|
"create_job": "Crear tasca",
|
||||||
@@ -112,7 +112,6 @@
|
|||||||
"jobs_failed": "{jobCount, plural, other {# fallides}}",
|
"jobs_failed": "{jobCount, plural, other {# fallides}}",
|
||||||
"library_created": "Bilbioteca creada: {library}",
|
"library_created": "Bilbioteca creada: {library}",
|
||||||
"library_deleted": "Bilbioteca eliminada",
|
"library_deleted": "Bilbioteca eliminada",
|
||||||
"library_import_path_description": "Especifiqueu una carpeta a importar. Aquesta carpeta, incloses les seves subcarpetes, serà escanejada per cercar-hi imatges i vídeos.",
|
|
||||||
"library_scanning": "Escaneig periòdic",
|
"library_scanning": "Escaneig periòdic",
|
||||||
"library_scanning_description": "Configurar l'escaneig periòdic de bilbioteques",
|
"library_scanning_description": "Configurar l'escaneig periòdic de bilbioteques",
|
||||||
"library_scanning_enable_description": "Habilita l'escaneig periòdic de biblioteques",
|
"library_scanning_enable_description": "Habilita l'escaneig periòdic de biblioteques",
|
||||||
@@ -154,6 +153,18 @@
|
|||||||
"machine_learning_min_detection_score_description": "La puntuació mínima de confiança per detectar una cara és de 0 a 1. Valors més baixos detectaran més cares, però poden donar lloc a falsos positius.",
|
"machine_learning_min_detection_score_description": "La puntuació mínima de confiança per detectar una cara és de 0 a 1. Valors més baixos detectaran més cares, però poden donar lloc a falsos positius.",
|
||||||
"machine_learning_min_recognized_faces": "Nombre mínim de cares reconegudes",
|
"machine_learning_min_recognized_faces": "Nombre mínim de cares reconegudes",
|
||||||
"machine_learning_min_recognized_faces_description": "El nombre mínim de cares reconegudes per crear una persona. Augmentar aquest valor fa que el reconeixement facial sigui més precís, però augmenta la possibilitat que una cara no sigui assignada a una persona.",
|
"machine_learning_min_recognized_faces_description": "El nombre mínim de cares reconegudes per crear una persona. Augmentar aquest valor fa que el reconeixement facial sigui més precís, però augmenta la possibilitat que una cara no sigui assignada a una persona.",
|
||||||
|
"machine_learning_ocr": "OCR",
|
||||||
|
"machine_learning_ocr_description": "Fes servir machine learning per reconèixer text a imatges",
|
||||||
|
"machine_learning_ocr_enabled": "Activar OCR",
|
||||||
|
"machine_learning_ocr_enabled_description": "Si està desactivat, les imatges no seran objecte de reconeixement de text.",
|
||||||
|
"machine_learning_ocr_max_resolution": "Màxima resolució",
|
||||||
|
"machine_learning_ocr_max_resolution_description": "Vista prèvia per sobre d'aquesta resolució serà reescalada per preservar la relació d'aspecte. Resolucions altes són més precises, però triguen més i gasten més memòria.",
|
||||||
|
"machine_learning_ocr_min_detection_score": "Puntuació mínima de detecció",
|
||||||
|
"machine_learning_ocr_min_detection_score_description": "Puntuació de mínima confiança per la detecció del text entre 0-1. Valors baixos detectaran més text pero pot donar falsos positius.",
|
||||||
|
"machine_learning_ocr_min_recognition_score": "Puntuació mínima de reconeixement",
|
||||||
|
"machine_learning_ocr_min_score_recognition_description": "Puntuació de confiança mínima pel reconeixement del text entre 0-1. Valors baixos reconeixen més text però pot donar falsos positius.",
|
||||||
|
"machine_learning_ocr_model": "Model OCR",
|
||||||
|
"machine_learning_ocr_model_description": "Models de servidor són més precisos que els de móbil, pero triguen més a processar i usen més memòria.",
|
||||||
"machine_learning_settings": "Configuració d'aprenentatge automàtic",
|
"machine_learning_settings": "Configuració d'aprenentatge automàtic",
|
||||||
"machine_learning_settings_description": "Gestiona funcions i configuració d'aprenentatge automàtic",
|
"machine_learning_settings_description": "Gestiona funcions i configuració d'aprenentatge automàtic",
|
||||||
"machine_learning_smart_search": "Cerca intel·ligent",
|
"machine_learning_smart_search": "Cerca intel·ligent",
|
||||||
@@ -211,6 +222,8 @@
|
|||||||
"notification_email_ignore_certificate_errors_description": "Ignora els errors de validació de certificat TLS (no recomanat)",
|
"notification_email_ignore_certificate_errors_description": "Ignora els errors de validació de certificat TLS (no recomanat)",
|
||||||
"notification_email_password_description": "Contrasenya per a autenticar-se amb el servidor de correu electrònic",
|
"notification_email_password_description": "Contrasenya per a autenticar-se amb el servidor de correu electrònic",
|
||||||
"notification_email_port_description": "Port del servidor de correu electrònic (p.ex. 25, 465 o 587)",
|
"notification_email_port_description": "Port del servidor de correu electrònic (p.ex. 25, 465 o 587)",
|
||||||
|
"notification_email_secure": "SMTPS",
|
||||||
|
"notification_email_secure_description": "Fes servir SMTPS (SMTP sobre TLS)",
|
||||||
"notification_email_sent_test_email_button": "Envia correu de prova i desa",
|
"notification_email_sent_test_email_button": "Envia correu de prova i desa",
|
||||||
"notification_email_setting_description": "Configuració per l'enviament de notificacions per correu electrònic",
|
"notification_email_setting_description": "Configuració per l'enviament de notificacions per correu electrònic",
|
||||||
"notification_email_test_email": "Envia correu de prova",
|
"notification_email_test_email": "Envia correu de prova",
|
||||||
@@ -243,6 +256,7 @@
|
|||||||
"oauth_storage_quota_default_description": "Quota disponible en GB quan no s'estableixi cap valor (Entreu 0 per a quota il·limitada).",
|
"oauth_storage_quota_default_description": "Quota disponible en GB quan no s'estableixi cap valor (Entreu 0 per a quota il·limitada).",
|
||||||
"oauth_timeout": "Solicitud caducada",
|
"oauth_timeout": "Solicitud caducada",
|
||||||
"oauth_timeout_description": "Timeout per a sol·licituds en mil·lisegons",
|
"oauth_timeout_description": "Timeout per a sol·licituds en mil·lisegons",
|
||||||
|
"ocr_job_description": "Fes servir machine learning per reconèixer text a les imatges",
|
||||||
"password_enable_description": "Inicia sessió amb correu electrònic i contrasenya",
|
"password_enable_description": "Inicia sessió amb correu electrònic i contrasenya",
|
||||||
"password_settings": "Inici de sessió amb contrasenya",
|
"password_settings": "Inici de sessió amb contrasenya",
|
||||||
"password_settings_description": "Gestiona la configuració de l'inici de sessió amb contrasenya",
|
"password_settings_description": "Gestiona la configuració de l'inici de sessió amb contrasenya",
|
||||||
@@ -333,7 +347,7 @@
|
|||||||
"transcoding_max_b_frames": "Nombre màxim de B-frames",
|
"transcoding_max_b_frames": "Nombre màxim de B-frames",
|
||||||
"transcoding_max_b_frames_description": "Els valors més alts milloren l'eficiència de la compressió, però alenteixen la codificació. És possible que no sigui compatible amb l'acceleració de maquinari en dispositius antics. 0 desactiva els B-frames, mentre que -1 estableix aquest valor automàticament.",
|
"transcoding_max_b_frames_description": "Els valors més alts milloren l'eficiència de la compressió, però alenteixen la codificació. És possible que no sigui compatible amb l'acceleració de maquinari en dispositius antics. 0 desactiva els B-frames, mentre que -1 estableix aquest valor automàticament.",
|
||||||
"transcoding_max_bitrate": "Taxa de bits màxima",
|
"transcoding_max_bitrate": "Taxa de bits màxima",
|
||||||
"transcoding_max_bitrate_description": "Establir una taxa de bits màxima pot fer que les mides dels fitxers siguin més previsibles amb un cost menor per a la qualitat. A 720p, els valors típics són 2600 kbit/s per a VP9 o HEVC, o 4500 kbit/s per a H.264. Desactivat si s'estableix a 0.",
|
"transcoding_max_bitrate_description": "Establir una taxa de bits màxima pot fer que les mides dels fitxers siguin més previsibles amb un cost menor per a la qualitat. A 720p, els valors típics són 2600 kbit/s per a VP9 o HEVC, o 4500 kbit/s per a H.264. Desactivat si s'estableix a 0. Quan no s'especifica, s'assumeix kbit/s; per tant 5000 i 5000k i 5M son equivalents.",
|
||||||
"transcoding_max_keyframe_interval": "Interval màxim de fotogrames clau",
|
"transcoding_max_keyframe_interval": "Interval màxim de fotogrames clau",
|
||||||
"transcoding_max_keyframe_interval_description": "Estableix la distància màxima entre fotogrames clau. Els valors més baixos empitjoren l'eficiència de la compressió, però milloren els temps de cerca i poden millorar la qualitat en escenes amb moviment ràpid. 0 estableix aquest valor automàticament.",
|
"transcoding_max_keyframe_interval_description": "Estableix la distància màxima entre fotogrames clau. Els valors més baixos empitjoren l'eficiència de la compressió, però milloren els temps de cerca i poden millorar la qualitat en escenes amb moviment ràpid. 0 estableix aquest valor automàticament.",
|
||||||
"transcoding_optimal_description": "Vídeos superiors a la resolució objectiu o que no tenen un format acceptat",
|
"transcoding_optimal_description": "Vídeos superiors a la resolució objectiu o que no tenen un format acceptat",
|
||||||
@@ -351,7 +365,7 @@
|
|||||||
"transcoding_target_resolution": "Resolució objectiu",
|
"transcoding_target_resolution": "Resolució objectiu",
|
||||||
"transcoding_target_resolution_description": "Les resolucions més altes poden conservar més detalls, però triguen més temps a codificar-se, tenen mides de fitxer més grans i poden reduir la capacitat de resposta de l'aplicació.",
|
"transcoding_target_resolution_description": "Les resolucions més altes poden conservar més detalls, però triguen més temps a codificar-se, tenen mides de fitxer més grans i poden reduir la capacitat de resposta de l'aplicació.",
|
||||||
"transcoding_temporal_aq": "AQ temporal",
|
"transcoding_temporal_aq": "AQ temporal",
|
||||||
"transcoding_temporal_aq_description": "S'aplica només a NVENC. Augmenta la qualitat de les escenes de baix moviment i alt detall. És possible que no sigui compatible amb dispositius antics.",
|
"transcoding_temporal_aq_description": "S'aplica només a NVENC. Quantització adaptativa temporal augmenta la qualitat de les escenes de baix moviment i alt detall. És possible que no sigui compatible amb dispositius antics.",
|
||||||
"transcoding_threads": "Fils",
|
"transcoding_threads": "Fils",
|
||||||
"transcoding_threads_description": "Els valors més alts condueixen a una codificació més ràpida, però deixen menys espai perquè el servidor processi altres tasques mentre està actiu. Aquest valor no hauria de ser superior al nombre de nuclis de CPU. Maximitza la utilització si s'estableix a 0.",
|
"transcoding_threads_description": "Els valors més alts condueixen a una codificació més ràpida, però deixen menys espai perquè el servidor processi altres tasques mentre està actiu. Aquest valor no hauria de ser superior al nombre de nuclis de CPU. Maximitza la utilització si s'estableix a 0.",
|
||||||
"transcoding_tone_mapping": "Mapeig de to",
|
"transcoding_tone_mapping": "Mapeig de to",
|
||||||
@@ -402,11 +416,11 @@
|
|||||||
"advanced_settings_prefer_remote_subtitle": "Alguns dispositius són molt lents en carregar miniatures dels elements locals. Activeu aquest paràmetre per carregar imatges remotes en el seu lloc.",
|
"advanced_settings_prefer_remote_subtitle": "Alguns dispositius són molt lents en carregar miniatures dels elements locals. Activeu aquest paràmetre per carregar imatges remotes en el seu lloc.",
|
||||||
"advanced_settings_prefer_remote_title": "Prefereix imatges remotes",
|
"advanced_settings_prefer_remote_title": "Prefereix imatges remotes",
|
||||||
"advanced_settings_proxy_headers_subtitle": "Definiu les capçaleres de proxy que Immich per enviar amb cada sol·licitud de xarxa",
|
"advanced_settings_proxy_headers_subtitle": "Definiu les capçaleres de proxy que Immich per enviar amb cada sol·licitud de xarxa",
|
||||||
"advanced_settings_proxy_headers_title": "Capçaleres de proxy",
|
"advanced_settings_proxy_headers_title": "Capçaleres de proxy particulars [EXPERIMENTAL]",
|
||||||
"advanced_settings_readonly_mode_subtitle": "Habilita el només de lectura mode on les fotos poden ser només vist, a coses els agrada seleccionant imatges múltiples, compartint, càsting, elimina és tot discapacitat. Habilita/Desactiva només de lectura via avatar d'usuari des de la pantalla major",
|
"advanced_settings_readonly_mode_subtitle": "Habilita el només de lectura mode on les fotos poden ser només vist, a coses els agrada seleccionant imatges múltiples, compartint, càsting, elimina és tot discapacitat. Habilita/Desactiva només de lectura via avatar d'usuari des de la pantalla major",
|
||||||
"advanced_settings_readonly_mode_title": "Mode de només lectura",
|
"advanced_settings_readonly_mode_title": "Mode de només lectura",
|
||||||
"advanced_settings_self_signed_ssl_subtitle": "Omet la verificació del certificat SSL del servidor. Requerit per a certificats autosignats.",
|
"advanced_settings_self_signed_ssl_subtitle": "Omet la verificació del certificat SSL del servidor. Requerit per a certificats autosignats.",
|
||||||
"advanced_settings_self_signed_ssl_title": "Permet certificats SSL autosignats",
|
"advanced_settings_self_signed_ssl_title": "Permet certificats SSL autosignats [EXPERIMENTAL]",
|
||||||
"advanced_settings_sync_remote_deletions_subtitle": "Suprimeix o restaura automàticament un actiu en aquest dispositiu quan es realitzi aquesta acció al web",
|
"advanced_settings_sync_remote_deletions_subtitle": "Suprimeix o restaura automàticament un actiu en aquest dispositiu quan es realitzi aquesta acció al web",
|
||||||
"advanced_settings_sync_remote_deletions_title": "Sincronitza les eliminacions remotes",
|
"advanced_settings_sync_remote_deletions_title": "Sincronitza les eliminacions remotes",
|
||||||
"advanced_settings_tile_subtitle": "Configuració avançada de l'usuari",
|
"advanced_settings_tile_subtitle": "Configuració avançada de l'usuari",
|
||||||
@@ -415,6 +429,7 @@
|
|||||||
"age_months": "{months, plural, one {# mes} other {# mesos}}",
|
"age_months": "{months, plural, one {# mes} other {# mesos}}",
|
||||||
"age_year_months": "Un any i {months, plural, one {# mes} other {# mesos}}",
|
"age_year_months": "Un any i {months, plural, one {# mes} other {# mesos}}",
|
||||||
"age_years": "{years, plural, one {# any} other {# anys}}",
|
"age_years": "{years, plural, one {# any} other {# anys}}",
|
||||||
|
"album": "Àlbum",
|
||||||
"album_added": "Àlbum afegit",
|
"album_added": "Àlbum afegit",
|
||||||
"album_added_notification_setting_description": "Rep una notificació per correu quan siguis afegit a un àlbum compartit",
|
"album_added_notification_setting_description": "Rep una notificació per correu quan siguis afegit a un àlbum compartit",
|
||||||
"album_cover_updated": "Portada de l'àlbum actualitzada",
|
"album_cover_updated": "Portada de l'àlbum actualitzada",
|
||||||
@@ -460,16 +475,21 @@
|
|||||||
"allow_edits": "Permet editar",
|
"allow_edits": "Permet editar",
|
||||||
"allow_public_user_to_download": "Permet que l'usuari públic pugui descarregar",
|
"allow_public_user_to_download": "Permet que l'usuari públic pugui descarregar",
|
||||||
"allow_public_user_to_upload": "Permet que l'usuari públic pugui carregar",
|
"allow_public_user_to_upload": "Permet que l'usuari públic pugui carregar",
|
||||||
|
"allowed": "Permès",
|
||||||
"alt_text_qr_code": "Codi QR",
|
"alt_text_qr_code": "Codi QR",
|
||||||
"anti_clockwise": "En sentit antihorari",
|
"anti_clockwise": "En sentit antihorari",
|
||||||
"api_key": "Clau API",
|
"api_key": "Clau API",
|
||||||
"api_key_description": "Aquest valor només es mostrarà una vegada. Assegureu-vos de copiar-lo abans de tancar la finestra.",
|
"api_key_description": "Aquest valor només es mostrarà una vegada. Assegureu-vos de copiar-lo abans de tancar la finestra.",
|
||||||
"api_key_empty": "El nom de la clau de l'API no pot estar buit",
|
"api_key_empty": "El nom de la clau de l'API no pot estar buit",
|
||||||
"api_keys": "Claus API",
|
"api_keys": "Claus API",
|
||||||
|
"app_architecture_variant": "Variant (Arquitectura)",
|
||||||
"app_bar_signout_dialog_content": "Estàs segur que vols tancar la sessió?",
|
"app_bar_signout_dialog_content": "Estàs segur que vols tancar la sessió?",
|
||||||
"app_bar_signout_dialog_ok": "Sí",
|
"app_bar_signout_dialog_ok": "Sí",
|
||||||
"app_bar_signout_dialog_title": "Tanca la sessió",
|
"app_bar_signout_dialog_title": "Tanca la sessió",
|
||||||
|
"app_download_links": "App descarrega enllaços",
|
||||||
"app_settings": "Configuració de l'app",
|
"app_settings": "Configuració de l'app",
|
||||||
|
"app_stores": "Botiga App",
|
||||||
|
"app_update_available": "Actualització App disponible",
|
||||||
"appears_in": "Apareix a",
|
"appears_in": "Apareix a",
|
||||||
"apply_count": "Aplicar ({count, number})",
|
"apply_count": "Aplicar ({count, number})",
|
||||||
"archive": "Arxiu",
|
"archive": "Arxiu",
|
||||||
@@ -553,6 +573,7 @@
|
|||||||
"backup_albums_sync": "Sincronització d'àlbums de còpia de seguretat",
|
"backup_albums_sync": "Sincronització d'àlbums de còpia de seguretat",
|
||||||
"backup_all": "Tots",
|
"backup_all": "Tots",
|
||||||
"backup_background_service_backup_failed_message": "No s'ha pogut copiar els elements. Tornant a intentar…",
|
"backup_background_service_backup_failed_message": "No s'ha pogut copiar els elements. Tornant a intentar…",
|
||||||
|
"backup_background_service_complete_notification": "Backup completat d'actius",
|
||||||
"backup_background_service_connection_failed_message": "No s'ha pogut connectar al servidor. Tornant a intentar…",
|
"backup_background_service_connection_failed_message": "No s'ha pogut connectar al servidor. Tornant a intentar…",
|
||||||
"backup_background_service_current_upload_notification": "Pujant {filename}",
|
"backup_background_service_current_upload_notification": "Pujant {filename}",
|
||||||
"backup_background_service_default_notification": "Cercant nous elements…",
|
"backup_background_service_default_notification": "Cercant nous elements…",
|
||||||
@@ -621,7 +642,7 @@
|
|||||||
"bugs_and_feature_requests": "Errors i sol·licituds de funcions",
|
"bugs_and_feature_requests": "Errors i sol·licituds de funcions",
|
||||||
"build": "Construeix",
|
"build": "Construeix",
|
||||||
"build_image": "Construeix la imatge",
|
"build_image": "Construeix la imatge",
|
||||||
"bulk_delete_duplicates_confirmation": "Esteu segur que voleu suprimir de manera massiva {count, plural, one {# recurs duplicat} other {# recursos duplicats}}? Això mantindrà el recurs més gran de cada grup i esborrarà permanentment tots els altres duplicats. No podeu desfer aquesta acció!",
|
"bulk_delete_duplicates_confirmation": "Esteu segurs que voleu suprimir de manera massiva {count, plural, one {# recurs duplicat} other {# recursos duplicats}}? Això mantindrà el recurs més gran de cada grup i esborrarà permanentment tots els altres duplicats. No podeu desfer aquesta acció!",
|
||||||
"bulk_keep_duplicates_confirmation": "Esteu segur que voleu mantenir {count, plural, one {# recurs duplicat} other {# recursos duplicats}}? Això resoldrà tots els grups duplicats sense eliminar res.",
|
"bulk_keep_duplicates_confirmation": "Esteu segur que voleu mantenir {count, plural, one {# recurs duplicat} other {# recursos duplicats}}? Això resoldrà tots els grups duplicats sense eliminar res.",
|
||||||
"bulk_trash_duplicates_confirmation": "Esteu segur que voleu enviar a les escombraries {count, plural, one {# recurs duplicat} other {# recursos duplicats}}? Això mantindrà el recurs més gran de cada grup i eliminarà la resta de duplicats.",
|
"bulk_trash_duplicates_confirmation": "Esteu segur que voleu enviar a les escombraries {count, plural, one {# recurs duplicat} other {# recursos duplicats}}? Això mantindrà el recurs més gran de cada grup i eliminarà la resta de duplicats.",
|
||||||
"buy": "Comprar Immich",
|
"buy": "Comprar Immich",
|
||||||
@@ -662,6 +683,8 @@
|
|||||||
"change_password_description": "Aquesta és la primera vegada que inicieu la sessió al sistema o s'ha fet una sol·licitud per canviar la contrasenya. Introduïu la nova contrasenya a continuació.",
|
"change_password_description": "Aquesta és la primera vegada que inicieu la sessió al sistema o s'ha fet una sol·licitud per canviar la contrasenya. Introduïu la nova contrasenya a continuació.",
|
||||||
"change_password_form_confirm_password": "Confirma la contrasenya",
|
"change_password_form_confirm_password": "Confirma la contrasenya",
|
||||||
"change_password_form_description": "Hola {name},\n\nAquesta és la primera vegada que inicies sessió al sistema o bé s'ha sol·licitat canviar la teva contrasenya. Si us plau, introdueix la nova contrasenya a continuació.",
|
"change_password_form_description": "Hola {name},\n\nAquesta és la primera vegada que inicies sessió al sistema o bé s'ha sol·licitat canviar la teva contrasenya. Si us plau, introdueix la nova contrasenya a continuació.",
|
||||||
|
"change_password_form_log_out": "Fer fora de tots els altres dispositius",
|
||||||
|
"change_password_form_log_out_description": "Es recomana fer fora de tots els altres dispositius",
|
||||||
"change_password_form_new_password": "Nova contrasenya",
|
"change_password_form_new_password": "Nova contrasenya",
|
||||||
"change_password_form_password_mismatch": "Les contrasenyes no coincideixen",
|
"change_password_form_password_mismatch": "Les contrasenyes no coincideixen",
|
||||||
"change_password_form_reenter_new_password": "Torna a introduir la nova contrasenya",
|
"change_password_form_reenter_new_password": "Torna a introduir la nova contrasenya",
|
||||||
@@ -701,7 +724,6 @@
|
|||||||
"comments_and_likes": "Comentaris i agradaments",
|
"comments_and_likes": "Comentaris i agradaments",
|
||||||
"comments_are_disabled": "Els comentaris estan desactivats",
|
"comments_are_disabled": "Els comentaris estan desactivats",
|
||||||
"common_create_new_album": "Crea un àlbum nou",
|
"common_create_new_album": "Crea un àlbum nou",
|
||||||
"common_server_error": "Si us plau, comproveu la vostra connexió de xarxa, assegureu-vos que el servidor és accessible i que les versions de l'aplicació i del servidor són compatibles.",
|
|
||||||
"completed": "Completat",
|
"completed": "Completat",
|
||||||
"confirm": "Confirmar",
|
"confirm": "Confirmar",
|
||||||
"confirm_admin_password": "Confirmeu la contrasenya d'administrador",
|
"confirm_admin_password": "Confirmeu la contrasenya d'administrador",
|
||||||
@@ -740,6 +762,7 @@
|
|||||||
"create": "Crea",
|
"create": "Crea",
|
||||||
"create_album": "Crear un àlbum",
|
"create_album": "Crear un àlbum",
|
||||||
"create_album_page_untitled": "Sense títol",
|
"create_album_page_untitled": "Sense títol",
|
||||||
|
"create_api_key": "Crear clau API",
|
||||||
"create_library": "Crea una llibreria",
|
"create_library": "Crea una llibreria",
|
||||||
"create_link": "Crear enllaç",
|
"create_link": "Crear enllaç",
|
||||||
"create_link_to_share": "Crear enllaç per compartir",
|
"create_link_to_share": "Crear enllaç per compartir",
|
||||||
@@ -769,6 +792,7 @@
|
|||||||
"daily_title_text_date_year": "E, dd MMM, yyyy",
|
"daily_title_text_date_year": "E, dd MMM, yyyy",
|
||||||
"dark": "Fosc",
|
"dark": "Fosc",
|
||||||
"dark_theme": "Canviar a tema fosc",
|
"dark_theme": "Canviar a tema fosc",
|
||||||
|
"date": "Data",
|
||||||
"date_after": "Data posterior a",
|
"date_after": "Data posterior a",
|
||||||
"date_and_time": "Data i hora",
|
"date_and_time": "Data i hora",
|
||||||
"date_before": "Data anterior a",
|
"date_before": "Data anterior a",
|
||||||
@@ -784,7 +808,7 @@
|
|||||||
"deduplication_info_description": "Per preseleccionar recursos automàticament i eliminar els duplicats de manera massiva, ens fixem en:",
|
"deduplication_info_description": "Per preseleccionar recursos automàticament i eliminar els duplicats de manera massiva, ens fixem en:",
|
||||||
"default_locale": "Localització predeterminada",
|
"default_locale": "Localització predeterminada",
|
||||||
"default_locale_description": "Format de dates i números segons la configuració del navegador",
|
"default_locale_description": "Format de dates i números segons la configuració del navegador",
|
||||||
"delete": "Esborra",
|
"delete": "Esborrar",
|
||||||
"delete_action_confirmation_message": "Segur que vols eliminar aquest recurs? Aquesta acció el mourà a la paperera del servidor, i et preguntarà si el vols eliminar localment",
|
"delete_action_confirmation_message": "Segur que vols eliminar aquest recurs? Aquesta acció el mourà a la paperera del servidor, i et preguntarà si el vols eliminar localment",
|
||||||
"delete_action_prompt": "{count} eliminats",
|
"delete_action_prompt": "{count} eliminats",
|
||||||
"delete_album": "Esborra l'àlbum",
|
"delete_album": "Esborra l'àlbum",
|
||||||
@@ -871,8 +895,6 @@
|
|||||||
"edit_description_prompt": "Si us plau, selecciona una nova descripció:",
|
"edit_description_prompt": "Si us plau, selecciona una nova descripció:",
|
||||||
"edit_exclusion_pattern": "Edita patró d'exclusió",
|
"edit_exclusion_pattern": "Edita patró d'exclusió",
|
||||||
"edit_faces": "Edita les cares",
|
"edit_faces": "Edita les cares",
|
||||||
"edit_import_path": "Edita la ruta d'importació",
|
|
||||||
"edit_import_paths": "Edita les rutes d'importació",
|
|
||||||
"edit_key": "Edita clau",
|
"edit_key": "Edita clau",
|
||||||
"edit_link": "Edita enllaç",
|
"edit_link": "Edita enllaç",
|
||||||
"edit_location": "Edita ubicació",
|
"edit_location": "Edita ubicació",
|
||||||
@@ -883,7 +905,6 @@
|
|||||||
"edit_tag": "Editar etiqueta",
|
"edit_tag": "Editar etiqueta",
|
||||||
"edit_title": "Edita títol",
|
"edit_title": "Edita títol",
|
||||||
"edit_user": "Edita l'usuari",
|
"edit_user": "Edita l'usuari",
|
||||||
"edited": "Editat",
|
|
||||||
"editor": "Editor",
|
"editor": "Editor",
|
||||||
"editor_close_without_save_prompt": "No es desaran els canvis",
|
"editor_close_without_save_prompt": "No es desaran els canvis",
|
||||||
"editor_close_without_save_title": "Tancar l'editor?",
|
"editor_close_without_save_title": "Tancar l'editor?",
|
||||||
@@ -945,7 +966,6 @@
|
|||||||
"failed_to_stack_assets": "No s'han pogut apilar els elements",
|
"failed_to_stack_assets": "No s'han pogut apilar els elements",
|
||||||
"failed_to_unstack_assets": "No s'han pogut desapilar els elements",
|
"failed_to_unstack_assets": "No s'han pogut desapilar els elements",
|
||||||
"failed_to_update_notification_status": "Error en actualitzar l'estat de les notificacions",
|
"failed_to_update_notification_status": "Error en actualitzar l'estat de les notificacions",
|
||||||
"import_path_already_exists": "Aquesta ruta d'importació ja existeix.",
|
|
||||||
"incorrect_email_or_password": "Correu electrònic o contrasenya incorrectes",
|
"incorrect_email_or_password": "Correu electrònic o contrasenya incorrectes",
|
||||||
"paths_validation_failed": "{paths, plural, one {# ruta} other {# rutes}} no ha pogut validar",
|
"paths_validation_failed": "{paths, plural, one {# ruta} other {# rutes}} no ha pogut validar",
|
||||||
"profile_picture_transparent_pixels": "Les fotos de perfil no poden tenir píxels transparents. Per favor, feu zoom in, mogueu la imatge o ambdues.",
|
"profile_picture_transparent_pixels": "Les fotos de perfil no poden tenir píxels transparents. Per favor, feu zoom in, mogueu la imatge o ambdues.",
|
||||||
@@ -955,7 +975,6 @@
|
|||||||
"unable_to_add_assets_to_shared_link": "No s'han pogut afegir els elements a l'enllaç compartit",
|
"unable_to_add_assets_to_shared_link": "No s'han pogut afegir els elements a l'enllaç compartit",
|
||||||
"unable_to_add_comment": "No es pot afegir el comentari",
|
"unable_to_add_comment": "No es pot afegir el comentari",
|
||||||
"unable_to_add_exclusion_pattern": "No s'ha pogut afegir el patró d’exclusió",
|
"unable_to_add_exclusion_pattern": "No s'ha pogut afegir el patró d’exclusió",
|
||||||
"unable_to_add_import_path": "No s'ha pogut afegir la ruta d'importació",
|
|
||||||
"unable_to_add_partners": "No es poden afegir companys",
|
"unable_to_add_partners": "No es poden 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_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_favorites": "No s'ha pogut {favorite, select, true {afegir l'element als} other {eliminar l'element dels}} preferits",
|
||||||
@@ -978,12 +997,10 @@
|
|||||||
"unable_to_delete_asset": "No es pot suprimir el recurs",
|
"unable_to_delete_asset": "No es pot suprimir el recurs",
|
||||||
"unable_to_delete_assets": "S'ha produït un error en suprimir recursos",
|
"unable_to_delete_assets": "S'ha produït un error en suprimir recursos",
|
||||||
"unable_to_delete_exclusion_pattern": "No es pot suprimir el patró d'exclusió",
|
"unable_to_delete_exclusion_pattern": "No es pot suprimir el patró d'exclusió",
|
||||||
"unable_to_delete_import_path": "No es pot suprimir la ruta d'importació",
|
|
||||||
"unable_to_delete_shared_link": "No es pot suprimir l'enllaç compartit",
|
"unable_to_delete_shared_link": "No es pot suprimir l'enllaç compartit",
|
||||||
"unable_to_delete_user": "No es pot eliminar l'usuari",
|
"unable_to_delete_user": "No es pot eliminar l'usuari",
|
||||||
"unable_to_download_files": "No es poden descarregar fitxers",
|
"unable_to_download_files": "No es poden descarregar fitxers",
|
||||||
"unable_to_edit_exclusion_pattern": "No es pot editar el patró d'exclusió",
|
"unable_to_edit_exclusion_pattern": "No es pot editar el patró d'exclusió",
|
||||||
"unable_to_edit_import_path": "No es pot editar la ruta d'importació",
|
|
||||||
"unable_to_empty_trash": "No es pot buidar la paperera",
|
"unable_to_empty_trash": "No es pot buidar la paperera",
|
||||||
"unable_to_enter_fullscreen": "No es pot entrar a la pantalla completa",
|
"unable_to_enter_fullscreen": "No es pot entrar a la pantalla completa",
|
||||||
"unable_to_exit_fullscreen": "No es pot sortir de la pantalla completa",
|
"unable_to_exit_fullscreen": "No es pot sortir de la pantalla completa",
|
||||||
@@ -1039,6 +1056,7 @@
|
|||||||
"exif_bottom_sheet_description_error": "No s'ha pogut actualitzar la descripció",
|
"exif_bottom_sheet_description_error": "No s'ha pogut actualitzar la descripció",
|
||||||
"exif_bottom_sheet_details": "DETALLS",
|
"exif_bottom_sheet_details": "DETALLS",
|
||||||
"exif_bottom_sheet_location": "UBICACIÓ",
|
"exif_bottom_sheet_location": "UBICACIÓ",
|
||||||
|
"exif_bottom_sheet_no_description": "Sense descrioció",
|
||||||
"exif_bottom_sheet_people": "PERSONES",
|
"exif_bottom_sheet_people": "PERSONES",
|
||||||
"exif_bottom_sheet_person_add_person": "Afegir nom",
|
"exif_bottom_sheet_person_add_person": "Afegir nom",
|
||||||
"exit_slideshow": "Surt de la presentació de diapositives",
|
"exit_slideshow": "Surt de la presentació de diapositives",
|
||||||
@@ -1077,6 +1095,7 @@
|
|||||||
"features_setting_description": "Administrar les funcions de l'aplicació",
|
"features_setting_description": "Administrar les funcions de l'aplicació",
|
||||||
"file_name": "Nom de l'arxiu",
|
"file_name": "Nom de l'arxiu",
|
||||||
"file_name_or_extension": "Nom de l'arxiu o extensió",
|
"file_name_or_extension": "Nom de l'arxiu o extensió",
|
||||||
|
"file_size": "Mida del fitxer",
|
||||||
"filename": "Nom del fitxer",
|
"filename": "Nom del fitxer",
|
||||||
"filetype": "Tipus d'arxiu",
|
"filetype": "Tipus d'arxiu",
|
||||||
"filter": "Filtrar",
|
"filter": "Filtrar",
|
||||||
@@ -1120,7 +1139,6 @@
|
|||||||
"header_settings_field_validator_msg": "El valor no pot estar buit",
|
"header_settings_field_validator_msg": "El valor no pot estar buit",
|
||||||
"header_settings_header_name_input": "Nom de la capçalera",
|
"header_settings_header_name_input": "Nom de la capçalera",
|
||||||
"header_settings_header_value_input": "Valor de la capçalera",
|
"header_settings_header_value_input": "Valor de la capçalera",
|
||||||
"headers_settings_tile_subtitle": "Definiu les capçaleres de proxy que l'aplicació hauria d'enviar amb cada sol·licitud de xarxa",
|
|
||||||
"headers_settings_tile_title": "Capçaleres proxy personalitzades",
|
"headers_settings_tile_title": "Capçaleres proxy personalitzades",
|
||||||
"hi_user": "Hola {name} ({email})",
|
"hi_user": "Hola {name} ({email})",
|
||||||
"hide_all_people": "Amaga totes les persones",
|
"hide_all_people": "Amaga totes les persones",
|
||||||
@@ -1173,6 +1191,8 @@
|
|||||||
"import_path": "Ruta d'importació",
|
"import_path": "Ruta d'importació",
|
||||||
"in_albums": "A {count, plural, one {# àlbum} other {# àlbums}}",
|
"in_albums": "A {count, plural, one {# àlbum} other {# àlbums}}",
|
||||||
"in_archive": "En arxiu",
|
"in_archive": "En arxiu",
|
||||||
|
"in_year": "En {year}",
|
||||||
|
"in_year_selector": "En",
|
||||||
"include_archived": "Incloure arxivats",
|
"include_archived": "Incloure arxivats",
|
||||||
"include_shared_albums": "Inclou àlbums compartits",
|
"include_shared_albums": "Inclou àlbums compartits",
|
||||||
"include_shared_partner_assets": "Incloure elements dels companys",
|
"include_shared_partner_assets": "Incloure elements dels companys",
|
||||||
@@ -1209,6 +1229,7 @@
|
|||||||
"language_setting_description": "Seleccioneu el vostre idioma",
|
"language_setting_description": "Seleccioneu el vostre idioma",
|
||||||
"large_files": "Fitxers Grans",
|
"large_files": "Fitxers Grans",
|
||||||
"last": "Últim",
|
"last": "Últim",
|
||||||
|
"last_months": "{count, plural, one {Últim mes} other {Últims # mesos}}",
|
||||||
"last_seen": "Vist per últim cop",
|
"last_seen": "Vist per últim cop",
|
||||||
"latest_version": "Última versió",
|
"latest_version": "Última versió",
|
||||||
"latitude": "Latitud",
|
"latitude": "Latitud",
|
||||||
@@ -1241,6 +1262,7 @@
|
|||||||
"local_media_summary": "Resum de Mitjans Locals",
|
"local_media_summary": "Resum de Mitjans Locals",
|
||||||
"local_network": "Xarxa local",
|
"local_network": "Xarxa local",
|
||||||
"local_network_sheet_info": "L'aplicació es connectarà al servidor mitjançant aquest URL quan utilitzeu la xarxa Wi-Fi especificada",
|
"local_network_sheet_info": "L'aplicació es connectarà al servidor mitjançant aquest URL quan utilitzeu la xarxa Wi-Fi especificada",
|
||||||
|
"location": "Localització",
|
||||||
"location_permission": "Permís d'ubicació",
|
"location_permission": "Permís d'ubicació",
|
||||||
"location_permission_content": "Per utilitzar la funció de canvi automàtic, Immich necessita un permís d'ubicació precisa perquè pugui llegir el nom de la xarxa Wi-Fi actual",
|
"location_permission_content": "Per utilitzar la funció de canvi automàtic, Immich necessita un permís d'ubicació precisa perquè pugui llegir el nom de la xarxa Wi-Fi actual",
|
||||||
"location_picker_choose_on_map": "Escollir en el mapa",
|
"location_picker_choose_on_map": "Escollir en el mapa",
|
||||||
@@ -1290,6 +1312,10 @@
|
|||||||
"main_menu": "Menú principal",
|
"main_menu": "Menú principal",
|
||||||
"make": "Fabricant",
|
"make": "Fabricant",
|
||||||
"manage_geolocation": "Gestioneu la vostra ubicació",
|
"manage_geolocation": "Gestioneu la vostra ubicació",
|
||||||
|
"manage_media_access_rationale": "Aquest permís es necessari per a la correcta gestió dels actius que es mouen a la paperera i es restauren d'ella.",
|
||||||
|
"manage_media_access_settings": "Configuració oberta",
|
||||||
|
"manage_media_access_subtitle": "Permet a l'Immich gestionar i moure fitxers multimèdia.",
|
||||||
|
"manage_media_access_title": "Accés a la gestió de mitjans",
|
||||||
"manage_shared_links": "Administrar enllaços compartits",
|
"manage_shared_links": "Administrar enllaços compartits",
|
||||||
"manage_sharing_with_partners": "Gestiona la compartició amb els companys",
|
"manage_sharing_with_partners": "Gestiona la compartició amb els companys",
|
||||||
"manage_the_app_settings": "Gestioneu la configuració de l'aplicació",
|
"manage_the_app_settings": "Gestioneu la configuració de l'aplicació",
|
||||||
@@ -1345,12 +1371,15 @@
|
|||||||
"minute": "Minut",
|
"minute": "Minut",
|
||||||
"minutes": "Minuts",
|
"minutes": "Minuts",
|
||||||
"missing": "Restants",
|
"missing": "Restants",
|
||||||
|
"mobile_app": "Aplicació mòbil",
|
||||||
|
"mobile_app_download_onboarding_note": "Descarregar la App de mòbil fent servir les seguents opcions",
|
||||||
"model": "Model",
|
"model": "Model",
|
||||||
"month": "Mes",
|
"month": "Mes",
|
||||||
"monthly_title_text_date_format": "MMMM y",
|
"monthly_title_text_date_format": "MMMM y",
|
||||||
"more": "Més",
|
"more": "Més",
|
||||||
"move": "Moure",
|
"move": "Moure",
|
||||||
"move_off_locked_folder": "Moure fora de la carpeta bloquejada",
|
"move_off_locked_folder": "Moure fora de la carpeta bloquejada",
|
||||||
|
"move_to": "Moure a",
|
||||||
"move_to_lock_folder_action_prompt": "{count} afegides a la carpeta protegida",
|
"move_to_lock_folder_action_prompt": "{count} afegides a la carpeta protegida",
|
||||||
"move_to_locked_folder": "Moure a la carpeta bloquejada",
|
"move_to_locked_folder": "Moure a la carpeta bloquejada",
|
||||||
"move_to_locked_folder_confirmation": "Aquestes fotos i vídeos seran eliminades de tots els àlbums, i només podran ser vistes des de la carpeta bloquejada",
|
"move_to_locked_folder_confirmation": "Aquestes fotos i vídeos seran eliminades de tots els àlbums, i només podran ser vistes des de la carpeta bloquejada",
|
||||||
@@ -1363,6 +1392,8 @@
|
|||||||
"my_albums": "Els meus àlbums",
|
"my_albums": "Els meus àlbums",
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
"name_or_nickname": "Nom o sobrenom",
|
"name_or_nickname": "Nom o sobrenom",
|
||||||
|
"navigate": "Navegar",
|
||||||
|
"navigate_to_time": "Navegar a un punt en el temps",
|
||||||
"network_requirement_photos_upload": "Fes servir dades mòbils per a còpies de seguretat de fotos",
|
"network_requirement_photos_upload": "Fes servir dades mòbils per a còpies de seguretat de fotos",
|
||||||
"network_requirement_videos_upload": "Fes servir dades mòbils per a còpies de seguretat de videos",
|
"network_requirement_videos_upload": "Fes servir dades mòbils per a còpies de seguretat de videos",
|
||||||
"network_requirements": "Requeriments de Xarxa",
|
"network_requirements": "Requeriments de Xarxa",
|
||||||
@@ -1372,11 +1403,13 @@
|
|||||||
"never": "Mai",
|
"never": "Mai",
|
||||||
"new_album": "Nou Àlbum",
|
"new_album": "Nou Àlbum",
|
||||||
"new_api_key": "Nova clau de l'API",
|
"new_api_key": "Nova clau de l'API",
|
||||||
|
"new_date_range": "Navegar a un reng de dates",
|
||||||
"new_password": "Nova contrasenya",
|
"new_password": "Nova contrasenya",
|
||||||
"new_person": "Persona nova",
|
"new_person": "Persona nova",
|
||||||
"new_pin_code": "Nou codi PIN",
|
"new_pin_code": "Nou codi PIN",
|
||||||
"new_pin_code_subtitle": "Aquesta és la primera vegada que accedeixes a la carpeta bloquejada. Crea una codi PIN i accedeix de manera segura a aquesta pàgina",
|
"new_pin_code_subtitle": "Aquesta és la primera vegada que accedeixes a la carpeta bloquejada. Crea una codi PIN i accedeix de manera segura a aquesta pàgina",
|
||||||
"new_timeline": "Nova Línia de Temps",
|
"new_timeline": "Nova Línia de Temps",
|
||||||
|
"new_update": "Nova actualització",
|
||||||
"new_user_created": "Nou usuari creat",
|
"new_user_created": "Nou usuari creat",
|
||||||
"new_version_available": "NOVA VERSIÓ DISPONIBLE",
|
"new_version_available": "NOVA VERSIÓ DISPONIBLE",
|
||||||
"newest_first": "El més nou primer",
|
"newest_first": "El més nou primer",
|
||||||
@@ -1392,6 +1425,7 @@
|
|||||||
"no_cast_devices_found": "No s'han trobat dispositius per transmetre",
|
"no_cast_devices_found": "No s'han trobat dispositius per transmetre",
|
||||||
"no_checksum_local": "Cap checksum disponible - no s'han pogut carregar els recursos locals",
|
"no_checksum_local": "Cap checksum disponible - no s'han pogut carregar els recursos locals",
|
||||||
"no_checksum_remote": "Cap checksum disponible - no s'ha pogut obtenir el recurs remot",
|
"no_checksum_remote": "Cap checksum disponible - no s'ha pogut obtenir el recurs remot",
|
||||||
|
"no_devices": "No hi ha dispositius autoritzats",
|
||||||
"no_duplicates_found": "No s'han trobat duplicats.",
|
"no_duplicates_found": "No s'han trobat duplicats.",
|
||||||
"no_exif_info_available": "No hi ha informació d'exif disponible",
|
"no_exif_info_available": "No hi ha informació d'exif disponible",
|
||||||
"no_explore_results_message": "Penja més fotos per explorar la teva col·lecció.",
|
"no_explore_results_message": "Penja més fotos per explorar la teva col·lecció.",
|
||||||
@@ -1408,6 +1442,7 @@
|
|||||||
"no_results_description": "Proveu un sinònim o una paraula clau més general",
|
"no_results_description": "Proveu un sinònim o una paraula clau més general",
|
||||||
"no_shared_albums_message": "Creeu un àlbum per compartir fotos i vídeos amb persones a la vostra xarxa",
|
"no_shared_albums_message": "Creeu un àlbum per compartir fotos i vídeos amb persones a la vostra xarxa",
|
||||||
"no_uploads_in_progress": "Cap pujada en progrés",
|
"no_uploads_in_progress": "Cap pujada en progrés",
|
||||||
|
"not_allowed": "No permès",
|
||||||
"not_available": "N/A",
|
"not_available": "N/A",
|
||||||
"not_in_any_album": "En cap àlbum",
|
"not_in_any_album": "En cap àlbum",
|
||||||
"not_selected": "No seleccionat",
|
"not_selected": "No seleccionat",
|
||||||
@@ -1422,6 +1457,9 @@
|
|||||||
"notifications": "Notificacions",
|
"notifications": "Notificacions",
|
||||||
"notifications_setting_description": "Gestiona les notificacions",
|
"notifications_setting_description": "Gestiona les notificacions",
|
||||||
"oauth": "OAuth",
|
"oauth": "OAuth",
|
||||||
|
"obtainium_configurator": "Configurador Obtainium",
|
||||||
|
"obtainium_configurator_instructions": "Utilitza Obtainium per instal·lar una actualització a la app directament des de Github-Immich. Crear una clau API i seleccionar una variant per crear un enllaç a la configuració Obtainium",
|
||||||
|
"ocr": "OCR",
|
||||||
"official_immich_resources": "Recursos oficials d'Immich",
|
"official_immich_resources": "Recursos oficials d'Immich",
|
||||||
"offline": "Fora de línia",
|
"offline": "Fora de línia",
|
||||||
"offset": "Diferència",
|
"offset": "Diferència",
|
||||||
@@ -1515,6 +1553,8 @@
|
|||||||
"photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Fotos}}",
|
"photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Fotos}}",
|
||||||
"photos_from_previous_years": "Fotos d'anys anteriors",
|
"photos_from_previous_years": "Fotos d'anys anteriors",
|
||||||
"pick_a_location": "Triar una ubicació",
|
"pick_a_location": "Triar una ubicació",
|
||||||
|
"pick_custom_range": "Rang personalitzat",
|
||||||
|
"pick_date_range": "Seleccioni un rang de dates",
|
||||||
"pin_code_changed_successfully": "Codi PIN canviat correctament",
|
"pin_code_changed_successfully": "Codi PIN canviat correctament",
|
||||||
"pin_code_reset_successfully": "S'ha restablert correctament el codi PIN",
|
"pin_code_reset_successfully": "S'ha restablert correctament el codi PIN",
|
||||||
"pin_code_setup_successfully": "S'ha configurat correctament un codi PIN",
|
"pin_code_setup_successfully": "S'ha configurat correctament un codi PIN",
|
||||||
@@ -1526,6 +1566,9 @@
|
|||||||
"play_memories": "Reproduir records",
|
"play_memories": "Reproduir records",
|
||||||
"play_motion_photo": "Reproduir Fotos en Moviment",
|
"play_motion_photo": "Reproduir Fotos en Moviment",
|
||||||
"play_or_pause_video": "Reproduir o posar en pausa el vídeo",
|
"play_or_pause_video": "Reproduir o posar en pausa el vídeo",
|
||||||
|
"play_original_video": "Veure el video original",
|
||||||
|
"play_original_video_setting_description": "Preferir la reproducció del video original sobre el video recodificat. Si el video original no es compatible potser no es reprodueixi correctament.",
|
||||||
|
"play_transcoded_video": "Veure el video recodificat",
|
||||||
"please_auth_to_access": "Per favor, autentica't per accedir",
|
"please_auth_to_access": "Per favor, autentica't per accedir",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"preferences_settings_subtitle": "Gestiona les preferències de l'aplicació",
|
"preferences_settings_subtitle": "Gestiona les preferències de l'aplicació",
|
||||||
@@ -1543,13 +1586,9 @@
|
|||||||
"privacy": "Privacitat",
|
"privacy": "Privacitat",
|
||||||
"profile": "Perfil",
|
"profile": "Perfil",
|
||||||
"profile_drawer_app_logs": "Registres",
|
"profile_drawer_app_logs": "Registres",
|
||||||
"profile_drawer_client_out_of_date_major": "L'aplicació mòbil està desactualitzada. Si us plau, actualitzeu a l'última versió major.",
|
|
||||||
"profile_drawer_client_out_of_date_minor": "L'aplicació mòbil està desactualitzada. Si us plau, actualitzeu a l'última versió menor.",
|
|
||||||
"profile_drawer_client_server_up_to_date": "El client i el servidor estan actualitzats",
|
"profile_drawer_client_server_up_to_date": "El client i el servidor estan actualitzats",
|
||||||
"profile_drawer_github": "GitHub",
|
"profile_drawer_github": "GitHub",
|
||||||
"profile_drawer_readonly_mode": "Mode només lectura. Feu pulsació llarga a la icona de l'avatar d'usuari per sortir.",
|
"profile_drawer_readonly_mode": "Mode només lectura. Feu pulsació llarga a la icona de l'avatar d'usuari per sortir.",
|
||||||
"profile_drawer_server_out_of_date_major": "El servidor està desactualitzat. Si us plau, actualitzeu a l'última versió major.",
|
|
||||||
"profile_drawer_server_out_of_date_minor": "El servidor està desactualitzat. Si us plau, actualitzeu a l'última versió menor.",
|
|
||||||
"profile_image_of_user": "Imatge de perfil de {user}",
|
"profile_image_of_user": "Imatge de perfil de {user}",
|
||||||
"profile_picture_set": "Imatge de perfil configurada.",
|
"profile_picture_set": "Imatge de perfil configurada.",
|
||||||
"public_album": "Àlbum públic",
|
"public_album": "Àlbum públic",
|
||||||
@@ -1666,6 +1705,7 @@
|
|||||||
"reset_sqlite_confirmation": "Segur que vols reiniciar la base de dades SQLite? Hauràs de tancar la sessió i tornar a accedir per a resincronitzar les dades",
|
"reset_sqlite_confirmation": "Segur que vols reiniciar la base de dades SQLite? Hauràs de tancar la sessió i tornar a accedir per a resincronitzar les dades",
|
||||||
"reset_sqlite_success": "S'ha reiniciat la base de dades correctament",
|
"reset_sqlite_success": "S'ha reiniciat la base de dades correctament",
|
||||||
"reset_to_default": "Restableix els valors predeterminats",
|
"reset_to_default": "Restableix els valors predeterminats",
|
||||||
|
"resolution": "Resolució",
|
||||||
"resolve_duplicates": "Resoldre duplicats",
|
"resolve_duplicates": "Resoldre duplicats",
|
||||||
"resolved_all_duplicates": "Tots els duplicats resolts",
|
"resolved_all_duplicates": "Tots els duplicats resolts",
|
||||||
"restore": "Recupera",
|
"restore": "Recupera",
|
||||||
@@ -1684,6 +1724,7 @@
|
|||||||
"running": "En execució",
|
"running": "En execució",
|
||||||
"save": "Desa",
|
"save": "Desa",
|
||||||
"save_to_gallery": "Desa a galeria",
|
"save_to_gallery": "Desa a galeria",
|
||||||
|
"saved": "Guardat",
|
||||||
"saved_api_key": "Clau d'API guardada",
|
"saved_api_key": "Clau d'API guardada",
|
||||||
"saved_profile": "Perfil guardat",
|
"saved_profile": "Perfil guardat",
|
||||||
"saved_settings": "Configuració guardada",
|
"saved_settings": "Configuració guardada",
|
||||||
@@ -1700,6 +1741,9 @@
|
|||||||
"search_by_description_example": "Jornada de senderisme a Sapa",
|
"search_by_description_example": "Jornada de senderisme a Sapa",
|
||||||
"search_by_filename": "Cerca per nom de fitxer o extensió",
|
"search_by_filename": "Cerca per nom de fitxer o extensió",
|
||||||
"search_by_filename_example": "per exemple IMG_1234.JPG o PNG",
|
"search_by_filename_example": "per exemple IMG_1234.JPG o PNG",
|
||||||
|
"search_by_ocr": "Buscar per OCR",
|
||||||
|
"search_by_ocr_example": "Després",
|
||||||
|
"search_camera_lens_model": "Buscar model de lents....",
|
||||||
"search_camera_make": "Buscar per fabricant de càmara...",
|
"search_camera_make": "Buscar per fabricant de càmara...",
|
||||||
"search_camera_model": "Buscar per model de càmera...",
|
"search_camera_model": "Buscar per model de càmera...",
|
||||||
"search_city": "Buscar per ciutat...",
|
"search_city": "Buscar per ciutat...",
|
||||||
@@ -1716,6 +1760,7 @@
|
|||||||
"search_filter_location_title": "Selecciona l'ubicació",
|
"search_filter_location_title": "Selecciona l'ubicació",
|
||||||
"search_filter_media_type": "Tipus de multimèdia",
|
"search_filter_media_type": "Tipus de multimèdia",
|
||||||
"search_filter_media_type_title": "Selecciona tipus de multimèdia",
|
"search_filter_media_type_title": "Selecciona tipus de multimèdia",
|
||||||
|
"search_filter_ocr": "Buscar per OCR",
|
||||||
"search_filter_people_title": "Selecciona persones",
|
"search_filter_people_title": "Selecciona persones",
|
||||||
"search_for": "Cercar",
|
"search_for": "Cercar",
|
||||||
"search_for_existing_person": "Busca una persona existent",
|
"search_for_existing_person": "Busca una persona existent",
|
||||||
@@ -1778,6 +1823,7 @@
|
|||||||
"server_online": "Servidor en línia",
|
"server_online": "Servidor en línia",
|
||||||
"server_privacy": "Privadesa del servidor",
|
"server_privacy": "Privadesa del servidor",
|
||||||
"server_stats": "Estadístiques del servidor",
|
"server_stats": "Estadístiques del servidor",
|
||||||
|
"server_update_available": "Actualització del servidor disponible",
|
||||||
"server_version": "Versió del servidor",
|
"server_version": "Versió del servidor",
|
||||||
"set": "Establir",
|
"set": "Establir",
|
||||||
"set_as_album_cover": "Establir com a portada de l'àlbum",
|
"set_as_album_cover": "Establir com a portada de l'àlbum",
|
||||||
@@ -1806,6 +1852,8 @@
|
|||||||
"setting_notifications_subtitle": "Ajusta les preferències de notificació",
|
"setting_notifications_subtitle": "Ajusta les preferències de notificació",
|
||||||
"setting_notifications_total_progress_subtitle": "Progrés general de la pujada (elements completats/total)",
|
"setting_notifications_total_progress_subtitle": "Progrés general de la pujada (elements completats/total)",
|
||||||
"setting_notifications_total_progress_title": "Mostra el progrés total de la còpia de seguretat en segon pla",
|
"setting_notifications_total_progress_title": "Mostra el progrés total de la còpia de seguretat en segon pla",
|
||||||
|
"setting_video_viewer_auto_play_subtitle": "Comença a veure videos quan s'obrin",
|
||||||
|
"setting_video_viewer_auto_play_title": "Veure videos automàticament",
|
||||||
"setting_video_viewer_looping_title": "Bucle",
|
"setting_video_viewer_looping_title": "Bucle",
|
||||||
"setting_video_viewer_original_video_subtitle": "Quan reproduïu un vídeo des del servidor, reproduïu l'original encara que hi hagi una transcodificació disponible. Pot conduir a l'amortització. Els vídeos disponibles localment es reprodueixen en qualitat original independentment d'aquesta configuració.",
|
"setting_video_viewer_original_video_subtitle": "Quan reproduïu un vídeo des del servidor, reproduïu l'original encara que hi hagi una transcodificació disponible. Pot conduir a l'amortització. Els vídeos disponibles localment es reprodueixen en qualitat original independentment d'aquesta configuració.",
|
||||||
"setting_video_viewer_original_video_title": "Força el vídeo original",
|
"setting_video_viewer_original_video_title": "Força el vídeo original",
|
||||||
@@ -1813,7 +1861,7 @@
|
|||||||
"settings_require_restart": "Si us plau, reinicieu Immich per a aplicar aquest canvi",
|
"settings_require_restart": "Si us plau, reinicieu Immich per a aplicar aquest canvi",
|
||||||
"settings_saved": "Configuració desada",
|
"settings_saved": "Configuració desada",
|
||||||
"setup_pin_code": "Configurar un codi PIN",
|
"setup_pin_code": "Configurar un codi PIN",
|
||||||
"share": "Comparteix",
|
"share": "Compartir",
|
||||||
"share_action_prompt": "Compartits {count} recursos",
|
"share_action_prompt": "Compartits {count} recursos",
|
||||||
"share_add_photos": "Afegeix fotografies",
|
"share_add_photos": "Afegeix fotografies",
|
||||||
"share_assets_selected": "{count} seleccionats",
|
"share_assets_selected": "{count} seleccionats",
|
||||||
@@ -1985,7 +2033,9 @@
|
|||||||
"theme_setting_three_stage_loading_title": "Activa la càrrega en tres etapes",
|
"theme_setting_three_stage_loading_title": "Activa la càrrega en tres etapes",
|
||||||
"they_will_be_merged_together": "Es combinaran",
|
"they_will_be_merged_together": "Es combinaran",
|
||||||
"third_party_resources": "Recursos de tercers",
|
"third_party_resources": "Recursos de tercers",
|
||||||
|
"time": "Temps",
|
||||||
"time_based_memories": "Records basats en el temps",
|
"time_based_memories": "Records basats en el temps",
|
||||||
|
"time_based_memories_duration": "Quants segons es mostrarà cada imatge.",
|
||||||
"timeline": "Cronologia",
|
"timeline": "Cronologia",
|
||||||
"timezone": "Fus horari",
|
"timezone": "Fus horari",
|
||||||
"to_archive": "Arxivar",
|
"to_archive": "Arxivar",
|
||||||
@@ -2017,6 +2067,7 @@
|
|||||||
"troubleshoot": "Solució de problemes",
|
"troubleshoot": "Solució de problemes",
|
||||||
"type": "Tipus",
|
"type": "Tipus",
|
||||||
"unable_to_change_pin_code": "No es pot canviar el codi PIN",
|
"unable_to_change_pin_code": "No es pot canviar el codi PIN",
|
||||||
|
"unable_to_check_version": "No es pot comprovar la versió de l'aplicació ni del servidor",
|
||||||
"unable_to_setup_pin_code": "No s'ha pogut configurar el codi PIN",
|
"unable_to_setup_pin_code": "No s'ha pogut configurar el codi PIN",
|
||||||
"unarchive": "Desarxivar",
|
"unarchive": "Desarxivar",
|
||||||
"unarchive_action_prompt": "{count} eliminades de l'arxiu",
|
"unarchive_action_prompt": "{count} eliminades de l'arxiu",
|
||||||
@@ -2125,6 +2176,7 @@
|
|||||||
"welcome": "Benvingut",
|
"welcome": "Benvingut",
|
||||||
"welcome_to_immich": "Benvingut a immich",
|
"welcome_to_immich": "Benvingut a immich",
|
||||||
"wifi_name": "Nom Wi-Fi",
|
"wifi_name": "Nom Wi-Fi",
|
||||||
|
"workflow": "Flux de treball",
|
||||||
"wrong_pin_code": "Codi PIN incorrecte",
|
"wrong_pin_code": "Codi PIN incorrecte",
|
||||||
"year": "Any",
|
"year": "Any",
|
||||||
"years_ago": "Fa {years, plural, one {# any} other {# anys}}",
|
"years_ago": "Fa {years, plural, one {# any} other {# anys}}",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user