mirror of
https://github.com/immich-app/immich.git
synced 2026-01-25 10:54:37 -08:00
Compare commits
376 Commits
refactor/t
...
feat/isola
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8797dedb1 | ||
|
|
3cd2d7f657 | ||
|
|
3c1a5c744b | ||
|
|
f26a5da87e | ||
|
|
d2e7bc3cfd | ||
|
|
61a9d5cbc7 | ||
|
|
ca0d4b283a | ||
|
|
2b4e4051f0 | ||
|
|
0f3956f654 | ||
|
|
99bd7d5f27 | ||
|
|
fe1d0edf4c | ||
|
|
4ef699e9fa | ||
|
|
3e21174dd8 | ||
|
|
1b56bb84f9 | ||
|
|
b3f5b8ede8 | ||
|
|
2b77dc8e1f | ||
|
|
97a594556b | ||
|
|
4a7c4b6d15 | ||
|
|
a8198f9934 | ||
|
|
b123beae38 | ||
|
|
74d463c19c | ||
|
|
1ada7a8340 | ||
|
|
39b2af1940 | ||
|
|
5d81cace23 | ||
|
|
65f9a228ba | ||
|
|
e6eca895ba | ||
|
|
8196bd9bbd | ||
|
|
07675a2de4 | ||
|
|
a2b03f7650 | ||
|
|
fdff591a11 | ||
|
|
e4443fa43e | ||
|
|
843d563178 | ||
|
|
256d62e22d | ||
|
|
91592aa48e | ||
|
|
2ac113624b | ||
|
|
0052979853 | ||
|
|
79b6c4ac70 | ||
|
|
95eb3e26c3 | ||
|
|
613dc858cb | ||
|
|
2f3fbd7dc5 | ||
|
|
80a5444bf4 | ||
|
|
d59ee7d2ae | ||
|
|
7b3a298c6a | ||
|
|
0a62ec7e29 | ||
|
|
21802ab5ba | ||
|
|
56dfdfd033 | ||
|
|
2190921c85 | ||
|
|
9fa8de7baa | ||
|
|
ed9448a6ee | ||
|
|
15224a9ac5 | ||
|
|
6e00fd92ef | ||
|
|
6fdd1ce41a | ||
|
|
91d4cd6824 | ||
|
|
c7254a0c30 | ||
|
|
38f01a6b7d | ||
|
|
f194a7ea3e | ||
|
|
05a7ba98c1 | ||
|
|
edc513a3df | ||
|
|
39212a049c | ||
|
|
9b4f370834 | ||
|
|
aba85b036c | ||
|
|
6e86697996 | ||
|
|
cc90c912f5 | ||
|
|
efd20ef0d4 | ||
|
|
0c0aa1f3c3 | ||
|
|
231a475a17 | ||
|
|
94ea83c415 | ||
|
|
4b5b9baa78 | ||
|
|
3bf0d5b99f | ||
|
|
8ed81ac3e1 | ||
|
|
7992fe85d6 | ||
|
|
afe925a55e | ||
|
|
5e3f5f2b55 | ||
|
|
d4ad523eb3 | ||
|
|
e8c80d88a5 | ||
|
|
76241a7b2b | ||
|
|
1e4af9731d | ||
|
|
88327fb872 | ||
|
|
702499b97d | ||
|
|
da248414af | ||
|
|
af2c232c87 | ||
|
|
cca037b03c | ||
|
|
1d71bb5a79 | ||
|
|
ee4f2c735d | ||
|
|
4d559a63ec | ||
|
|
573e9b0d52 | ||
|
|
a2502109ab | ||
|
|
3cdece4945 | ||
|
|
520b825511 | ||
|
|
191401f2f1 | ||
|
|
8136d7fd54 | ||
|
|
5d1e486478 | ||
|
|
85b0b97ef2 | ||
|
|
471fab0591 | ||
|
|
6997ed83c4 | ||
|
|
a2ba36c16d | ||
|
|
109c79125d | ||
|
|
fbd49e0b79 | ||
|
|
1f20b6471c | ||
|
|
1d6a9f6e80 | ||
|
|
0a9f1a3cbf | ||
|
|
4f803832ad | ||
|
|
ef4aec7398 | ||
|
|
5bb3492616 | ||
|
|
78229baeab | ||
|
|
81f269e2a9 | ||
|
|
225b0f9377 | ||
|
|
30b90f9baa | ||
|
|
1293e473ca | ||
|
|
1a24a2d35e | ||
|
|
f0f1687c79 | ||
|
|
ded980bfc3 | ||
|
|
4cb56edebf | ||
|
|
c411151560 | ||
|
|
f52bd9f38a | ||
|
|
006d02cfaf | ||
|
|
263f96da87 | ||
|
|
f22affd836 | ||
|
|
f5667cefd4 | ||
|
|
7efce389b2 | ||
|
|
f59cff4f5d | ||
|
|
984f06ac40 | ||
|
|
9d4a12dfd4 | ||
|
|
94730567ab | ||
|
|
57db5e64de | ||
|
|
4d32968f2b | ||
|
|
10989e6927 | ||
|
|
62cc12be3c | ||
|
|
1874557b95 | ||
|
|
9a78547bf0 | ||
|
|
0b1bd9deb1 | ||
|
|
7202179d63 | ||
|
|
519a7df4cd | ||
|
|
3762728c84 | ||
|
|
bc3fa2b3fb | ||
|
|
57fca378bc | ||
|
|
eb718145c0 | ||
|
|
c87c1866ae | ||
|
|
b190423d96 | ||
|
|
edd3ab7cc9 | ||
|
|
4147f1d912 | ||
|
|
e4311da1a4 | ||
|
|
b7bb118c00 | ||
|
|
21f7314907 | ||
|
|
2541011eaa | ||
|
|
18d8cc4449 | ||
|
|
8e8a2f997e | ||
|
|
86e5c611ec | ||
|
|
e700bb5467 | ||
|
|
a1aa2b807b | ||
|
|
abea5a53de | ||
|
|
bcf6685643 | ||
|
|
bd27898ea9 | ||
|
|
3321c1a9df | ||
|
|
72a898d89d | ||
|
|
a16c5955d7 | ||
|
|
e87bfa548a | ||
|
|
369a30e227 | ||
|
|
0df618feee | ||
|
|
363b9276eb | ||
|
|
36d7dd9319 | ||
|
|
a57c4d9a9e | ||
|
|
724948d36d | ||
|
|
83f8065f10 | ||
|
|
e63e8e2517 | ||
|
|
01e3b8e5df | ||
|
|
5a7c9a252c | ||
|
|
f99f5f4f91 | ||
|
|
8ad27c7cea | ||
|
|
edc21ed746 | ||
|
|
dd744f8ee3 | ||
|
|
f6f9a3abb4 | ||
|
|
1c156a179b | ||
|
|
952f189d8b | ||
|
|
40e750e8be | ||
|
|
c7510d572a | ||
|
|
165f9e15ee | ||
|
|
dfdbb773ce | ||
|
|
f053ce548d | ||
|
|
d7c28470ee | ||
|
|
28f6064240 | ||
|
|
4b3b458bb6 | ||
|
|
4736b4e3e8 | ||
|
|
a17f188e97 | ||
|
|
5b80323326 | ||
|
|
1425b3da6b | ||
|
|
3d2196b0f2 | ||
|
|
50d7956c07 | ||
|
|
22d3fd3b92 | ||
|
|
a469e86b32 | ||
|
|
138c9232df | ||
|
|
2e1f8625ec | ||
|
|
f7cbb7417c | ||
|
|
125de91c71 | ||
|
|
c9b58f5893 | ||
|
|
640fd7308b | ||
|
|
557a79f747 | ||
|
|
5ade152bc5 | ||
|
|
827bf1ef18 | ||
|
|
a02adbb828 | ||
|
|
ab7520c167 | ||
|
|
de1b448639 | ||
|
|
c15998e805 | ||
|
|
f0b069adb9 | ||
|
|
276d02e12b | ||
|
|
ded9535434 | ||
|
|
997aec2441 | ||
|
|
cb2bd47816 | ||
|
|
f1c8377ca0 | ||
|
|
8416397589 | ||
|
|
dc29635b67 | ||
|
|
00290e1e71 | ||
|
|
3ef4c4f315 | ||
|
|
b10a8baf53 | ||
|
|
77926383db | ||
|
|
35eda735c8 | ||
|
|
8f7a71d1cf | ||
|
|
33cdea88aa | ||
|
|
4b345e02ff | ||
|
|
8cf900bafa | ||
|
|
59a3f0f455 | ||
|
|
c5d99711f7 | ||
|
|
4c0a41723f | ||
|
|
f73511a754 | ||
|
|
e637387082 | ||
|
|
baad38f0e6 | ||
|
|
161147af51 | ||
|
|
cbdf5011f9 | ||
|
|
f0f1d279c4 | ||
|
|
5821f2fe61 | ||
|
|
4cbce072be | ||
|
|
5e5bb7e87d | ||
|
|
b052893a1e | ||
|
|
15e58595fd | ||
|
|
6d499c782a | ||
|
|
7af99b8606 | ||
|
|
01e39277e0 | ||
|
|
06e79703da | ||
|
|
c360781565 | ||
|
|
287f6d5c94 | ||
|
|
fe9125a3d1 | ||
|
|
8b31936bb6 | ||
|
|
19958dfd83 | ||
|
|
1e1cf0d1fe | ||
|
|
879e0ea131 | ||
|
|
42136f9091 | ||
|
|
1109c32891 | ||
|
|
3c80049192 | ||
|
|
8f1669efbe | ||
|
|
146bf65d02 | ||
|
|
75a7c9c06c | ||
|
|
ae8f5a6673 | ||
|
|
31f2c7b505 | ||
|
|
ba6687dde9 | ||
|
|
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 |
@@ -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": {
|
||||||
|
|||||||
2
.github/.nvmrc
vendored
2
.github/.nvmrc
vendored
@@ -1 +1 @@
|
|||||||
24.11.0
|
24.13.0
|
||||||
|
|||||||
2
.github/package.json
vendored
2
.github/package.json
vendored
@@ -4,6 +4,6 @@
|
|||||||
"format:fix": "prettier --write ."
|
"format:fix": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "^3.5.3"
|
"prettier": "^3.7.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
57
.github/workflows/build-mobile.yml
vendored
57
.github/workflows/build-mobile.yml
vendored
@@ -30,18 +30,6 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
IOS_CERTIFICATE_PASSWORD:
|
IOS_CERTIFICATE_PASSWORD:
|
||||||
required: true
|
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:
|
FASTLANE_TEAM_ID:
|
||||||
required: true
|
required: true
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -96,7 +84,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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.sha }}
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -108,14 +96,14 @@ jobs:
|
|||||||
working-directory: ./mobile
|
working-directory: ./mobile
|
||||||
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
||||||
|
|
||||||
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
- uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
- name: Restore Gradle Cache
|
- name: Restore Gradle Cache
|
||||||
id: cache-gradle-restore
|
id: cache-gradle-restore
|
||||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -165,14 +153,14 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Publish Android Artifact
|
- name: Publish Android Artifact
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: release-apk-signed
|
name: release-apk-signed
|
||||||
path: mobile/build/app/outputs/flutter-apk/*.apk
|
path: mobile/build/app/outputs/flutter-apk/*.apk
|
||||||
|
|
||||||
- name: Save Gradle Cache
|
- name: Save Gradle Cache
|
||||||
id: cache-gradle-save
|
id: cache-gradle-save
|
||||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -194,7 +182,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.sha }}
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -222,6 +210,7 @@ jobs:
|
|||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
ruby-version: '3.3'
|
ruby-version: '3.3'
|
||||||
|
bundler-cache: true
|
||||||
working-directory: ./mobile/ios
|
working-directory: ./mobile/ios
|
||||||
|
|
||||||
- name: Install CocoaPods dependencies
|
- name: Install CocoaPods dependencies
|
||||||
@@ -229,13 +218,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pod install
|
pod install
|
||||||
|
|
||||||
- name: Install Fastlane
|
|
||||||
working-directory: ./mobile/ios
|
|
||||||
run: |
|
|
||||||
gem install bundler
|
|
||||||
bundle config set --local path 'vendor/bundle'
|
|
||||||
bundle install
|
|
||||||
|
|
||||||
- name: Create API Key
|
- 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 }}
|
||||||
@@ -246,35 +228,14 @@ jobs:
|
|||||||
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
|
||||||
|
|
||||||
- name: Import Certificate and Provisioning Profiles
|
- name: Import Certificate
|
||||||
env:
|
env:
|
||||||
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
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 }}
|
|
||||||
ENVIRONMENT: ${{ inputs.environment || 'development' }}
|
|
||||||
working-directory: ./mobile/ios
|
working-directory: ./mobile/ios
|
||||||
run: |
|
run: |
|
||||||
# Decode certificate
|
# Decode certificate
|
||||||
echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
|
echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
|
||||||
|
|
||||||
# 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
|
- name: Create keychain and import certificate
|
||||||
env:
|
env:
|
||||||
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||||
@@ -325,7 +286,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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: ios-release-ipa
|
name: ios-release-ipa
|
||||||
path: mobile/ios/Runner.ipa
|
path: mobile/ios/Runner.ipa
|
||||||
|
|||||||
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
10
.github/workflows/cli.yml
vendored
10
.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './cli/.nvmrc'
|
node-version-file: './cli/.nvmrc'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
@@ -78,7 +78,7 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.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@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
@@ -105,7 +105,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate docker image tags
|
- name: Generate docker image tags
|
||||||
id: metadata
|
id: metadata
|
||||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
with:
|
with:
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=false
|
latest=false
|
||||||
|
|||||||
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:9c905a4ff69f00c4b2f98b40b6090ab3ab18d1a15ed1379733b8691aa1fcb271
|
image: ghcr.io/immich-app/mdq:main@sha256:ab9f163cd5d5cec42704a26ca2769ecf3f10aa8e7bae847f1d527cdf075946e6
|
||||||
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.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@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||||
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@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||||
|
|
||||||
# ℹ️ 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@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||||
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@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
|
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@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
|
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
|
||||||
|
|||||||
7
.github/workflows/docs-build.yml
vendored
7
.github/workflows/docs-build.yml
vendored
@@ -60,16 +60,17 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './docs/.nvmrc'
|
node-version-file: './docs/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -85,7 +86,7 @@ jobs:
|
|||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Upload build output
|
- name: Upload build output
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: docs-build-output
|
name: docs-build-output
|
||||||
path: docs/build/
|
path: docs/build/
|
||||||
|
|||||||
4
.github/workflows/docs-deploy.yml
vendored
4
.github/workflows/docs-deploy.yml
vendored
@@ -125,13 +125,13 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
|
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
|
||||||
|
|
||||||
- name: Load parameters
|
- name: Load parameters
|
||||||
id: parameters
|
id: parameters
|
||||||
|
|||||||
4
.github/workflows/docs-destroy.yml
vendored
4
.github/workflows/docs-destroy.yml
vendored
@@ -23,13 +23,13 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
|
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
|
||||||
|
|
||||||
- name: Destroy Docs Subdomain
|
- name: Destroy Docs Subdomain
|
||||||
env:
|
env:
|
||||||
|
|||||||
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@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: 'Checkout'
|
- name: 'Checkout'
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.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 }}
|
||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
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@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|||||||
28
.github/workflows/prepare-release.yml
vendored
28
.github/workflows/prepare-release.yml
vendored
@@ -45,30 +45,31 @@ jobs:
|
|||||||
needs: [merge_translations]
|
needs: [merge_translations]
|
||||||
outputs:
|
outputs:
|
||||||
ref: ${{ steps.push-tag.outputs.commit_long_sha }}
|
ref: ${{ steps.push-tag.outputs.commit_long_sha }}
|
||||||
|
version: ${{ steps.output.outputs.version }}
|
||||||
permissions: {} # No job-level permissions are needed because it uses the app-token
|
permissions: {} # No job-level permissions are needed because it uses the app-token
|
||||||
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@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.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@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -80,13 +81,16 @@ jobs:
|
|||||||
MOBILE_BUMP: ${{ inputs.mobileBump }}
|
MOBILE_BUMP: ${{ inputs.mobileBump }}
|
||||||
run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
|
run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
|
||||||
|
|
||||||
|
- id: output
|
||||||
|
run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Commit and tag
|
- name: Commit and tag
|
||||||
id: push-tag
|
id: push-tag
|
||||||
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
||||||
with:
|
with:
|
||||||
default_author: github_actions
|
default_author: github_actions
|
||||||
message: 'chore: version ${{ env.IMMICH_VERSION }}'
|
message: 'chore: version ${{ steps.output.outputs.version }}'
|
||||||
tag: ${{ env.IMMICH_VERSION }}
|
tag: ${{ steps.output.outputs.version }}
|
||||||
push: true
|
push: true
|
||||||
|
|
||||||
build_mobile:
|
build_mobile:
|
||||||
@@ -119,35 +123,35 @@ jobs:
|
|||||||
|
|
||||||
prepare_release:
|
prepare_release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build_mobile
|
needs: [build_mobile, bump_version]
|
||||||
permissions:
|
permissions:
|
||||||
actions: read # To download the app artifact
|
actions: read # To download the app artifact
|
||||||
# No content permissions are needed because it uses the app-token
|
# No content permissions are needed because it uses the app-token
|
||||||
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@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.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@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: release-apk-signed
|
name: release-apk-signed
|
||||||
github-token: ${{ steps.generate-token.outputs.token }}
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|
||||||
- name: Create draft release
|
- name: Create draft release
|
||||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
tag_name: ${{ env.IMMICH_VERSION }}
|
tag_name: ${{ needs.bump_version.outputs.version }}
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
body_path: misc/release/notes.tmpl
|
body_path: misc/release/notes.tmpl
|
||||||
|
|||||||
10
.github/workflows/release-pr.yml
vendored
10
.github/workflows/release-pr.yml
vendored
@@ -17,26 +17,26 @@ 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@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.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@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -159,7 +159,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create PR
|
- name: Create PR
|
||||||
id: create-pr
|
id: create-pr
|
||||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||||
|
|||||||
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@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.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@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
|
with:
|
||||||
|
name: release-apk-signed
|
||||||
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Create draft release
|
||||||
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.version.outputs.result }}
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
body_path: ${{ steps.changelog.outputs.path }}
|
||||||
|
draft: true
|
||||||
|
files: |
|
||||||
|
docker/docker-compose.yml
|
||||||
|
docker/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');
|
||||||
|
}
|
||||||
4
.github/workflows/sdk.yml
vendored
4
.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './open-api/typescript-sdk/.nvmrc'
|
node-version-file: './open-api/typescript-sdk/.nvmrc'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|||||||
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
79
.github/workflows/test.yml
vendored
79
.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -114,14 +114,14 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './cli/.nvmrc'
|
node-version-file: './cli/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -161,14 +161,14 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './cli/.nvmrc'
|
node-version-file: './cli/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -203,14 +203,14 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './web/.nvmrc'
|
node-version-file: './web/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -247,14 +247,14 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './web/.nvmrc'
|
node-version-file: './web/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -285,22 +285,22 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './web/.nvmrc'
|
node-version-file: './web/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm --filter=immich-web install --frozen-lockfile
|
run: pnpm --filter=immich-i18n install --frozen-lockfile
|
||||||
- name: Format
|
- name: Format
|
||||||
run: pnpm --filter=immich-web format:i18n
|
run: pnpm --filter=immich-i18n format:fix
|
||||||
- name: Find file changes
|
- name: Find file changes
|
||||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||||
id: verify-changed-files
|
id: verify-changed-files
|
||||||
@@ -333,14 +333,14 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './e2e/.nvmrc'
|
node-version-file: './e2e/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -379,7 +379,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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
@@ -387,7 +387,7 @@ jobs:
|
|||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -418,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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
@@ -426,7 +426,7 @@ jobs:
|
|||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './e2e/.nvmrc'
|
node-version-file: './e2e/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -473,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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
@@ -481,7 +481,7 @@ jobs:
|
|||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './e2e/.nvmrc'
|
node-version-file: './e2e/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -500,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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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]
|
||||||
@@ -526,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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -558,17 +566,14 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.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@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||||
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
with:
|
||||||
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
|
python-version: 3.11
|
||||||
# with:
|
|
||||||
# python-version: 3.11
|
|
||||||
# cache: 'uv'
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
uv sync --extra cpu
|
uv sync --extra cpu
|
||||||
@@ -602,14 +607,14 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './.github/.nvmrc'
|
node-version-file: './.github/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -631,7 +636,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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -653,14 +658,14 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -715,14 +720,14 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
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",
|
||||||
|
|||||||
31
CONTRIBUTING.md
Normal file
31
CONTRIBUTING.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Contributing to Immich
|
||||||
|
|
||||||
|
We appreciate every contribution, and we're happy about every new contributor. So please feel invited to help make Immich a better product!
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
To get you started quickly we have detailed guides for the dev setup on our [website](https://docs.immich.app/developer/setup). If you prefer, you can also use [Devcontainers](https://docs.immich.app/developer/devcontainers).
|
||||||
|
There are also additional resources about Immich's architecture, database migrations, the use of OpenAPI, and more in our [developer documentation](https://docs.immich.app/developer/architecture).
|
||||||
|
|
||||||
|
## General
|
||||||
|
|
||||||
|
Please try to keep pull requests as focused as possible. A PR should do exactly one thing and not bleed into other, unrelated areas. The smaller a PR, the fewer changes are likely needed, and the quicker it will likely be merged. For larger/more impactful PRs, please reach out to us first to discuss your plans. The best way to do this is through our [Discord](https://discord.immich.app). We have a dedicated `#contributing` channel there. Additionally, please fill out the entire template when opening a PR.
|
||||||
|
|
||||||
|
## Finding work
|
||||||
|
|
||||||
|
If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on!
|
||||||
|
|
||||||
|
## Use of generative AI
|
||||||
|
|
||||||
|
We generally discourage PRs entirely generated by an LLM. For any part generated by an LLM, please put extra effort into your self-review. By using generative AI without proper self-review, the time you save ends up being more work we need to put in for proper reviews and code cleanup. Please keep that in mind when submitting code by an LLM. Clearly state the use of LLMs/(generative) AI in your pull request as requested by the template.
|
||||||
|
|
||||||
|
## Feature freezes
|
||||||
|
|
||||||
|
From time to time, we put a feature freeze on parts of the codebase. For us, this means we won't accept most PRs that make changes in that area. Exempted from this are simple bug fixes that require only minor changes. We will close feature PRs that target a feature-frozen area, even if that feature is highly requested and you put a lot of work into it. Please keep that in mind, and if you're ever uncertain if a PR would be accepted, reach out to us first (e.g., in the aforementioned `#contributing` channel). We hate to throw away work. Currently, we have feature freezes on:
|
||||||
|
|
||||||
|
* Sharing/Asset ownership
|
||||||
|
* (External) libraries
|
||||||
|
|
||||||
|
## Non-code contributions
|
||||||
|
|
||||||
|
If you want to contribute to Immich but you don't feel comfortable programming in our tech stack, there are other ways you can help the team. All our translations are done through [Weblate](https://hosted.weblate.org/projects/immich). These rely entirely on the community; if you speak a language that isn't fully translated yet, submitting translations there is greatly appreciated! If you like helping others, answering Q&A discussions here on GitHub and replying to people on our Discord is also always appreciated.
|
||||||
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 @@
|
|||||||
24.11.0
|
24.13.0
|
||||||
|
|||||||
@@ -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 ./
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.101",
|
"version": "2.2.105",
|
||||||
"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.19.1",
|
"@types/node": "^24.10.8",
|
||||||
"@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,15 +28,15 @@
|
|||||||
"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.7.4",
|
||||||
"prettier-plugin-organize-imports": "^4.0.0",
|
"prettier-plugin-organize-imports": "^4.0.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"typescript-eslint": "^8.28.0",
|
"typescript-eslint": "^8.28.0",
|
||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0",
|
||||||
"vite-tsconfig-paths": "^5.0.0",
|
"vite-tsconfig-paths": "^6.0.0",
|
||||||
"vitest": "^3.0.0",
|
"vitest": "^3.0.0",
|
||||||
"vitest-fetch-mock": "^0.4.0",
|
"vitest-fetch-mock": "^0.4.0",
|
||||||
"yaml": "^2.3.1"
|
"yaml": "^2.3.1"
|
||||||
@@ -69,6 +69,6 @@
|
|||||||
"micromatch": "^4.0.8"
|
"micromatch": "^4.0.8"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.11.0"
|
"node": "24.13.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tools]
|
[tools]
|
||||||
terragrunt = "0.91.2"
|
terragrunt = "0.93.10"
|
||||||
opentofu = "1.10.6"
|
opentofu = "1.10.7"
|
||||||
|
|
||||||
[tasks."tg:fmt"]
|
[tasks."tg:fmt"]
|
||||||
run = "terragrunt hclfmt"
|
run = "terragrunt hclfmt"
|
||||||
|
|||||||
@@ -58,10 +58,6 @@ services:
|
|||||||
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
|
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
|
||||||
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
|
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
|
||||||
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
|
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
|
||||||
ulimits:
|
|
||||||
nofile:
|
|
||||||
soft: 1048576
|
|
||||||
hard: 1048576
|
|
||||||
ports:
|
ports:
|
||||||
- 9230:9230
|
- 9230:9230
|
||||||
- 9231:9231
|
- 9231:9231
|
||||||
@@ -100,10 +96,6 @@ 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
|
||||||
ulimits:
|
|
||||||
nofile:
|
|
||||||
soft: 1048576
|
|
||||||
hard: 1048576
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
immich-server:
|
immich-server:
|
||||||
@@ -135,7 +127,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:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
@@ -154,6 +146,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
shm_size: 128mb
|
shm_size: 128mb
|
||||||
|
healthcheck:
|
||||||
|
disable: false
|
||||||
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
|
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
|
||||||
# immich-prometheus:
|
# immich-prometheus:
|
||||||
# container_name: immich_prometheus
|
# container_name: immich_prometheus
|
||||||
|
|||||||
@@ -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:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
@@ -77,13 +77,15 @@ services:
|
|||||||
- 5432:5432
|
- 5432:5432
|
||||||
shm_size: 128mb
|
shm_size: 128mb
|
||||||
restart: always
|
restart: always
|
||||||
|
healthcheck:
|
||||||
|
disable: false
|
||||||
|
|
||||||
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
|
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
|
||||||
immich-prometheus:
|
immich-prometheus:
|
||||||
container_name: immich_prometheus
|
container_name: immich_prometheus
|
||||||
ports:
|
ports:
|
||||||
- 9090:9090
|
- 9090:9090
|
||||||
image: prom/prometheus@sha256:49214755b6153f90a597adcbff0252cc61069f8ab69ce8411285cd4a560e8038
|
image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
- prometheus-data:/prometheus
|
- prometheus-data:/prometheus
|
||||||
@@ -95,7 +97,7 @@ services:
|
|||||||
command: ['./run.sh', '-disable-reporting']
|
command: ['./run.sh', '-disable-reporting']
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- 3000:3000
|
||||||
image: grafana/grafana:12.2.1-ubuntu@sha256:797530c642f7b41ba7848c44cfda5e361ef1f3391a98bed1e5d448c472b6826a
|
image: grafana/grafana:12.3.1-ubuntu@sha256:d57f1365197aec34c4d80869d8ca45bb7787c7663904950dab214dfb40c1c2fd
|
||||||
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:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
@@ -69,6 +69,8 @@ services:
|
|||||||
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
||||||
shm_size: 128mb
|
shm_size: 128mb
|
||||||
restart: always
|
restart: always
|
||||||
|
healthcheck:
|
||||||
|
disable: false
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
model-cache:
|
model-cache:
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
24.11.0
|
24.13.0
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ For organizations seeking to resell Immich, we have established the following gu
|
|||||||
|
|
||||||
- Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team.
|
- Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team.
|
||||||
|
|
||||||
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directly from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
|
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase product keys directly from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
|
||||||
|
|
||||||
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app
|
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Immich is known to work with Postgres versions `>= 14, < 19`.
|
|||||||
VectorChord is known to work with pgvector versions `>= 0.7, < 0.9`.
|
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 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`.
|
The current accepted range for VectorChord is `>= 0.3, < 2.0`.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Specifying the connection URL
|
## Specifying the connection URL
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ 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;
|
||||||
|
|
||||||
|
# increase body buffer to avoid limiting upload speed
|
||||||
|
client_body_buffer_size 1024k;
|
||||||
|
|
||||||
# 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;
|
||||||
@@ -29,8 +35,6 @@ server {
|
|||||||
|
|
||||||
# enable websockets: http://nginx.org/en/docs/http/websocket.html
|
# enable websockets: http://nginx.org/en/docs/http/websocket.html
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
|
|
||||||
# set timeout
|
# set timeout
|
||||||
@@ -40,6 +44,8 @@ server {
|
|||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://<backend_url>:2283;
|
proxy_pass http://<backend_url>:2283;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
}
|
}
|
||||||
|
|
||||||
# useful when using Let's Encrypt http-01 challenge
|
# useful when using Let's Encrypt http-01 challenge
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ Password login has been enabled.
|
|||||||
Disable Maintenance Mode
|
Disable Maintenance Mode
|
||||||
|
|
||||||
```
|
```
|
||||||
immich-admin disable-maintenace-mode
|
immich-admin disable-maintenance-mode
|
||||||
Maintenance mode has been disabled.
|
Maintenance mode has been disabled.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ The Dev Container supports multiple ways to run tests:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run tests for specific components
|
# Run tests for specific components
|
||||||
make test-server # Server unit tests
|
make test-server # Server unit tests
|
||||||
make test-web # Web unit tests
|
make test-web # Web unit tests
|
||||||
make test-e2e # End-to-end tests
|
make test-e2e # End-to-end tests
|
||||||
make test-cli # CLI tests
|
make test-cli # CLI tests
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -293,21 +294,21 @@ pnpm run test:web # Run web UI tests
|
|||||||
```bash
|
```bash
|
||||||
# Linting
|
# Linting
|
||||||
make lint-server # Lint server code
|
make lint-server # Lint server code
|
||||||
make lint-web # Lint web code
|
make lint-web # Lint web code
|
||||||
make lint-all # Lint all components
|
make lint-all # Lint all components
|
||||||
|
|
||||||
# Formatting
|
# Formatting
|
||||||
make format-server # Format server code
|
make format-server # Format server code
|
||||||
make format-web # Format web code
|
make format-web # Format web code
|
||||||
make format-all # Format all code
|
make format-all # Format all code
|
||||||
|
|
||||||
# Type checking
|
# Type checking
|
||||||
make check-server # Type check server
|
make check-server # Type check server
|
||||||
make check-web # Type check web
|
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
|
||||||
@@ -315,21 +316,21 @@ make hygiene-all # Runs lint, format, check, SQL sync, and audit
|
|||||||
```bash
|
```bash
|
||||||
# Build commands
|
# Build commands
|
||||||
make build-server # Build server
|
make build-server # Build server
|
||||||
make build-web # Build web app
|
make build-web # Build web app
|
||||||
make build-all # Build everything
|
make build-all # Build everything
|
||||||
|
|
||||||
# API generation
|
# API generation
|
||||||
make open-api # Generate OpenAPI specs
|
make open-api # Generate OpenAPI specs
|
||||||
make open-api-typescript # Generate TypeScript SDK
|
make open-api-typescript # Generate TypeScript SDK
|
||||||
make open-api-dart # Generate Dart SDK
|
make open-api-dart # Generate Dart SDK
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
make sql # Sync database schema
|
make sql # Sync database schema
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
make install-server # Install server dependencies
|
make install-server # Install server dependencies
|
||||||
make install-web # Install web dependencies
|
make install-web # Install web dependencies
|
||||||
make install-all # Install all dependencies
|
make install-all # Install all dependencies
|
||||||
```
|
```
|
||||||
|
|
||||||
### Debugging
|
### Debugging
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ sidebar_position: 2
|
|||||||
|
|
||||||
# Setup
|
# Setup
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
Make sure to read the [`CONTRIBUTING.md`](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md) before you dive into the code.
|
||||||
|
:::
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
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:
|
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:
|
||||||
|
|
||||||
@@ -48,7 +52,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:
|
||||||
|
|||||||
@@ -71,6 +71,22 @@ For RKMPP to work:
|
|||||||
|
|
||||||
5. (Optional) Enable hardware decoding for optimal performance.
|
5. (Optional) Enable hardware decoding for optimal performance.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>immich.json</summary>
|
||||||
|
|
||||||
|
If you use a [configuration file](/install/config-file.md), use the `accel` option to select the hardware (e.g. `qsv` for Intel or `nvenc` for Nvidia). Set `accelDecode` to `true` if you want hardware decoding.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ffmpeg": {
|
||||||
|
"accel": "qsv",
|
||||||
|
"accelDecode": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
#### Single Compose File
|
#### Single Compose File
|
||||||
|
|
||||||
Some platforms, including Unraid and Portainer, do not support multiple Compose files as of writing. As an alternative, you can "inline" the relevant contents of the [`hwaccel.transcoding.yml`][hw-file] file into the `immich-server` service directly.
|
Some platforms, including Unraid and Portainer, do not support multiple Compose files as of writing. As an alternative, you can "inline" the relevant contents of the [`hwaccel.transcoding.yml`][hw-file] file into the `immich-server` service directly.
|
||||||
|
|||||||
@@ -95,11 +95,3 @@ Enter the cloud on the top right -> cog wheel on the top right -> select the syn
|
|||||||
If you delete/move photos in the local album on your device, it will not be reflected in the album on the server **even if** you click Sync albums
|
If you delete/move photos in the local album on your device, it will not be reflected in the album on the server **even if** you click Sync albums
|
||||||
It will only reflect files you add.
|
It will only reflect files you add.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
If the same asset is in more than one album it will only sync to the first album it's in, after that it won't sync again even if the user clicks sync albums manually.
|
|
||||||
To overcome this limitation, the files must be removed from the ignore list by
|
|
||||||
App settings -> Advanced -> Duplicate Assets -> Clear
|
|
||||||
|
|
||||||
:::info
|
|
||||||
Cleaning duplicate assets from the list will cause all the previously uploaded duplicate files to be re-uploaded, the files will not actually be uploaded and will be rejected on the server side (due to duplication) but will be synchronized to the album and at the end will be added to the ignore list again at the end of the synchronization.
|
|
||||||
:::
|
|
||||||
|
|||||||
@@ -112,4 +112,40 @@ You can then make a new panel, specifying Prometheus as the data source for it.
|
|||||||
|
|
||||||
-- TODO: add images and more details here
|
-- TODO: add images and more details here
|
||||||
|
|
||||||
|
## Structured Logging
|
||||||
|
|
||||||
|
In addition to Prometheus metrics, Immich supports structured JSON logging which is ideal for log aggregation systems like Grafana Loki, ELK Stack, Datadog, Splunk, and others.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
By default, Immich outputs human-readable console logs. To enable JSON logging, set the `IMMICH_LOG_FORMAT` environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IMMICH_LOG_FORMAT=json
|
||||||
|
```
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
The default is `IMMICH_LOG_FORMAT=console` for human-readable logs with colors during development. For production deployments using log aggregation, use `IMMICH_LOG_FORMAT=json`.
|
||||||
|
:::
|
||||||
|
|
||||||
|
### JSON Log Format
|
||||||
|
|
||||||
|
When enabled, logs are output in structured JSON format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"level":"log","pid":36,"timestamp":1766533331507,"message":"Initialized websocket server","context":"WebsocketRepository"}
|
||||||
|
{"level":"warn","pid":48,"timestamp":1766533331629,"message":"Unable to open /build/www/index.html, skipping SSR.","context":"ApiService"}
|
||||||
|
{"level":"error","pid":36,"timestamp":1766533331690,"message":"Failed to load plugin immich-core:","context":"Error"}
|
||||||
|
```
|
||||||
|
|
||||||
|
This format includes:
|
||||||
|
|
||||||
|
- `level`: Log level (log, warn, error, etc.)
|
||||||
|
- `pid`: Process ID
|
||||||
|
- `timestamp`: Unix timestamp in milliseconds
|
||||||
|
- `message`: Log message
|
||||||
|
- `context`: Service or component that generated the log
|
||||||
|
|
||||||
|
For more information on log formats, see [`IMMICH_LOG_FORMAT`](/install/environment-variables.md#general).
|
||||||
|
|
||||||
[prom-file]: https://github.com/immich-app/immich/releases/latest/download/prometheus.yml
|
[prom-file]: https://github.com/immich-app/immich/releases/latest/download/prometheus.yml
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ You can create a public link to share a group of photos or videos, or an album,
|
|||||||
The public shared link is generated with a random URL, which acts as as a secret to avoid the link being guessed by unwanted parties, for instance.
|
The public shared link is generated with a random URL, which acts as as a secret to avoid the link being guessed by unwanted parties, for instance.
|
||||||
|
|
||||||
```
|
```
|
||||||
https://immich.yourdomain.com/share/JUckRMxlgpo7F9BpyqGk_cZEwDzaU_U5LU5_oNZp1ETIBa9dpQ0b5ghNm_22QVJfn3k
|
https://my.immich.app/share/JUckRMxlgpo7F9BpyqGk_cZEwDzaU_U5LU5_oNZp1ETIBa9dpQ0b5ghNm_22QVJfn3k
|
||||||
```
|
```
|
||||||
|
|
||||||
### Creating a public share link
|
### Creating a public share link
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
|
|||||||
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
|
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
|
||||||
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
|
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
|
||||||
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
|
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
|
||||||
|
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
|
||||||
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
|
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
|
||||||
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
|
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
|
||||||
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
|
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
|
||||||
@@ -43,6 +44,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
|
|||||||
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
|
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
|
||||||
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
|
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
|
||||||
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
|
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
|
||||||
|
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
|
||||||
|
|
||||||
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
|
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
|
||||||
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.
|
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.
|
||||||
@@ -62,10 +64,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 +82,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 +95,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.
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,17 @@ Hardware and software requirements for Immich:
|
|||||||
- Immich runs well in a virtualized environment when running in a full virtual machine.
|
- Immich runs well in a virtualized environment when running in a full virtual machine.
|
||||||
The use of Docker in LXC containers is [not recommended](https://pve.proxmox.com/wiki/Linux_Container), but may be possible for advanced users.
|
The use of Docker in LXC containers is [not recommended](https://pve.proxmox.com/wiki/Linux_Container), but may be possible for advanced users.
|
||||||
If you have issues, we recommend that you switch to a supported VM deployment.
|
If you have issues, we recommend that you switch to a supported VM deployment.
|
||||||
- **RAM**: Minimum 4GB, recommended 6GB.
|
- **RAM**: Minimum 6GB, recommended 8GB.
|
||||||
- **CPU**: Minimum 2 cores, recommended 4 cores.
|
- **CPU**: Minimum 2 cores, recommended 4 cores.
|
||||||
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
|
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
|
||||||
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
|
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
|
||||||
|
|
||||||
:::tip
|
:::note RAM requirements
|
||||||
|
For a smooth experience, especially during asset upload, Immich requires at least 6GB of RAM.
|
||||||
|
For systems with only 4GB of RAM, Immich can be run with machine learning features disabled.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::tip Postgres setup
|
||||||
Good performance and a stable connection to the Postgres database is critical to a smooth Immich experience.
|
Good performance and a stable connection to the Postgres database is critical to a smooth Immich experience.
|
||||||
The Postgres database files are typically between 1-3 GB in size.
|
The Postgres database files are typically between 1-3 GB in size.
|
||||||
For this reason, the Postgres database (`DB_DATA_LOCATION`) should ideally use local SSD storage, and never a network share of any kind.
|
For this reason, the Postgres database (`DB_DATA_LOCATION`) should ideally use local SSD storage, and never a network share of any kind.
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ const config = {
|
|||||||
locales: ['en'],
|
locales: ['en'],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Mermaid diagrams
|
||||||
|
markdown: {
|
||||||
|
mermaid: true,
|
||||||
|
},
|
||||||
|
themes: ['@docusaurus/theme-mermaid'],
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
async function myPlugin(context, options) {
|
async function myPlugin(context, options) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@docusaurus/core": "~3.9.0",
|
"@docusaurus/core": "~3.9.0",
|
||||||
"@docusaurus/preset-classic": "~3.9.0",
|
"@docusaurus/preset-classic": "~3.9.0",
|
||||||
"@docusaurus/theme-common": "~3.9.0",
|
"@docusaurus/theme-common": "~3.9.0",
|
||||||
|
"@docusaurus/theme-mermaid": "~3.9.0",
|
||||||
"@mdi/js": "^7.3.67",
|
"@mdi/js": "^7.3.67",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mdi/react": "^1.6.1",
|
||||||
"@mdx-js/react": "^3.0.0",
|
"@mdx-js/react": "^3.0.0",
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
"@docusaurus/module-type-aliases": "~3.9.0",
|
"@docusaurus/module-type-aliases": "~3.9.0",
|
||||||
"@docusaurus/tsconfig": "^3.7.0",
|
"@docusaurus/tsconfig": "^3.7.0",
|
||||||
"@docusaurus/types": "^3.7.0",
|
"@docusaurus/types": "^3.7.0",
|
||||||
"prettier": "^3.2.4",
|
"prettier": "^3.7.4",
|
||||||
"typescript": "^5.1.6"
|
"typescript": "^5.1.6"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
@@ -57,6 +58,6 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.11.0"
|
"node": "24.13.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,19 +8,19 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Overpass';
|
font-family: 'GoogleSans';
|
||||||
src: url('/fonts/overpass/Overpass.ttf') format('truetype-variations');
|
src: url('/fonts/GoogleSans/GoogleSans.ttf') format('truetype-variations');
|
||||||
font-weight: 1 999;
|
font-weight: 410 900;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
ascent-override: 106.25%;
|
ascent-override: 106.25%;
|
||||||
size-adjust: 106.25%;
|
size-adjust: 106.25%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Overpass Mono';
|
font-family: 'GoogleSansCode';
|
||||||
src: url('/fonts/overpass/OverpassMono.ttf') format('truetype-variations');
|
src: url('/fonts/GoogleSansCode/GoogleSansCode.ttf') format('truetype-variations');
|
||||||
font-weight: 1 999;
|
font-weight: 1 900;
|
||||||
font-style: normal;
|
font-style: monospace;
|
||||||
ascent-override: 106.25%;
|
ascent-override: 106.25%;
|
||||||
size-adjust: 106.25%;
|
size-adjust: 106.25%;
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,8 @@ img {
|
|||||||
|
|
||||||
/* You can override the default Infima variables here. */
|
/* You can override the default Infima variables here. */
|
||||||
:root {
|
:root {
|
||||||
font-family: 'Overpass', sans-serif;
|
font-family: 'GoogleSans', sans-serif;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
--ifm-color-primary: #4250af;
|
--ifm-color-primary: #4250af;
|
||||||
--ifm-color-primary-dark: #4250af;
|
--ifm-color-primary-dark: #4250af;
|
||||||
--ifm-color-primary-darker: #4250af;
|
--ifm-color-primary-darker: #4250af;
|
||||||
@@ -48,6 +49,16 @@ img {
|
|||||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
|
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-family: 'GoogleSans', sans-serif;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||||
[data-theme='dark'] {
|
[data-theme='dark'] {
|
||||||
--ifm-color-primary: #adcbfa;
|
--ifm-color-primary: #adcbfa;
|
||||||
@@ -71,15 +82,22 @@ div[class^='announcementBar_'] {
|
|||||||
padding: 10px 10px 10px 16px;
|
padding: 10px 10px 10px 16px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
margin-right: 16px;
|
margin-right: 16px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu__list-item-collapsible {
|
.menu__list-item-collapsible {
|
||||||
margin-right: 16px;
|
margin-right: 16px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu__link--active {
|
.menu__link--active {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-of-contents__link {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 450;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* workaround for version switcher PR 15894 */
|
/* workaround for version switcher PR 15894 */
|
||||||
@@ -88,13 +106,14 @@ div[class*='navbar__items'] > li:has(a[class*='version-switcher-34ab39']) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
|
font-family: 'GoogleSansCode';
|
||||||
}
|
}
|
||||||
|
|
||||||
.buy-button {
|
.buy-button {
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
font-family: 'Overpass', sans-serif;
|
font-family: 'GoogleSans', sans-serif;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 0 5px 2px rgba(181, 206, 254, 0.4);
|
box-shadow: 0 0 5px 2px rgba(181, 206, 254, 0.4);
|
||||||
|
|||||||
16
docs/static/archived-versions.json
vendored
16
docs/static/archived-versions.json
vendored
@@ -1,4 +1,20 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"label": "v2.4.1",
|
||||||
|
"url": "https://docs.v2.4.1.archive.immich.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "v2.4.0",
|
||||||
|
"url": "https://docs.v2.4.0.archive.immich.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"label": "v2.2.3",
|
||||||
"url": "https://docs.v2.2.3.archive.immich.app"
|
"url": "https://docs.v2.2.3.archive.immich.app"
|
||||||
|
|||||||
BIN
docs/static/fonts/GoogleSans/GoogleSans.ttf
vendored
Normal file
BIN
docs/static/fonts/GoogleSans/GoogleSans.ttf
vendored
Normal file
Binary file not shown.
BIN
docs/static/fonts/GoogleSansCode/GoogleSansCode.ttf
vendored
Normal file
BIN
docs/static/fonts/GoogleSansCode/GoogleSansCode.ttf
vendored
Normal file
Binary file not shown.
BIN
docs/static/fonts/overpass/Overpass-Italic.ttf
vendored
BIN
docs/static/fonts/overpass/Overpass-Italic.ttf
vendored
Binary file not shown.
BIN
docs/static/fonts/overpass/Overpass.ttf
vendored
BIN
docs/static/fonts/overpass/Overpass.ttf
vendored
Binary file not shown.
BIN
docs/static/fonts/overpass/OverpassMono.ttf
vendored
BIN
docs/static/fonts/overpass/OverpassMono.ttf
vendored
Binary file not shown.
6
e2e-auth-server/Dockerfile
Normal file
6
e2e-auth-server/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25
|
||||||
|
RUN corepack enable
|
||||||
|
ADD package.json *.ts ./
|
||||||
|
RUN pnpm install
|
||||||
|
EXPOSE 2286
|
||||||
|
CMD ["pnpm", "run", "start"]
|
||||||
@@ -125,7 +125,7 @@ const setup = async () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const onStart = () => console.log(`[auth-server] http://${host}:${port}/.well-known/openid-configuration`);
|
const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`);
|
||||||
const app = oidc.listen(port, host, onStart);
|
const app = oidc.listen(port, host, onStart);
|
||||||
return () => app.close();
|
return () => app.close();
|
||||||
};
|
};
|
||||||
15
e2e-auth-server/package.json
Normal file
15
e2e-auth-server/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@immich/e2e-auth-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "auth-server.ts",
|
||||||
|
"scripts": {
|
||||||
|
"start": "tsx startup.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jose": "^5.6.3",
|
||||||
|
"@types/oidc-provider": "^9.0.0",
|
||||||
|
"oidc-provider": "^9.0.0",
|
||||||
|
"tsx": "^4.20.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
e2e-auth-server/startup.ts
Normal file
8
e2e-auth-server/startup.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import setup from './auth-server'
|
||||||
|
|
||||||
|
const teardown = await setup()
|
||||||
|
process.on('exit', () => {
|
||||||
|
teardown()
|
||||||
|
console.log('[e2e-auth-server] stopped')
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
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 @@
|
|||||||
24.11.0
|
24.13.0
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
name: immich-e2e
|
name: immich-e2e
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
e2e-auth-server:
|
||||||
|
build:
|
||||||
|
context: ../e2e-auth-server
|
||||||
|
ports:
|
||||||
|
- 2286:2286
|
||||||
|
|
||||||
immich-server:
|
immich-server:
|
||||||
container_name: immich-e2e-server
|
container_name: immich-e2e-server
|
||||||
image: immich-server:latest
|
image: immich-server:latest
|
||||||
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
|
||||||
@@ -24,8 +33,6 @@ services:
|
|||||||
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
|
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
|
||||||
volumes:
|
volumes:
|
||||||
- ./test-assets:/test-assets
|
- ./test-assets:/test-assets
|
||||||
extra_hosts:
|
|
||||||
- 'auth-server:host-gateway'
|
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "2.2.3",
|
"version": "2.4.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -20,30 +20,30 @@
|
|||||||
"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/e2e-auth-server": "file:../e2e-auth-server",
|
||||||
"@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.19.1",
|
"@types/node": "^24.10.8",
|
||||||
"@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": "^34.3.0",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"jose": "^5.6.3",
|
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
"oidc-provider": "^9.0.0",
|
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.7.4",
|
||||||
"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 +52,6 @@
|
|||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.11.0"
|
"node": "24.13.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
350
e2e/src/api/specs/database-backups.e2e-spec.ts
Normal file
350
e2e/src/api/specs/database-backups.e2e-spec.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import { LoginResponseDto, ManualJobName } from '@immich/sdk';
|
||||||
|
import { errorDto } from 'src/responses';
|
||||||
|
import { app, utils } from 'src/utils';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('/admin/database-backups', () => {
|
||||||
|
let cookie: string | undefined;
|
||||||
|
let admin: LoginResponseDto;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await utils.resetDatabase();
|
||||||
|
admin = await utils.adminSetup();
|
||||||
|
await utils.resetBackups(admin.accessToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /', async () => {
|
||||||
|
it('should succeed and be empty', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.get('/admin/database-backups')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual({
|
||||||
|
backups: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain a created backup', async () => {
|
||||||
|
await utils.createJob(admin.accessToken, {
|
||||||
|
name: ManualJobName.BackupDatabase,
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'backupDatabase');
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.get('/admin/database-backups')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
backups: [
|
||||||
|
expect.objectContaining({
|
||||||
|
filename: expect.stringMatching(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/),
|
||||||
|
filesize: expect.any(Number),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /', async () => {
|
||||||
|
it('should delete backup', async () => {
|
||||||
|
const filename = await utils.createBackup(admin.accessToken);
|
||||||
|
|
||||||
|
const { status } = await request(app)
|
||||||
|
.delete(`/admin/database-backups`)
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({ backups: [filename] });
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
|
||||||
|
const { status: listStatus, body } = await request(app)
|
||||||
|
.get('/admin/database-backups')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
|
||||||
|
expect(listStatus).toBe(200);
|
||||||
|
expect(body).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
backups: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// => action: restore database flow
|
||||||
|
|
||||||
|
describe.sequential('POST /start-restore', () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({ action: 'end' });
|
||||||
|
await utils.poll(
|
||||||
|
() => request(app).get('/server/config'),
|
||||||
|
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||||
|
);
|
||||||
|
|
||||||
|
admin = await utils.adminSetup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.sequential('should not work when the server is configured', async () => {
|
||||||
|
const { status, body } = await request(app).post('/admin/database-backups/start-restore').send();
|
||||||
|
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest('The server already has an admin'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.sequential('should enter maintenance mode in "database restore mode"', async () => {
|
||||||
|
await utils.resetDatabase(); // reset database before running this test
|
||||||
|
|
||||||
|
const { status, headers } = await request(app).post('/admin/database-backups/start-restore').send();
|
||||||
|
|
||||||
|
expect(status).toBe(201);
|
||||||
|
|
||||||
|
cookie = headers['set-cookie'][0].split(';')[0];
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeTruthy();
|
||||||
|
|
||||||
|
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||||
|
expect(status2).toBe(200);
|
||||||
|
expect(body).toEqual({
|
||||||
|
active: true,
|
||||||
|
action: 'select_database_restore',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// => action: restore database
|
||||||
|
|
||||||
|
describe.sequential('POST /backups/restore', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await utils.disconnectDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await utils.connectDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.sequential('should restore a backup', { timeout: 60_000 }, async () => {
|
||||||
|
let filename = await utils.createBackup(admin.accessToken);
|
||||||
|
|
||||||
|
// work-around until test is running on released version
|
||||||
|
await utils.move(
|
||||||
|
`/data/backups/${filename}`,
|
||||||
|
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
|
||||||
|
);
|
||||||
|
filename = 'immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz';
|
||||||
|
|
||||||
|
const { status } = await request(app)
|
||||||
|
.post('/admin/maintenance')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({
|
||||||
|
action: 'restore_database',
|
||||||
|
restoreBackupFilename: filename,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(201);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeTruthy();
|
||||||
|
|
||||||
|
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||||
|
expect(status2).toBe(200);
|
||||||
|
expect(body).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
active: true,
|
||||||
|
action: 'restore_database',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.sequential('fail to restore a corrupted backup', { timeout: 60_000 }, async () => {
|
||||||
|
await utils.prepareTestBackup('corrupted');
|
||||||
|
|
||||||
|
const { status, headers } = await request(app)
|
||||||
|
.post('/admin/maintenance')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({
|
||||||
|
action: 'restore_database',
|
||||||
|
restoreBackupFilename: 'development-corrupted.sql.gz',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(201);
|
||||||
|
cookie = headers['set-cookie'][0].split(';')[0];
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeTruthy();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
active: true,
|
||||||
|
action: 'restore_database',
|
||||||
|
error: 'Something went wrong, see logs!',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { status: status2, body: body2 } = await request(app)
|
||||||
|
.get('/admin/maintenance/status')
|
||||||
|
.set('cookie', cookie!)
|
||||||
|
.send({ token: 'token' });
|
||||||
|
expect(status2).toBe(200);
|
||||||
|
expect(body2).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
active: true,
|
||||||
|
action: 'restore_database',
|
||||||
|
error: expect.stringContaining('IM CORRUPTED'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
|
||||||
|
action: 'end',
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.poll(
|
||||||
|
() => request(app).get('/server/config'),
|
||||||
|
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.sequential('rollback to restore point if backup is missing admin', { timeout: 60_000 }, async () => {
|
||||||
|
await utils.prepareTestBackup('empty');
|
||||||
|
|
||||||
|
const { status, headers } = await request(app)
|
||||||
|
.post('/admin/maintenance')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({
|
||||||
|
action: 'restore_database',
|
||||||
|
restoreBackupFilename: 'development-empty.sql.gz',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(201);
|
||||||
|
cookie = headers['set-cookie'][0].split(';')[0];
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeTruthy();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
active: true,
|
||||||
|
action: 'restore_database',
|
||||||
|
error: 'Something went wrong, see logs!',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { status: status2, body: body2 } = await request(app)
|
||||||
|
.get('/admin/maintenance/status')
|
||||||
|
.set('cookie', cookie!)
|
||||||
|
.send({ token: 'token' });
|
||||||
|
expect(status2).toBe(200);
|
||||||
|
expect(body2).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
active: true,
|
||||||
|
action: 'restore_database',
|
||||||
|
error: expect.stringContaining('Server health check failed, no admin exists.'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
|
||||||
|
action: 'end',
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.poll(
|
||||||
|
() => request(app).get('/server/config'),
|
||||||
|
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ describe('/admin/maintenance', () => {
|
|||||||
await utils.resetDatabase();
|
await utils.resetDatabase();
|
||||||
admin = await utils.adminSetup();
|
admin = await utils.adminSetup();
|
||||||
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
||||||
|
await utils.resetBackups(admin.accessToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
// => outside of maintenance mode
|
// => outside of maintenance mode
|
||||||
@@ -26,6 +27,17 @@ describe('/admin/maintenance', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /status', async () => {
|
||||||
|
it('to always indicate we are not in maintenance mode', async () => {
|
||||||
|
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual({
|
||||||
|
active: false,
|
||||||
|
action: 'end',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('POST /login', async () => {
|
describe('POST /login', async () => {
|
||||||
it('should not work out of maintenance mode', async () => {
|
it('should not work out of maintenance mode', async () => {
|
||||||
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
|
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
|
||||||
@@ -39,6 +51,7 @@ describe('/admin/maintenance', () => {
|
|||||||
describe.sequential('POST /', () => {
|
describe.sequential('POST /', () => {
|
||||||
it('should require authentication', async () => {
|
it('should require authentication', async () => {
|
||||||
const { status, body } = await request(app).post('/admin/maintenance').send({
|
const { status, body } = await request(app).post('/admin/maintenance').send({
|
||||||
|
active: false,
|
||||||
action: 'end',
|
action: 'end',
|
||||||
});
|
});
|
||||||
expect(status).toBe(401);
|
expect(status).toBe(401);
|
||||||
@@ -69,6 +82,7 @@ describe('/admin/maintenance', () => {
|
|||||||
.send({
|
.send({
|
||||||
action: 'start',
|
action: 'start',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(201);
|
||||||
|
|
||||||
cookie = headers['set-cookie'][0].split(';')[0];
|
cookie = headers['set-cookie'][0].split(';')[0];
|
||||||
@@ -79,12 +93,13 @@ describe('/admin/maintenance', () => {
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const { body } = await request(app).get('/server/config');
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
return body.maintenanceMode;
|
return body.maintenanceMode;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
interval: 5e2,
|
interval: 500,
|
||||||
timeout: 1e4,
|
timeout: 10_000,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toBeTruthy();
|
.toBeTruthy();
|
||||||
@@ -102,6 +117,17 @@ describe('/admin/maintenance', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /status', async () => {
|
||||||
|
it('to indicate we are in maintenance mode', async () => {
|
||||||
|
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual({
|
||||||
|
active: true,
|
||||||
|
action: 'start',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('POST /login', async () => {
|
describe('POST /login', async () => {
|
||||||
it('should fail without cookie or token in body', async () => {
|
it('should fail without cookie or token in body', async () => {
|
||||||
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
|
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
|
||||||
@@ -158,12 +184,13 @@ describe('/admin/maintenance', () => {
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const { body } = await request(app).get('/server/config');
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
return body.maintenanceMode;
|
return body.maintenanceMode;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
interval: 5e2,
|
interval: 500,
|
||||||
timeout: 1e4,
|
timeout: 10_000,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toBeFalsy();
|
.toBeFalsy();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { OAuthClient, OAuthUser } from '@immich/e2e-auth-server';
|
||||||
import {
|
import {
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
SystemConfigOAuthDto,
|
SystemConfigOAuthDto,
|
||||||
@@ -8,13 +9,12 @@ import {
|
|||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { createHash, randomBytes } from 'node:crypto';
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { errorDto } from 'src/responses';
|
import { errorDto } from 'src/responses';
|
||||||
import { OAuthClient, OAuthUser } from 'src/setup/auth-server';
|
|
||||||
import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
|
import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { beforeAll, describe, expect, it } from 'vitest';
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
const authServer = {
|
const authServer = {
|
||||||
internal: 'http://auth-server:2286',
|
internal: 'http://e2e-auth-server:2286',
|
||||||
external: 'http://127.0.0.1:2286',
|
external: 'http://127.0.0.1:2286',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ describe('/shared-links', () => {
|
|||||||
let user1: LoginResponseDto;
|
let user1: LoginResponseDto;
|
||||||
let user2: LoginResponseDto;
|
let user2: LoginResponseDto;
|
||||||
let album: AlbumResponseDto;
|
let album: AlbumResponseDto;
|
||||||
let metadataAlbum: AlbumResponseDto;
|
|
||||||
let deletedAlbum: AlbumResponseDto;
|
let deletedAlbum: AlbumResponseDto;
|
||||||
let linkWithDeletedAlbum: SharedLinkResponseDto;
|
let linkWithDeletedAlbum: SharedLinkResponseDto;
|
||||||
let linkWithPassword: SharedLinkResponseDto;
|
let linkWithPassword: SharedLinkResponseDto;
|
||||||
@@ -41,18 +40,9 @@ describe('/shared-links', () => {
|
|||||||
|
|
||||||
[asset1, asset2] = await Promise.all([utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken)]);
|
[asset1, asset2] = await Promise.all([utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken)]);
|
||||||
|
|
||||||
[album, deletedAlbum, metadataAlbum] = await Promise.all([
|
[album, deletedAlbum] = await Promise.all([
|
||||||
createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }),
|
createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }),
|
||||||
createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }),
|
createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }),
|
||||||
createAlbum(
|
|
||||||
{
|
|
||||||
createAlbumDto: {
|
|
||||||
albumName: 'metadata album',
|
|
||||||
assetIds: [asset1.id],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ headers: asBearerAuth(user1.accessToken) },
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
[linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
|
[linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
|
||||||
@@ -75,14 +65,14 @@ describe('/shared-links', () => {
|
|||||||
password: 'foo',
|
password: 'foo',
|
||||||
}),
|
}),
|
||||||
utils.createSharedLink(user1.accessToken, {
|
utils.createSharedLink(user1.accessToken, {
|
||||||
type: SharedLinkType.Album,
|
type: SharedLinkType.Individual,
|
||||||
albumId: metadataAlbum.id,
|
assetIds: [asset1.id],
|
||||||
showMetadata: true,
|
showMetadata: true,
|
||||||
slug: 'metadata-album',
|
slug: 'metadata-slug',
|
||||||
}),
|
}),
|
||||||
utils.createSharedLink(user1.accessToken, {
|
utils.createSharedLink(user1.accessToken, {
|
||||||
type: SharedLinkType.Album,
|
type: SharedLinkType.Individual,
|
||||||
albumId: metadataAlbum.id,
|
assetIds: [asset1.id],
|
||||||
showMetadata: false,
|
showMetadata: false,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -95,9 +85,7 @@ describe('/shared-links', () => {
|
|||||||
const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`);
|
const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`);
|
||||||
expect(resp.status).toBe(200);
|
expect(resp.status).toBe(200);
|
||||||
expect(resp.header['content-type']).toContain('text/html');
|
expect(resp.header['content-type']).toContain('text/html');
|
||||||
expect(resp.text).toContain(
|
expect(resp.text).toContain(`<meta name="description" content="1 shared photos & videos" />`);
|
||||||
`<meta name="description" content="${metadataAlbum.assets.length} shared photos & videos" />`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have correct asset count in meta tag for empty album', async () => {
|
it('should have correct asset count in meta tag for empty album', async () => {
|
||||||
@@ -144,9 +132,7 @@ describe('/shared-links', () => {
|
|||||||
const resp = await request(baseUrl).get(`/s/${linkWithMetadata.slug}`);
|
const resp = await request(baseUrl).get(`/s/${linkWithMetadata.slug}`);
|
||||||
expect(resp.status).toBe(200);
|
expect(resp.status).toBe(200);
|
||||||
expect(resp.header['content-type']).toContain('text/html');
|
expect(resp.header['content-type']).toContain('text/html');
|
||||||
expect(resp.text).toContain(
|
expect(resp.text).toContain(`<meta name="description" content="1 shared photos & videos" />`);
|
||||||
`<meta name="description" content="${metadataAlbum.assets.length} shared photos & videos" />`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,12 +257,12 @@ describe('/shared-links', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return metadata for album shared link', async () => {
|
it('should return metadata for individual shared link', async () => {
|
||||||
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key });
|
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key });
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body.assets).toHaveLength(0);
|
expect(body.assets).toHaveLength(1);
|
||||||
expect(body.album).toBeDefined();
|
expect(body.album).not.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not return metadata for album shared link without metadata', async () => {
|
it('should not return metadata for album shared link without metadata', async () => {
|
||||||
@@ -284,7 +270,7 @@ describe('/shared-links', () => {
|
|||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body.assets).toHaveLength(1);
|
expect(body.assets).toHaveLength(1);
|
||||||
expect(body.album).toBeDefined();
|
expect(body.album).not.toBeDefined();
|
||||||
|
|
||||||
const asset = body.assets[0];
|
const asset = body.assets[0];
|
||||||
expect(asset).not.toHaveProperty('exifInfo');
|
expect(asset).not.toHaveProperty('exifInfo');
|
||||||
|
|||||||
@@ -26,6 +26,5 @@ export const makeRandomImage = () => {
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error('Ran out of random asset data');
|
throw new Error('Ran out of random asset data');
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|||||||
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 };
|
||||||
|
}
|
||||||
439
e2e/src/generators/timeline/rest-response.ts
Normal file
439
e2e/src/generators/timeline/rest-response.ts
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
isEdited: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single asset by ID from timeline data
|
||||||
|
* This matches the response from GET /api/assets/:id
|
||||||
|
*/
|
||||||
|
export function getAsset(
|
||||||
|
timelineData: MockTimelineData,
|
||||||
|
assetId: string,
|
||||||
|
owner?: UserResponseDto,
|
||||||
|
): AssetResponseDto | undefined {
|
||||||
|
// Search through all buckets for the asset
|
||||||
|
const buckets = [...timelineData.buckets.values()];
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
179
e2e/src/mock-network/timeline-network.ts
Normal file
179
e2e/src/mock-network/timeline-network.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { AssetResponseDto } from '@immich/sdk';
|
||||||
|
import { BrowserContext, Page, Request, Route } from '@playwright/test';
|
||||||
|
import { basename } from 'node:path';
|
||||||
|
import {
|
||||||
|
Changes,
|
||||||
|
getAlbum,
|
||||||
|
getAsset,
|
||||||
|
getTimeBucket,
|
||||||
|
getTimeBuckets,
|
||||||
|
randomPreview,
|
||||||
|
randomThumbnail,
|
||||||
|
TimelineData,
|
||||||
|
} from 'src/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) => {
|
||||||
|
if (request.method() === 'GET') {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const pathname = url.pathname;
|
||||||
|
const assetId = basename(pathname);
|
||||||
|
let asset = getAsset(timelineRestData, assetId);
|
||||||
|
if (changes.assetDeletions.includes(asset!.id)) {
|
||||||
|
asset = {
|
||||||
|
...asset,
|
||||||
|
isTrashed: true,
|
||||||
|
} as AssetResponseDto;
|
||||||
|
}
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: asset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.route('**/api/assets', async (route, request) => {
|
||||||
|
if (request.method() === 'DELETE') {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.route('**/api/assets/*/ocr', async (route) => {
|
||||||
|
return route.fulfill({ status: 200, contentType: 'application/json', json: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
||||||
|
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
||||||
|
const match = request.url().match(pattern);
|
||||||
|
if (!match?.groups) {
|
||||||
|
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.groups.size === 'preview') {
|
||||||
|
if (!route.request().serviceWorker()) {
|
||||||
|
return route.continue();
|
||||||
|
}
|
||||||
|
const asset = getAsset(timelineRestData, match.groups.assetId);
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
|
||||||
|
body: await randomPreview(
|
||||||
|
match.groups.assetId,
|
||||||
|
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (match.groups.size === 'thumbnail') {
|
||||||
|
if (!route.request().serviceWorker()) {
|
||||||
|
return route.continue();
|
||||||
|
}
|
||||||
|
const asset = getAsset(timelineRestData, match.groups.assetId);
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'image/jpeg' },
|
||||||
|
body: await randomThumbnail(
|
||||||
|
match.groups.assetId,
|
||||||
|
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.route('**/api/albums/**', async (route, request) => {
|
||||||
|
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
|
||||||
|
if (albumsMatch) {
|
||||||
|
const album = getAlbum(timelineRestData, testContext.adminId, albumsMatch.groups?.albumId, changes);
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: album,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.route('**/api/albums**', async (route, request) => {
|
||||||
|
const allAlbums = request.url().match(/\/api\/albums\?assetId=(?<assetId>[^&]+)/);
|
||||||
|
if (allAlbums) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pageRoutePromise = async (
|
||||||
|
page: Page,
|
||||||
|
route: string,
|
||||||
|
callback: (route: Route, request: Request) => Promise<void>,
|
||||||
|
) => {
|
||||||
|
let resolveRequest: ((value: unknown | PromiseLike<unknown>) => void) | undefined;
|
||||||
|
const deleteRequest = new Promise((resolve) => {
|
||||||
|
resolveRequest = resolve;
|
||||||
|
});
|
||||||
|
await page.route(route, async (route, request) => {
|
||||||
|
await callback(route, request);
|
||||||
|
const requestJson = request.postDataJSON();
|
||||||
|
resolveRequest?.(requestJson);
|
||||||
|
});
|
||||||
|
return deleteRequest;
|
||||||
|
};
|
||||||
117
e2e/src/utils.ts
117
e2e/src/utils.ts
@@ -6,13 +6,15 @@ import {
|
|||||||
CheckExistingAssetsDto,
|
CheckExistingAssetsDto,
|
||||||
CreateAlbumDto,
|
CreateAlbumDto,
|
||||||
CreateLibraryDto,
|
CreateLibraryDto,
|
||||||
|
JobCreateDto,
|
||||||
MaintenanceAction,
|
MaintenanceAction,
|
||||||
|
ManualJobName,
|
||||||
MetadataSearchDto,
|
MetadataSearchDto,
|
||||||
Permission,
|
Permission,
|
||||||
PersonCreateDto,
|
PersonCreateDto,
|
||||||
QueueCommandDto,
|
QueueCommandDto,
|
||||||
QueueName,
|
QueueName,
|
||||||
QueuesResponseDto,
|
QueuesResponseLegacyDto,
|
||||||
SharedLinkCreateDto,
|
SharedLinkCreateDto,
|
||||||
UpdateLibraryDto,
|
UpdateLibraryDto,
|
||||||
UserAdminCreateDto,
|
UserAdminCreateDto,
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
checkExistingAssets,
|
checkExistingAssets,
|
||||||
createAlbum,
|
createAlbum,
|
||||||
createApiKey,
|
createApiKey,
|
||||||
|
createJob,
|
||||||
createLibrary,
|
createLibrary,
|
||||||
createPartner,
|
createPartner,
|
||||||
createPerson,
|
createPerson,
|
||||||
@@ -28,10 +31,12 @@ import {
|
|||||||
createStack,
|
createStack,
|
||||||
createUserAdmin,
|
createUserAdmin,
|
||||||
deleteAssets,
|
deleteAssets,
|
||||||
|
deleteDatabaseBackup,
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
getConfig,
|
getConfig,
|
||||||
getConfigDefaults,
|
getConfigDefaults,
|
||||||
getQueuesLegacy,
|
getQueuesLegacy,
|
||||||
|
listDatabaseBackups,
|
||||||
login,
|
login,
|
||||||
runQueueCommandLegacy,
|
runQueueCommandLegacy,
|
||||||
scanLibrary,
|
scanLibrary,
|
||||||
@@ -52,16 +57,22 @@ import {
|
|||||||
import { BrowserContext } from '@playwright/test';
|
import { BrowserContext } from '@playwright/test';
|
||||||
import { exec, spawn } from 'node:child_process';
|
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 { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { mkdtemp } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path, { dirname } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
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 { createGzip } from 'node:zlib';
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
import { io, type Socket } from 'socket.io-client';
|
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 };
|
||||||
@@ -70,20 +81,21 @@ 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}` });
|
||||||
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
|
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
|
||||||
export const immichCli = (args: string[]) =>
|
export const immichCli = (args: string[]) =>
|
||||||
executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise;
|
executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise;
|
||||||
export const immichAdmin = (args: string[]) =>
|
export const dockerExec = (args: string[]) =>
|
||||||
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
|
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', args.join(' ')]);
|
||||||
|
export const immichAdmin = (args: string[]) => dockerExec([`immich-admin ${args.join(' ')}`]);
|
||||||
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
|
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
|
||||||
export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||||
|
|
||||||
@@ -147,12 +159,26 @@ const onEvent = ({ event, id }: { event: EventType; id: string }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const utils = {
|
export const utils = {
|
||||||
|
connectDatabase: async () => {
|
||||||
|
if (!client) {
|
||||||
|
client = new pg.Client(dbUrl);
|
||||||
|
client.on('end', () => (client = null));
|
||||||
|
client.on('error', () => (client = null));
|
||||||
|
await client.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnectDatabase: async () => {
|
||||||
|
if (client) {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
resetDatabase: async (tables?: string[]) => {
|
resetDatabase: async (tables?: string[]) => {
|
||||||
try {
|
try {
|
||||||
if (!client) {
|
client = await utils.connectDatabase();
|
||||||
client = new pg.Client(dbUrl);
|
|
||||||
await client.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
tables = tables || [
|
tables = tables || [
|
||||||
// TODO e2e test for deleting a stack, since it is quite complex
|
// TODO e2e test for deleting a stack, since it is quite complex
|
||||||
@@ -479,10 +505,13 @@ 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) }),
|
||||||
|
|
||||||
|
createJob: async (accessToken: string, jobCreateDto: JobCreateDto) =>
|
||||||
|
createJob({ jobCreateDto }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
|
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
|
||||||
runQueueCommandLegacy({ name, queueCommandDto }, { 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',
|
||||||
@@ -557,18 +586,57 @@ export const utils = {
|
|||||||
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
|
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async move(source: string, dest: string) {
|
||||||
|
return executeCommand('docker', ['exec', 'immich-e2e-server', 'mv', source, dest]).promise;
|
||||||
|
},
|
||||||
|
|
||||||
|
createBackup: async (accessToken: string) => {
|
||||||
|
await utils.createJob(accessToken, {
|
||||||
|
name: ManualJobName.BackupDatabase,
|
||||||
|
});
|
||||||
|
|
||||||
|
return utils.poll(
|
||||||
|
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
|
||||||
|
({ status, body }) => status === 200 && body.backups.length === 1,
|
||||||
|
({ body }) => body.backups[0].filename,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetBackups: async (accessToken: string) => {
|
||||||
|
const { backups } = await listDatabaseBackups({ headers: asBearerAuth(accessToken) });
|
||||||
|
|
||||||
|
const backupFiles = backups.map((b) => b.filename);
|
||||||
|
await deleteDatabaseBackup(
|
||||||
|
{ databaseBackupDeleteDto: { backups: backupFiles } },
|
||||||
|
{ headers: asBearerAuth(accessToken) },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
prepareTestBackup: async (generate: 'empty' | 'corrupted') => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'test-'));
|
||||||
|
const fn = join(dir, 'file');
|
||||||
|
|
||||||
|
const sql = Readable.from(generate === 'corrupted' ? 'IM CORRUPTED;' : 'SELECT 1;');
|
||||||
|
const gzip = createGzip();
|
||||||
|
const writeStream = createWriteStream(fn);
|
||||||
|
await pipeline(sql, gzip, writeStream);
|
||||||
|
|
||||||
|
await executeCommand('docker', ['cp', fn, `immich-e2e-server:/data/backups/development-${generate}.sql.gz`])
|
||||||
|
.promise;
|
||||||
|
},
|
||||||
|
|
||||||
resetAdminConfig: async (accessToken: string) => {
|
resetAdminConfig: async (accessToken: string) => {
|
||||||
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
|
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
|
||||||
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
||||||
},
|
},
|
||||||
|
|
||||||
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseDto) => {
|
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => {
|
||||||
const queues = await getQueuesLegacy({ 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 QueuesResponseDto, 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);
|
||||||
@@ -599,6 +667,25 @@ export const utils = {
|
|||||||
await utils.waitForQueueFinish(accessToken, 'sidecar');
|
await utils.waitForQueueFinish(accessToken, 'sidecar');
|
||||||
await utils.waitForQueueFinish(accessToken, 'metadataExtraction');
|
await utils.waitForQueueFinish(accessToken, 'metadataExtraction');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async poll<T>(cb: () => Promise<T>, validate: (value: T) => boolean, map?: (value: T) => any) {
|
||||||
|
let timeout = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const data = await cb();
|
||||||
|
if (validate(data)) {
|
||||||
|
return map ? map(data) : data;
|
||||||
|
}
|
||||||
|
timeout++;
|
||||||
|
if (timeout >= 10) {
|
||||||
|
throw 'Could not clean up test.';
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5e2));
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
utils.initSdk();
|
utils.initSdk();
|
||||||
|
|||||||
270
e2e/src/web/specs/asset-viewer/asset-viewer.parallel-e2e-spec.ts
Normal file
270
e2e/src/web/specs/asset-viewer/asset-viewer.parallel-e2e-spec.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
Changes,
|
||||||
|
createDefaultTimelineConfig,
|
||||||
|
generateTimelineData,
|
||||||
|
SeededRandom,
|
||||||
|
selectRandom,
|
||||||
|
TimelineAssetConfig,
|
||||||
|
TimelineData,
|
||||||
|
} from 'src/generators/timeline';
|
||||||
|
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
|
||||||
|
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
|
||||||
|
import { utils } from 'src/utils';
|
||||||
|
import { assetViewerUtils, cancelAllPollers } from 'src/web/specs/timeline/utils';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
test.describe('asset-viewer', () => {
|
||||||
|
const rng = new SeededRandom(529);
|
||||||
|
let adminUserId: string;
|
||||||
|
let timelineRestData: TimelineData;
|
||||||
|
const assets: TimelineAssetConfig[] = [];
|
||||||
|
const yearMonths: string[] = [];
|
||||||
|
const testContext = new TimelineTestContext();
|
||||||
|
const changes: Changes = {
|
||||||
|
albumAdditions: [],
|
||||||
|
assetDeletions: [],
|
||||||
|
assetArchivals: [],
|
||||||
|
assetFavorites: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
utils.initSdk();
|
||||||
|
adminUserId = faker.string.uuid();
|
||||||
|
testContext.adminId = adminUserId;
|
||||||
|
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||||
|
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||||
|
assets.push(...timeBucket);
|
||||||
|
}
|
||||||
|
for (const yearMonth of timelineRestData.buckets.keys()) {
|
||||||
|
const [year, month] = yearMonth.split('-');
|
||||||
|
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/:id', () => {
|
||||||
|
test('Navigate to next asset via button', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||||
|
|
||||||
|
await page.getByLabel('View next asset').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navigate to previous asset via button', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||||
|
|
||||||
|
await page.getByLabel('View previous asset').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navigate to next asset via keyboard (ArrowRight)', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||||
|
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navigate to previous asset via keyboard (ArrowLeft)', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||||
|
|
||||||
|
await page.keyboard.press('ArrowLeft');
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navigate forward 5 times via button', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
await page.getByLabel('View next asset').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + i].id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navigate backward 5 times via button', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
await page.getByLabel('View previous asset').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index - i]);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - i].id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navigate forward then backward via keyboard', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
|
||||||
|
// Navigate forward 3 times
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate backward 3 times to return to original
|
||||||
|
for (let i = 2; i >= 0; i--) {
|
||||||
|
await page.keyboard.press('ArrowLeft');
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we're back at the original asset
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Verify no next button on last asset', async ({ page }) => {
|
||||||
|
const lastAsset = assets.at(-1)!;
|
||||||
|
await page.goto(`/photos/${lastAsset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
|
||||||
|
|
||||||
|
// Verify next button doesn't exist
|
||||||
|
await expect(page.getByLabel('View next asset')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Verify no previous button on first asset', async ({ page }) => {
|
||||||
|
const firstAsset = assets[0];
|
||||||
|
await page.goto(`/photos/${firstAsset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, firstAsset);
|
||||||
|
|
||||||
|
// Verify previous button doesn't exist
|
||||||
|
await expect(page.getByLabel('View previous asset')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Delete photo advances to next', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||||
|
});
|
||||||
|
test('Delete photo advances to next (2x)', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
|
||||||
|
});
|
||||||
|
test('Delete last photo advances to prev', async ({ page }) => {
|
||||||
|
const asset = assets.at(-1)!;
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||||
|
});
|
||||||
|
test('Delete last photo advances to prev (2x)', async ({ page }) => {
|
||||||
|
const asset = assets.at(-1)!;
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.describe('/trash/photos/:id', () => {
|
||||||
|
test('Delete trashed photo advances to next', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||||
|
changes.assetDeletions.push(...deletedAssets);
|
||||||
|
await page.goto(`/trash/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
// confirm dialog
|
||||||
|
await page.getByRole('button').getByText('Delete').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||||
|
});
|
||||||
|
test('Delete trashed photo advances to next 2x', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||||
|
changes.assetDeletions.push(...deletedAssets);
|
||||||
|
await page.goto(`/trash/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
// confirm dialog
|
||||||
|
await page.getByRole('button').getByText('Delete').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
// confirm dialog
|
||||||
|
await page.getByRole('button').getByText('Delete').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
|
||||||
|
});
|
||||||
|
test('Delete trashed photo advances to prev', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||||
|
changes.assetDeletions.push(...deletedAssets);
|
||||||
|
await page.goto(`/trash/photos/${assets[index + 9].id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
// confirm dialog
|
||||||
|
await page.getByRole('button').getByText('Delete').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
|
||||||
|
});
|
||||||
|
test('Delete trashed photo advances to prev 2x', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||||
|
changes.assetDeletions.push(...deletedAssets);
|
||||||
|
await page.goto(`/trash/photos/${assets[index + 9].id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
// confirm dialog
|
||||||
|
await page.getByRole('button').getByText('Delete').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
// confirm dialog
|
||||||
|
await page.getByRole('button').getByText('Delete').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
105
e2e/src/web/specs/database-backups.e2e-spec.ts
Normal file
105
e2e/src/web/specs/database-backups.e2e-spec.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { LoginResponseDto } from '@immich/sdk';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { utils } from 'src/utils';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.describe('Database Backups', () => {
|
||||||
|
let admin: LoginResponseDto;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
utils.initSdk();
|
||||||
|
await utils.resetDatabase();
|
||||||
|
admin = await utils.adminSetup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore a backup from settings', async ({ context, page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
await utils.resetBackups(admin.accessToken);
|
||||||
|
const filename = await utils.createBackup(admin.accessToken);
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
|
// work-around until test is running on released version
|
||||||
|
await utils.move(
|
||||||
|
`/data/backups/${filename}`,
|
||||||
|
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/admin/maintenance?isOpen=backups');
|
||||||
|
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/maintenance?**');
|
||||||
|
await page.waitForURL('/admin/maintenance**', { timeout: 60_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handle backup restore failure', async ({ context, page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
await utils.resetBackups(admin.accessToken);
|
||||||
|
await utils.prepareTestBackup('corrupted');
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
|
await page.goto('/admin/maintenance?isOpen=backups');
|
||||||
|
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/maintenance?**');
|
||||||
|
await expect(page.getByText('IM CORRUPTED')).toBeVisible({ timeout: 60_000 });
|
||||||
|
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||||
|
await page.waitForURL('/admin/maintenance**');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rollback to restore point if backup is missing admin', async ({ context, page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
await utils.resetBackups(admin.accessToken);
|
||||||
|
await utils.prepareTestBackup('empty');
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
|
await page.goto('/admin/maintenance?isOpen=backups');
|
||||||
|
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/maintenance?**');
|
||||||
|
await expect(page.getByText('Server health check failed, no admin exists.')).toBeVisible({ timeout: 60_000 });
|
||||||
|
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||||
|
await page.waitForURL('/admin/maintenance**');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore a backup from onboarding', async ({ context, page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
await utils.resetBackups(admin.accessToken);
|
||||||
|
const filename = await utils.createBackup(admin.accessToken);
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
|
// work-around until test is running on released version
|
||||||
|
await utils.move(
|
||||||
|
`/data/backups/${filename}`,
|
||||||
|
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
|
||||||
|
);
|
||||||
|
|
||||||
|
await utils.resetDatabase();
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: 'Restore from backup' }).click();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.waitForURL('/maintenance**');
|
||||||
|
} catch {
|
||||||
|
// when chained with the rest of the tests
|
||||||
|
// this navigation may fail..? not sure why...
|
||||||
|
await page.goto('/maintenance');
|
||||||
|
await page.waitForURL('/maintenance**');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/maintenance?**');
|
||||||
|
await page.waitForURL('/photos', { timeout: 60_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,13 +16,12 @@ test.describe('Maintenance', () => {
|
|||||||
test('enter and exit maintenance mode', async ({ context, page }) => {
|
test('enter and exit maintenance mode', async ({ context, page }) => {
|
||||||
await utils.setAuthCookies(context, admin.accessToken);
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
await page.goto('/admin/system-settings?isOpen=maintenance');
|
await page.goto('/admin/maintenance');
|
||||||
await page.getByRole('button', { name: 'Start maintenance mode' }).click();
|
await page.getByRole('button', { name: 'Switch to maintenance mode' }).click();
|
||||||
|
|
||||||
await page.waitForURL(`/maintenance?${new URLSearchParams({ continue: '/admin/system-settings' })}`);
|
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
|
|
||||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||||
await page.waitForURL('/admin/system-settings');
|
await page.waitForURL('**/admin/maintenance*', { timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
|
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
|
||||||
@@ -35,10 +34,10 @@ test.describe('Maintenance', () => {
|
|||||||
|
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForURL('/maintenance?**', {
|
await page.waitForURL('**/maintenance?**', {
|
||||||
timeout: 1e3,
|
timeout: 1000,
|
||||||
});
|
});
|
||||||
}).toPass({ timeout: 1e4 });
|
}).toPass({ timeout: 10_000 });
|
||||||
|
|
||||||
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
|
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toHaveCount(0);
|
||||||
@@ -47,6 +46,6 @@ test.describe('Maintenance', () => {
|
|||||||
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
|
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||||
await page.waitForURL('/auth/login');
|
await page.waitForURL('**/auth/login');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Page, expect, test } from '@playwright/test';
|
|||||||
import { utils } from 'src/utils';
|
import { utils } from 'src/utils';
|
||||||
|
|
||||||
function imageLocator(page: Page) {
|
function imageLocator(page: Page) {
|
||||||
return page.getByAltText('Image taken on').locator('visible=true');
|
return page.getByAltText('Image taken').locator('visible=true');
|
||||||
}
|
}
|
||||||
test.describe('Photo Viewer', () => {
|
test.describe('Photo Viewer', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
|
|||||||
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('Add assets').click();
|
||||||
|
await expect(put).resolves.toEqual({
|
||||||
|
ids: [
|
||||||
|
'c077ea7b-cfa1-45e4-8554-f86c00ee5658',
|
||||||
|
'040fd762-dbbc-486d-a51a-2d84115e6229',
|
||||||
|
'86af0b5f-79d3-4f75-bab3-3b61f6c72b23',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const addedAsset = getAsset(timelineRestData, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658')!;
|
||||||
|
await pageUtils.goToAsset(page, addedAsset.fileCreatedAt);
|
||||||
|
await thumbnailUtils.expectInViewport(page, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658');
|
||||||
|
await thumbnailUtils.expectInViewport(page, '040fd762-dbbc-486d-a51a-2d84115e6229');
|
||||||
|
await thumbnailUtils.expectInViewport(page, '86af0b5f-79d3-4f75-bab3-3b61f6c72b23');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.describe('/trash', () => {
|
||||||
|
test('open /photos, trash photo, open /trash, restore', async ({ page }) => {
|
||||||
|
await pageUtils.openPhotosPage(page);
|
||||||
|
const assetToTrash = assets[0];
|
||||||
|
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
|
||||||
|
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
|
||||||
|
await page.getByLabel('Menu').click();
|
||||||
|
const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||||
|
const requestJson = request.postDataJSON();
|
||||||
|
changes.assetDeletions.push(...requestJson.ids);
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: requestJson.ids.map((id: string) => ({ id, success: true })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByRole('menuitem').getByText('Delete').click();
|
||||||
|
await expect(deleteRequest).resolves.toEqual({
|
||||||
|
force: false,
|
||||||
|
ids: [assetToTrash.id],
|
||||||
|
});
|
||||||
|
await page.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;
|
||||||
|
};
|
||||||
242
e2e/src/web/specs/timeline/utils.ts
Normal file
242
e2e/src/web/specs/timeline/utils.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
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}&edited=true"]`,
|
||||||
|
)
|
||||||
|
.or(
|
||||||
|
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
|
||||||
|
)
|
||||||
|
.waitFor();
|
||||||
|
},
|
||||||
|
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||||
|
const activeElement = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -54,9 +54,9 @@ test.describe('User Administration', () => {
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Edit' }).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: 'Save' }).click();
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -83,9 +83,9 @@ test.describe('User Administration', () => {
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Edit' }).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: 'Save' }).click();
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
// skip `docker compose up` if `make e2e` was already run
|
// skip `docker compose up` if `make e2e` was already run
|
||||||
const globalSetup: string[] = ['src/setup/auth-server.ts'];
|
const globalSetup: string[] = [];
|
||||||
try {
|
try {
|
||||||
await fetch('http://127.0.0.1:2285/api/server-info/ping');
|
await fetch('http://127.0.0.1:2285/api/server-info/ping');
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
5
i18n/.prettierrc
Normal file
5
i18n/.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"jsonRecursiveSort": true,
|
||||||
|
"jsonSortOrder": "{\"/.*/\": \"lexical\"}",
|
||||||
|
"plugins": ["prettier-plugin-sort-json"]
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
@@ -69,7 +68,6 @@
|
|||||||
"disable_login": "Deaktiveer aanmelding",
|
"disable_login": "Deaktiveer aanmelding",
|
||||||
"duplicate_detection_job_description": "Begin masjienleer op bates om soortgelyke beelde op te spoor. Maak staat op Smart Search",
|
"duplicate_detection_job_description": "Begin masjienleer op bates om soortgelyke beelde op te spoor. Maak staat op Smart Search",
|
||||||
"exclusion_pattern_description": "Met uitsluitingspatrone kan jy lêers en vouers ignoreer wanneer jy jou biblioteek skandeer. Dit is nuttig as jy vouers het wat lêers bevat wat jy nie wil invoer nie, soos RAW-lêers.",
|
"exclusion_pattern_description": "Met uitsluitingspatrone kan jy lêers en vouers ignoreer wanneer jy jou biblioteek skandeer. Dit is nuttig as jy vouers het wat lêers bevat wat jy nie wil invoer nie, soos RAW-lêers.",
|
||||||
"external_library_management": "Eksterne Biblioteekbestuur",
|
|
||||||
"face_detection": "Gesig herkenning",
|
"face_detection": "Gesig herkenning",
|
||||||
"face_detection_description": "Identifiseer die gesigte in media deur middel van masjienleer. Vir videos word slegs die duimnaelskets oorweeg. “Herlaai” (ver)werk al die media weer. “Stel terug” verwyder alle huidige gesigdata. “Onverwerk” plaas bates in die tou wat nog nie verwerk is nie. Geidentifiseerde gesigte sal ná voltooiing van Gesigidentifikasie vir Gesigherkenning in die tou geplaas word, om hulle in bestaande of nuwe persone te groepeer.",
|
"face_detection_description": "Identifiseer die gesigte in media deur middel van masjienleer. Vir videos word slegs die duimnaelskets oorweeg. “Herlaai” (ver)werk al die media weer. “Stel terug” verwyder alle huidige gesigdata. “Onverwerk” plaas bates in die tou wat nog nie verwerk is nie. Geidentifiseerde gesigte sal ná voltooiing van Gesigidentifikasie vir Gesigherkenning in die tou geplaas word, om hulle in bestaande of nuwe persone te groepeer.",
|
||||||
"facial_recognition_job_description": "Groepeer gesigte in mense in. Die stap is vinniger nadat Gesig Deteksie klaar is. \"Herstel\" (her-)groepeer alle gesigte. \"Vermiste\" plaas gesigte in ry wat nie 'n persoon gekoppel het nie.",
|
"facial_recognition_job_description": "Groepeer gesigte in mense in. Die stap is vinniger nadat Gesig Deteksie klaar is. \"Herstel\" (her-)groepeer alle gesigte. \"Vermiste\" plaas gesigte in ry wat nie 'n persoon gekoppel het nie.",
|
||||||
@@ -98,10 +96,8 @@
|
|||||||
"job_not_concurrency_safe": "Hierdie taak kan nie gelyktydig uitgevoer word nie.",
|
"job_not_concurrency_safe": "Hierdie taak kan nie gelyktydig uitgevoer word nie.",
|
||||||
"job_settings": "Agtergrondtaakinstellings",
|
"job_settings": "Agtergrondtaakinstellings",
|
||||||
"job_settings_description": "Bestuur werkgelyktydigheid",
|
"job_settings_description": "Bestuur werkgelyktydigheid",
|
||||||
"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",
|
||||||
|
|||||||
131
i18n/ar.json
131
i18n/ar.json
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"about": "حَوْل",
|
"about": "حول",
|
||||||
"account": "حساب",
|
"account": "حساب",
|
||||||
"account_settings": "إعدادات الحساب",
|
"account_settings": "إعدادات الحساب",
|
||||||
"acknowledge": "أُدرك ذلك",
|
"acknowledge": "أُدرك ذلك",
|
||||||
@@ -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": "أضف شريكًا",
|
||||||
@@ -32,6 +31,7 @@
|
|||||||
"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_upload_to_stack": "اضف رفع الى حزمة",
|
||||||
"add_url": "إضافة رابط",
|
"add_url": "إضافة رابط",
|
||||||
@@ -67,6 +67,7 @@
|
|||||||
"confirm_reprocess_all_faces": "هل أنت متأكد أنك تريد إعادة معالجة جميع الوجوه؟ سيخلي هذا كل الأشخاص الذين سَميتَهم.",
|
"confirm_reprocess_all_faces": "هل أنت متأكد أنك تريد إعادة معالجة جميع الوجوه؟ سيخلي هذا كل الأشخاص الذين سَميتَهم.",
|
||||||
"confirm_user_password_reset": "هل أنت متأكد أنك تريد إعادة تعيين كلمة مرور {user}؟",
|
"confirm_user_password_reset": "هل أنت متأكد أنك تريد إعادة تعيين كلمة مرور {user}؟",
|
||||||
"confirm_user_pin_code_reset": "هل انت متاكد من اعادة ضبط رمز PIN الخاص ب {user}؟",
|
"confirm_user_pin_code_reset": "هل انت متاكد من اعادة ضبط رمز PIN الخاص ب {user}؟",
|
||||||
|
"copy_config_to_clipboard_description": "انسخ اعدادات النظام الحالية بتنسيق JSON الى الحافظة",
|
||||||
"create_job": "إنشاء وظيفة",
|
"create_job": "إنشاء وظيفة",
|
||||||
"cron_expression": "تعبير Cron",
|
"cron_expression": "تعبير Cron",
|
||||||
"cron_expression_description": "اضبط الفاصل الزمني للفحص باستخدام تنسيق cron. لمزيد من المعلومات يُرجى الرجوع إلى <link>Crontab Guru</link> على سبيل المثال",
|
"cron_expression_description": "اضبط الفاصل الزمني للفحص باستخدام تنسيق cron. لمزيد من المعلومات يُرجى الرجوع إلى <link>Crontab Guru</link> على سبيل المثال",
|
||||||
@@ -74,7 +75,8 @@
|
|||||||
"disable_login": "تعطيل تسجيل الدخول",
|
"disable_login": "تعطيل تسجيل الدخول",
|
||||||
"duplicate_detection_job_description": "بدء التعلم الآلي على المحتوى للعثور على الصور المتشابهة. يعتمد على البحث الذكي",
|
"duplicate_detection_job_description": "بدء التعلم الآلي على المحتوى للعثور على الصور المتشابهة. يعتمد على البحث الذكي",
|
||||||
"exclusion_pattern_description": "تتيح لك أنماط الاستبعاد تجاهل الملفات والمجلدات عند فحص مكتبتك. يعد هذا مفيدًا إذا كان لديك مجلدات تحتوي على ملفات لا تريد استيرادها، مثل ملفات RAW.",
|
"exclusion_pattern_description": "تتيح لك أنماط الاستبعاد تجاهل الملفات والمجلدات عند فحص مكتبتك. يعد هذا مفيدًا إذا كان لديك مجلدات تحتوي على ملفات لا تريد استيرادها، مثل ملفات RAW.",
|
||||||
"external_library_management": "إدارة المكتبة الخارجية",
|
"export_config_as_json_description": "تحميل اعدادات النظام الحالية كملف بصيغة JSON",
|
||||||
|
"external_libraries_page_description": "مشرف صفحة مكتبة خارجية",
|
||||||
"face_detection": "إكتشاف الوجوه",
|
"face_detection": "إكتشاف الوجوه",
|
||||||
"face_detection_description": "اكتشف الوجوه في الأصول باستخدام التعلم الآلي. بالنسبة لمقاطع الفيديو، يتم اعتبار الصورة المصغرة فقط. \"تحديث\" (إعادة) معالجة جميع الأصول. \"إعادة تعيين\" تمسح أيضًا جميع بيانات الوجوه الحالية. \"مفقود\" يضع الأصول التي لم تتم معالجتها بعد في قائمة الانتظار. سيتم وضع الوجوه المكتشفة في قائمة الانتظار للتعرف على الوجه بعد اكتمال اكتشاف الوجه، وتجميعها في أشخاص موجودين أو جدد.",
|
"face_detection_description": "اكتشف الوجوه في الأصول باستخدام التعلم الآلي. بالنسبة لمقاطع الفيديو، يتم اعتبار الصورة المصغرة فقط. \"تحديث\" (إعادة) معالجة جميع الأصول. \"إعادة تعيين\" تمسح أيضًا جميع بيانات الوجوه الحالية. \"مفقود\" يضع الأصول التي لم تتم معالجتها بعد في قائمة الانتظار. سيتم وضع الوجوه المكتشفة في قائمة الانتظار للتعرف على الوجه بعد اكتمال اكتشاف الوجه، وتجميعها في أشخاص موجودين أو جدد.",
|
||||||
"facial_recognition_job_description": "تجميع الوجوه المكتشفة كأشخاص. يتم تنفيذ هذه الخطوة بعد اكتمال اكتشاف الوجه. خيار \"إعادة التعيين\" يعيد تجميع جميع الوجوه. خيار \"المفقود\" يضع في قائمة الانتظار الوجوه التي لم يتم تعيين شخص لها.",
|
"facial_recognition_job_description": "تجميع الوجوه المكتشفة كأشخاص. يتم تنفيذ هذه الخطوة بعد اكتمال اكتشاف الوجه. خيار \"إعادة التعيين\" يعيد تجميع جميع الوجوه. خيار \"المفقود\" يضع في قائمة الانتظار الوجوه التي لم يتم تعيين شخص لها.",
|
||||||
@@ -102,23 +104,27 @@
|
|||||||
"image_thumbnail_description": "صورة مصغرة صغيرة مع بيانات وصفية مجردة، تُستخدم عند عرض مجموعات من الصور مثل الجدول الزمني الرئيسي",
|
"image_thumbnail_description": "صورة مصغرة صغيرة مع بيانات وصفية مجردة، تُستخدم عند عرض مجموعات من الصور مثل الجدول الزمني الرئيسي",
|
||||||
"image_thumbnail_quality_description": "تتراوح جودة الصورة المصغرة من 1 إلى 100. كلما كانت الجودة أعلى كان ذلك أفضل، ولكنها تنتج ملفات أكبر وقد تقلل من استجابة التطبيق.",
|
"image_thumbnail_quality_description": "تتراوح جودة الصورة المصغرة من 1 إلى 100. كلما كانت الجودة أعلى كان ذلك أفضل، ولكنها تنتج ملفات أكبر وقد تقلل من استجابة التطبيق.",
|
||||||
"image_thumbnail_title": "إعدادات الصورة المصغرة",
|
"image_thumbnail_title": "إعدادات الصورة المصغرة",
|
||||||
|
"import_config_from_json_description": "استيراد اعدادات النظام بتحميل ملف اعدادات بصيغة JSON",
|
||||||
"job_concurrency": "تزامن {job}",
|
"job_concurrency": "تزامن {job}",
|
||||||
"job_created": "تم إنشاء الوظيفة",
|
"job_created": "تم إنشاء الوظيفة",
|
||||||
"job_not_concurrency_safe": "هذه الوظيفة غير آمنة للتشغيل المتزامن.",
|
"job_not_concurrency_safe": "هذه الوظيفة غير آمنة للتشغيل المتزامن.",
|
||||||
"job_settings": "إعدادات الوظائف",
|
"job_settings": "إعدادات الوظائف",
|
||||||
"job_settings_description": "إدارة تزامن الوظائف",
|
"job_settings_description": "إدارة تزامن الوظائف",
|
||||||
"job_status": "حالة الوظيفة",
|
|
||||||
"jobs_delayed": "{jobCount, plural, other {# مؤجلة}}",
|
"jobs_delayed": "{jobCount, plural, other {# مؤجلة}}",
|
||||||
"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_details": "تفاصيل المكتبة",
|
||||||
|
"library_folder_description": "حدد مجلد للاستيراد. هذا المجلد مع المجلدات الفرعية، سيتم تفحصهم للصور والفديوات.",
|
||||||
|
"library_remove_exclusion_pattern_prompt": "هل انت متاكد انك تريد ازالة نمط الاستبعاد هذا؟",
|
||||||
|
"library_remove_folder_prompt": "هل انت متاكد انك تريد ازالة مجلد الاستيراد هذا؟",
|
||||||
"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_updated": "مكتبة محدثة",
|
||||||
"library_watching_enable_description": "مراقبة المكتبات الخارجية لاكتشاف تغييرات الملفات",
|
"library_watching_enable_description": "مراقبة المكتبات الخارجية لاكتشاف تغييرات الملفات",
|
||||||
"library_watching_settings": "مراقبة المكتبات [تجريبي]",
|
"library_watching_settings": "مراقبة المكتبات [تجريبي]",
|
||||||
"library_watching_settings_description": "راقب تلقائيًا التغييرات في الملفات",
|
"library_watching_settings_description": "راقب تلقائيًا التغييرات في الملفات",
|
||||||
@@ -154,6 +160,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": "البحث الذكي",
|
||||||
@@ -161,7 +179,12 @@
|
|||||||
"machine_learning_smart_search_enabled": "تفعيل البحث الذكي",
|
"machine_learning_smart_search_enabled": "تفعيل البحث الذكي",
|
||||||
"machine_learning_smart_search_enabled_description": "إذا تم تعطيله، فلن يتم ترميز الصور للبحث الذكي.",
|
"machine_learning_smart_search_enabled_description": "إذا تم تعطيله، فلن يتم ترميز الصور للبحث الذكي.",
|
||||||
"machine_learning_url_description": "عنوان URL لخادم التعلم الآلي. إذا تم توفير أكثر من عنوان URL واحد، سيتم محاولة الاتصال بكل خادم على حدة حتى يستجيب أحدهم بنجاح، بدءًا من الأول إلى الأخير. سيتم تجاهل الخوادم التي لا تستجيب مؤقتًا حتى تعود للعمل.",
|
"machine_learning_url_description": "عنوان URL لخادم التعلم الآلي. إذا تم توفير أكثر من عنوان URL واحد، سيتم محاولة الاتصال بكل خادم على حدة حتى يستجيب أحدهم بنجاح، بدءًا من الأول إلى الأخير. سيتم تجاهل الخوادم التي لا تستجيب مؤقتًا حتى تعود للعمل.",
|
||||||
|
"maintenance_settings": "صيانة",
|
||||||
|
"maintenance_settings_description": "ضع Immich في وضع الصيانة.",
|
||||||
|
"maintenance_start": "ابدأ وضع الصيانة",
|
||||||
|
"maintenance_start_error": "فشل البدء في وضع الصيانة.",
|
||||||
"manage_concurrency": "إدارة التزامن",
|
"manage_concurrency": "إدارة التزامن",
|
||||||
|
"manage_concurrency_description": "انتقل الى صفحة الاعمال لادارة تزامن المهام",
|
||||||
"manage_log_settings": "إدارة إعدادات السجلات",
|
"manage_log_settings": "إدارة إعدادات السجلات",
|
||||||
"map_dark_style": "النمط الداكن",
|
"map_dark_style": "النمط الداكن",
|
||||||
"map_enable_description": "تفعيل ميزات الخرائط",
|
"map_enable_description": "تفعيل ميزات الخرائط",
|
||||||
@@ -211,6 +234,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": "إرسال بريد تجريبي",
|
||||||
@@ -243,6 +268,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": "إدارة تسجيل الدخول بكلمة المرور",
|
||||||
@@ -264,8 +290,10 @@
|
|||||||
"server_public_users_description": "يتم إدراج جميع المستخدمين (الاسم والبريد الإلكتروني) عند إضافة مستخدم إلى الألبومات المشتركة. عند تعطيل هذه الميزة، ستكون قائمة المستخدمين متاحة فقط لمستخدمي الإدارة.",
|
"server_public_users_description": "يتم إدراج جميع المستخدمين (الاسم والبريد الإلكتروني) عند إضافة مستخدم إلى الألبومات المشتركة. عند تعطيل هذه الميزة، ستكون قائمة المستخدمين متاحة فقط لمستخدمي الإدارة.",
|
||||||
"server_settings": "إعدادات الخادم",
|
"server_settings": "إعدادات الخادم",
|
||||||
"server_settings_description": "إدارة إعدادات الخادم",
|
"server_settings_description": "إدارة إعدادات الخادم",
|
||||||
|
"server_stats_page_description": "صفحة إحصائيات مسؤول الخادم",
|
||||||
"server_welcome_message": "الرسالة الترحيبية",
|
"server_welcome_message": "الرسالة الترحيبية",
|
||||||
"server_welcome_message_description": "رسالة تُعرض على صفحة تسجيل الدخول.",
|
"server_welcome_message_description": "رسالة تُعرض على صفحة تسجيل الدخول.",
|
||||||
|
"settings_page_description": "صفخة اعدادات المسؤول",
|
||||||
"sidecar_job": "البيانات الوصفية الجانبية",
|
"sidecar_job": "البيانات الوصفية الجانبية",
|
||||||
"sidecar_job_description": "اكتشاف أو مزامنة البيانات التعريفية الجانبية من نظام الملفات",
|
"sidecar_job_description": "اكتشاف أو مزامنة البيانات التعريفية الجانبية من نظام الملفات",
|
||||||
"slideshow_duration_description": "عدد الثواني لعرض كل صورة",
|
"slideshow_duration_description": "عدد الثواني لعرض كل صورة",
|
||||||
@@ -384,7 +412,8 @@
|
|||||||
"user_restore_scheduled_removal": "استعادة المستخدم - تمت جدولة الإزالة في {date, date, long}",
|
"user_restore_scheduled_removal": "استعادة المستخدم - تمت جدولة الإزالة في {date, date, long}",
|
||||||
"user_settings": "إعدادات المستخدم",
|
"user_settings": "إعدادات المستخدم",
|
||||||
"user_settings_description": "إدارة إعدادات المستخدم",
|
"user_settings_description": "إدارة إعدادات المستخدم",
|
||||||
"user_successfully_removed": "تمت إزالة المستخدم {email} بنجاح.",
|
"user_successfully_removed": "المستخدم {email} تمت ازالته بنجاح.",
|
||||||
|
"users_page_description": "صفحة ادارة المستخدمين",
|
||||||
"version_check_enabled_description": "تفعيل التحقق من الإصدارات الجديدة",
|
"version_check_enabled_description": "تفعيل التحقق من الإصدارات الجديدة",
|
||||||
"version_check_implications": "تعتمد ميزة التحقق من الإصدار على التواصل الدوري مع github.com",
|
"version_check_implications": "تعتمد ميزة التحقق من الإصدار على التواصل الدوري مع github.com",
|
||||||
"version_check_settings": "التحقق من الإصدار",
|
"version_check_settings": "التحقق من الإصدار",
|
||||||
@@ -402,11 +431,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": "إعدادات المستخدم المتقدمة",
|
||||||
@@ -415,6 +444,7 @@
|
|||||||
"age_months": "عمر {months, plural, one {# شهر} other {# أشهر}}",
|
"age_months": "عمر {months, plural, one {# شهر} other {# أشهر}}",
|
||||||
"age_year_months": "عمر سنة واحدة، {months, plural, one {# شهر} other {# أشهر}}",
|
"age_year_months": "عمر سنة واحدة، {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": "تم تحديث غلاف الألبوم",
|
||||||
@@ -460,16 +490,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": "الأرشيف",
|
||||||
@@ -553,6 +588,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 +698,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,13 +727,14 @@
|
|||||||
"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": "طي",
|
||||||
"collapse_all": "طيّ الكل",
|
"collapse_all": "طيّ الكل",
|
||||||
"color": "اللون",
|
"color": "اللون",
|
||||||
"color_theme": "نمط الألوان",
|
"color_theme": "نمط الألوان",
|
||||||
|
"command": "امر",
|
||||||
"comment_deleted": "تم حذف التعليق",
|
"comment_deleted": "تم حذف التعليق",
|
||||||
"comment_options": "خيارات التعليق",
|
"comment_options": "خيارات التعليق",
|
||||||
"comments_and_likes": "التعليقات والإعجابات",
|
"comments_and_likes": "التعليقات والإعجابات",
|
||||||
@@ -739,6 +778,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 +808,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": "التاريخ قبل",
|
||||||
@@ -870,8 +911,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": "تعديل الموقع",
|
||||||
@@ -943,8 +982,8 @@
|
|||||||
"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": "بريد أو كلمة مرور غير صحيحة",
|
||||||
|
"library_folder_already_exists": "مسار الاستيراد موجود بالفعل.",
|
||||||
"paths_validation_failed": "فشل في التحقق من {paths, plural, one {# مسار} other {# مسارات}}",
|
"paths_validation_failed": "فشل في التحقق من {paths, plural, one {# مسار} other {# مسارات}}",
|
||||||
"profile_picture_transparent_pixels": "لا يمكن أن تحتوي صور الملف الشخصي على أجزاء/بكسلات شفافة. يرجى التكبير و/أو تحريك الصورة.",
|
"profile_picture_transparent_pixels": "لا يمكن أن تحتوي صور الملف الشخصي على أجزاء/بكسلات شفافة. يرجى التكبير و/أو تحريك الصورة.",
|
||||||
"quota_higher_than_disk_size": "لقد قمت بتعيين حصة نسبية أعلى من حجم القرص",
|
"quota_higher_than_disk_size": "لقد قمت بتعيين حصة نسبية أعلى من حجم القرص",
|
||||||
@@ -953,7 +992,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 {إزالة المحتوى من}} المفضلة",
|
||||||
@@ -976,12 +1014,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": "غير قادر على الخروج من وضع ملء الشاشة",
|
||||||
@@ -1032,11 +1068,13 @@
|
|||||||
"unable_to_update_user": "غير قادر على تحديث المستخدم",
|
"unable_to_update_user": "غير قادر على تحديث المستخدم",
|
||||||
"unable_to_upload_file": "تعذر رفع الملف"
|
"unable_to_upload_file": "تعذر رفع الملف"
|
||||||
},
|
},
|
||||||
|
"exclusion_pattern": "نمط استبعاد",
|
||||||
"exif": "Exif (صيغة ملف صوري قابل للتبادل)",
|
"exif": "Exif (صيغة ملف صوري قابل للتبادل)",
|
||||||
"exif_bottom_sheet_description": "اضف وصفا...",
|
"exif_bottom_sheet_description": "اضف وصفا...",
|
||||||
"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": "خروج من العرض التقديمي",
|
||||||
@@ -1075,6 +1113,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": "تصفية",
|
||||||
@@ -1089,6 +1128,7 @@
|
|||||||
"folders_feature_description": "تصفح عرض المجلد للصور ومقاطع الفيديو الموجودة على نظام الملفات",
|
"folders_feature_description": "تصفح عرض المجلد للصور ومقاطع الفيديو الموجودة على نظام الملفات",
|
||||||
"forgot_pin_code_question": "هل نسيت رمز الPIN الخاص بك؟",
|
"forgot_pin_code_question": "هل نسيت رمز الPIN الخاص بك؟",
|
||||||
"forward": "إلى الأمام",
|
"forward": "إلى الأمام",
|
||||||
|
"full_path": "مسار كامل:{path}",
|
||||||
"gcast_enabled": "كوكل كاست",
|
"gcast_enabled": "كوكل كاست",
|
||||||
"gcast_enabled_description": "تقوم هذه الميزة بتحميل الموارد الخارجية من Google حتى تعمل.",
|
"gcast_enabled_description": "تقوم هذه الميزة بتحميل الموارد الخارجية من Google حتى تعمل.",
|
||||||
"general": "عام",
|
"general": "عام",
|
||||||
@@ -1114,7 +1154,7 @@
|
|||||||
"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": "قيمة الرأس",
|
||||||
@@ -1125,6 +1165,7 @@
|
|||||||
"hide_named_person": "إخفاء الشخص {name}",
|
"hide_named_person": "إخفاء الشخص {name}",
|
||||||
"hide_password": "اخفاء كلمة المرور",
|
"hide_password": "اخفاء كلمة المرور",
|
||||||
"hide_person": "اخفاء الشخص",
|
"hide_person": "اخفاء الشخص",
|
||||||
|
"hide_text_recognition": "اخفاء التعرف على النص",
|
||||||
"hide_unnamed_people": "إخفاء الأشخاص بدون إسم",
|
"hide_unnamed_people": "إخفاء الأشخاص بدون إسم",
|
||||||
"home_page_add_to_album_conflicts": "تمت إضافة {added} أصول إلى الألبوم {album}. {failed} أصول موجودة بالفعل في الألبوم.",
|
"home_page_add_to_album_conflicts": "تمت إضافة {added} أصول إلى الألبوم {album}. {failed} أصول موجودة بالفعل في الألبوم.",
|
||||||
"home_page_add_to_album_err_local": "لا يمكن إضافة الأصول المحلية إلى الألبومات حتى الآن ، سوف يتخطى",
|
"home_page_add_to_album_err_local": "لا يمكن إضافة الأصول المحلية إلى الألبومات حتى الآن ، سوف يتخطى",
|
||||||
@@ -1170,6 +1211,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": "تضمين محتويات الشريك المشتركة",
|
||||||
@@ -1206,6 +1249,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": "خط العرض",
|
||||||
@@ -1215,6 +1259,8 @@
|
|||||||
"let_others_respond": "دع الآخرين يستجيبون",
|
"let_others_respond": "دع الآخرين يستجيبون",
|
||||||
"level": "المستوى",
|
"level": "المستوى",
|
||||||
"library": "مكتبة",
|
"library": "مكتبة",
|
||||||
|
"library_add_folder": "اضافة مجلد",
|
||||||
|
"library_edit_folder": "تعديل مجلد",
|
||||||
"library_options": "خيارات المكتبة",
|
"library_options": "خيارات المكتبة",
|
||||||
"library_page_device_albums": "ألبومات على الجهاز",
|
"library_page_device_albums": "ألبومات على الجهاز",
|
||||||
"library_page_new_album": "البوم جديد",
|
"library_page_new_album": "البوم جديد",
|
||||||
@@ -1238,6 +1284,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": "اختر على الخريطة",
|
||||||
@@ -1285,8 +1332,17 @@
|
|||||||
"loop_videos_description": "فَعْل لتكرار مقطع فيديو تلقائيًا في عارض التفاصيل.",
|
"loop_videos_description": "فَعْل لتكرار مقطع فيديو تلقائيًا في عارض التفاصيل.",
|
||||||
"main_branch_warning": "أنت تستخدم إصداراً قيد التطوير؛ ونحن نوصي بشدة باستخدام إصدار النشر!",
|
"main_branch_warning": "أنت تستخدم إصداراً قيد التطوير؛ ونحن نوصي بشدة باستخدام إصدار النشر!",
|
||||||
"main_menu": "القائمة الرئيسية",
|
"main_menu": "القائمة الرئيسية",
|
||||||
|
"maintenance_description": "يجب وضع Immich في وضع الصيانة <link>وضع الصيانة</link>.",
|
||||||
|
"maintenance_end": "انهاء وضع الصيانة",
|
||||||
|
"maintenance_end_error": "فشل في انهاء وضع الصيانة.",
|
||||||
|
"maintenance_logged_in_as": "حاليا مسجل باسم {user}",
|
||||||
|
"maintenance_title": "غير متوفر مؤقتا",
|
||||||
"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": "إدارة إعدادات التطبيق",
|
||||||
@@ -1342,12 +1398,15 @@
|
|||||||
"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": "ط ط ط",
|
||||||
"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": "هذه الصور والفديوات ستتم ازالتها من جميع الالبومات، ويمكنان تتم مشاهدتها فقط من خلال المجلد المقفل",
|
||||||
@@ -1360,6 +1419,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": "متطلبات الشبكة",
|
||||||
@@ -1369,11 +1430,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": "الأحدث أولاً",
|
||||||
@@ -1389,12 +1452,14 @@
|
|||||||
"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": "قم برفع المزيد من الصور لاستكشاف مجموعتك.",
|
||||||
"no_favorites_message": "أضف المفضلة للعثور بسرعة على أفضل الصور ومقاطع الفيديو",
|
"no_favorites_message": "أضف المفضلة للعثور بسرعة على أفضل الصور ومقاطع الفيديو",
|
||||||
"no_libraries_message": "إنشاء مكتبة خارجية لعرض الصور ومقاطع الفيديو الخاصة بك",
|
"no_libraries_message": "إنشاء مكتبة خارجية لعرض الصور ومقاطع الفيديو الخاصة بك",
|
||||||
"no_local_assets_found": "لم يتم العثور على أي اصول محلية تتطابق مع قيمة التحقق هذه",
|
"no_local_assets_found": "لم يتم العثور على أي اصول محلية تتطابق مع قيمة التحقق هذه",
|
||||||
|
"no_location_set": "لم يتم تحديد موقع",
|
||||||
"no_locked_photos_message": "الصور والفديوهات في المجلد المقفل مخفية ولن تصهر في التصفح او البحث في مكتبتك.",
|
"no_locked_photos_message": "الصور والفديوهات في المجلد المقفل مخفية ولن تصهر في التصفح او البحث في مكتبتك.",
|
||||||
"no_name": "لا اسم",
|
"no_name": "لا اسم",
|
||||||
"no_notifications": "لا توجد تنبيهات",
|
"no_notifications": "لا توجد تنبيهات",
|
||||||
@@ -1405,6 +1470,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": "لم يختار",
|
||||||
@@ -1419,6 +1485,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": "ازاحة",
|
||||||
@@ -1450,6 +1519,7 @@
|
|||||||
"other_variables": "متغيرات أخرى",
|
"other_variables": "متغيرات أخرى",
|
||||||
"owned": "مملوكة",
|
"owned": "مملوكة",
|
||||||
"owner": "المالك",
|
"owner": "المالك",
|
||||||
|
"page": "صفحة",
|
||||||
"partner": "شريك",
|
"partner": "شريك",
|
||||||
"partner_can_access": "يستطيع {partner} الوصول",
|
"partner_can_access": "يستطيع {partner} الوصول",
|
||||||
"partner_can_access_assets": "جميع الصور ومقاطع الفيديو الخاصة بك باستثناء تلك الموجودة في المؤرشفة والمحذوفة",
|
"partner_can_access_assets": "جميع الصور ومقاطع الفيديو الخاصة بك باستثناء تلك الموجودة في المؤرشفة والمحذوفة",
|
||||||
@@ -1512,6 +1582,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 بنجاح",
|
||||||
@@ -1523,6 +1595,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": "ادارة تفضيلات التطبيق",
|
||||||
@@ -1659,6 +1734,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": "الاستعاده من سلة المهملات",
|
||||||
@@ -1677,6 +1753,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": "تم حفظ الإعدادات",
|
||||||
@@ -1693,6 +1770,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": "البحث حسب المدينة...",
|
||||||
@@ -1709,6 +1789,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": "البحث عن شخص موجود",
|
||||||
@@ -1770,7 +1851,10 @@
|
|||||||
"server_offline": "الخادم غير متصل",
|
"server_offline": "الخادم غير متصل",
|
||||||
"server_online": "الخادم متصل",
|
"server_online": "الخادم متصل",
|
||||||
"server_privacy": "خصوصية الخادم",
|
"server_privacy": "خصوصية الخادم",
|
||||||
|
"server_restarting_description": "سيتم تحديث هذه الصفحة بعد لحضات.",
|
||||||
|
"server_restarting_title": "يتم اعادة تشغيل الخادم",
|
||||||
"server_stats": "إحصائيات الخادم",
|
"server_stats": "إحصائيات الخادم",
|
||||||
|
"server_update_available": "تحديث الخادم متاح",
|
||||||
"server_version": "إصدار الخادم",
|
"server_version": "إصدار الخادم",
|
||||||
"set": "تحديد",
|
"set": "تحديد",
|
||||||
"set_as_album_cover": "تحديد كغلاف للألبوم",
|
"set_as_album_cover": "تحديد كغلاف للألبوم",
|
||||||
@@ -1799,6 +1883,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": "اجبار عرض الفديو الاصلي",
|
||||||
@@ -1890,6 +1976,7 @@
|
|||||||
"show_slideshow_transition": "إظهار انتقال عرض الشرائح",
|
"show_slideshow_transition": "إظهار انتقال عرض الشرائح",
|
||||||
"show_supporter_badge": "شارة المؤيد",
|
"show_supporter_badge": "شارة المؤيد",
|
||||||
"show_supporter_badge_description": "إظهار شارة المؤيد",
|
"show_supporter_badge_description": "إظهار شارة المؤيد",
|
||||||
|
"show_text_recognition": "اضهار التعرف على النصوص",
|
||||||
"show_text_search_menu": "عرض قائمة خيارات البحث في النص",
|
"show_text_search_menu": "عرض قائمة خيارات البحث في النص",
|
||||||
"shuffle": "خلط",
|
"shuffle": "خلط",
|
||||||
"sidebar": "الشريط الجانبي",
|
"sidebar": "الشريط الجانبي",
|
||||||
@@ -1960,6 +2047,7 @@
|
|||||||
"tags": "العلامات",
|
"tags": "العلامات",
|
||||||
"tap_to_run_job": "انقر لتشغيل المهمة",
|
"tap_to_run_job": "انقر لتشغيل المهمة",
|
||||||
"template": "النموذج",
|
"template": "النموذج",
|
||||||
|
"text_recognition": "التعرف على النصوص",
|
||||||
"theme": "مظهر",
|
"theme": "مظهر",
|
||||||
"theme_selection": "اختيار السمة",
|
"theme_selection": "اختيار السمة",
|
||||||
"theme_selection_description": "قم بتعيين السمة تلقائيًا على اللون الفاتح أو الداكن بناءً على تفضيلات نظام المتصفح الخاص بك",
|
"theme_selection_description": "قم بتعيين السمة تلقائيًا على اللون الفاتح أو الداكن بناءً على تفضيلات نظام المتصفح الخاص بك",
|
||||||
@@ -1978,7 +2066,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": "أرشفة",
|
||||||
@@ -1990,6 +2080,7 @@
|
|||||||
"to_select": "للتحديد",
|
"to_select": "للتحديد",
|
||||||
"to_trash": "حذف",
|
"to_trash": "حذف",
|
||||||
"toggle_settings": "الإعدادات",
|
"toggle_settings": "الإعدادات",
|
||||||
|
"toggle_theme_description": "تبديل السمة",
|
||||||
"total": "الإجمالي",
|
"total": "الإجمالي",
|
||||||
"total_usage": "الاستخدام الإجمالي",
|
"total_usage": "الاستخدام الإجمالي",
|
||||||
"trash": "المهملات",
|
"trash": "المهملات",
|
||||||
@@ -2010,6 +2101,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} ازيل من الارشيف",
|
||||||
@@ -2118,12 +2210,13 @@
|
|||||||
"welcome": "مرحباً",
|
"welcome": "مرحباً",
|
||||||
"welcome_to_immich": "مرحباً بك في Immich",
|
"welcome_to_immich": "مرحباً بك في Immich",
|
||||||
"wifi_name": "اسم شبكة Wi-Fi",
|
"wifi_name": "اسم شبكة Wi-Fi",
|
||||||
"wrong_pin_code": "رمز PIN خاطئ",
|
"workflow": "سير العمل",
|
||||||
|
"wrong_pin_code": "رمز التعريف الشخصي خاطئ",
|
||||||
"year": "سنة",
|
"year": "سنة",
|
||||||
"years_ago": "منذ {years, plural, one {# سنة} other {# سنوات}}",
|
"years_ago": "{years, plural, one {# سنة} other {# سنوات}} مضت",
|
||||||
"yes": "نعم",
|
"yes": "نعم",
|
||||||
"you_dont_have_any_shared_links": "ليس لديك أي روابط مشتركة",
|
"you_dont_have_any_shared_links": "ليس لديك أي روابط مشتركة",
|
||||||
"your_wifi_name": "اسم شبكة Wi-Fi الخاص بك",
|
"your_wifi_name": "اسم شبكة الاتصال اللاسلكي الخاص بك",
|
||||||
"zoom_image": "تكبير الصورة",
|
"zoom_image": "تكبير الصورة",
|
||||||
"zoom_to_bounds": "تكبير حتى حدود المنطقة"
|
"zoom_to_bounds": "تكبير حتى حدود المنطقة"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"action_common_update": "Yenilə",
|
"action_common_update": "Yenilə",
|
||||||
"actions": "Əməliyyatlar",
|
"actions": "Əməliyyatlar",
|
||||||
"active": "Aktiv",
|
"active": "Aktiv",
|
||||||
|
"active_count": "Aktiv: {count}",
|
||||||
"activity": "Fəaliyyət",
|
"activity": "Fəaliyyət",
|
||||||
"activity_changed": "Fəaliyyət {enabled, select, true {aktivdir} other {aktiv deyil}}",
|
"activity_changed": "Fəaliyyət {enabled, select, true {aktivdir} other {aktiv deyil}}",
|
||||||
"add": "Əlavə et",
|
"add": "Əlavə et",
|
||||||
@@ -17,7 +18,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",
|
||||||
@@ -32,7 +32,9 @@
|
|||||||
"add_to_album_toggle": "{album} üçün seçimi dəyişin",
|
"add_to_album_toggle": "{album} üçün seçimi dəyişin",
|
||||||
"add_to_albums": "Albomlara əlavə et",
|
"add_to_albums": "Albomlara əlavə et",
|
||||||
"add_to_albums_count": "({count}) albomlarına əlavə et",
|
"add_to_albums_count": "({count}) albomlarına əlavə et",
|
||||||
|
"add_to_bottom_bar": "Əlavə et",
|
||||||
"add_to_shared_album": "Paylaşılan alboma əlavə et",
|
"add_to_shared_album": "Paylaşılan alboma əlavə et",
|
||||||
|
"add_upload_to_stack": "Yeni yüklənmə əlavə et",
|
||||||
"add_url": "URL əlavə et",
|
"add_url": "URL əlavə et",
|
||||||
"added_to_archive": "Arxivə əlavə edildi",
|
"added_to_archive": "Arxivə əlavə edildi",
|
||||||
"added_to_favorites": "Sevimlilələrə əlavə edildi",
|
"added_to_favorites": "Sevimlilələrə əlavə edildi",
|
||||||
@@ -64,7 +66,6 @@
|
|||||||
"confirm_user_password_reset": "{user} adlı istifadəçinin şifrəsini sıfırlamaq istədiyinizdən əminmisiniz?",
|
"confirm_user_password_reset": "{user} adlı istifadəçinin şifrəsini sıfırlamaq istədiyinizdən əminmisiniz?",
|
||||||
"disable_login": "Giriş etməni söndür",
|
"disable_login": "Giriş etməni söndür",
|
||||||
"duplicate_detection_job_description": "Bənzər şəkilləri tapmaq üçün maşın öyrənməsini işə salın. Bu prosses Smart Search funksiyasına əsaslanır",
|
"duplicate_detection_job_description": "Bənzər şəkilləri tapmaq üçün maşın öyrənməsini işə salın. Bu prosses Smart Search funksiyasına əsaslanır",
|
||||||
"external_library_management": "Xarici kitabxana idarəetməsi",
|
|
||||||
"face_detection": "Üz tanıma",
|
"face_detection": "Üz tanıma",
|
||||||
"force_delete_user_warning": "XƏBƏRDARLIQ: Bu əməliyyat istifadəçi və bütün məlumatları siləcəkdir. Bu prossesi və silinən faylları geri qaytarmaq olmaz.",
|
"force_delete_user_warning": "XƏBƏRDARLIQ: Bu əməliyyat istifadəçi və bütün məlumatları siləcəkdir. Bu prossesi və silinən faylları geri qaytarmaq olmaz.",
|
||||||
"image_format_description": "WebP, JPEG faylına görə daha kiçik həcmə sahibdir, lakin onu kodlaşdırmaq daha çox vaxt alır.",
|
"image_format_description": "WebP, JPEG faylına görə daha kiçik həcmə sahibdir, lakin onu kodlaşdırmaq daha çox vaxt alır.",
|
||||||
@@ -80,12 +81,10 @@
|
|||||||
"job_not_concurrency_safe": "Bu iş eyni vaxtda icra üçün təhlükəsiz deyil.",
|
"job_not_concurrency_safe": "Bu iş eyni vaxtda icra üçün təhlükəsiz deyil.",
|
||||||
"job_settings": "Tapşırıq parametrləri",
|
"job_settings": "Tapşırıq parametrləri",
|
||||||
"job_settings_description": "Parallel şəkildə fəaliyyət göstərən tapşırıqları idarə et",
|
"job_settings_description": "Parallel şəkildə fəaliyyət göstərən tapşırıqları idarə et",
|
||||||
"job_status": "Tapşırıq statusu",
|
|
||||||
"jobs_delayed": "{jobCount, plural, other {# gecikməli}}",
|
"jobs_delayed": "{jobCount, plural, other {# gecikməli}}",
|
||||||
"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": "Дадаць партнёра",
|
||||||
@@ -73,7 +72,6 @@
|
|||||||
"disable_login": "Адключыць уваход",
|
"disable_login": "Адключыць уваход",
|
||||||
"duplicate_detection_job_description": "Запусціць машыннае навучанне на актывах для выяўлення падобных выяў. Залежыць ад Smart Search",
|
"duplicate_detection_job_description": "Запусціць машыннае навучанне на актывах для выяўлення падобных выяў. Залежыць ад Smart Search",
|
||||||
"exclusion_pattern_description": "Шаблоны выключэння дазваляюць ігнараваць файлы і папкі пры сканаванні вашай бібліятэкі. Гэта карысна, калі ў вас ёсць папкі, якія змяшчаюць файлы, якія вы не хочаце імпартаваць, напрыклад, файлы RAW.",
|
"exclusion_pattern_description": "Шаблоны выключэння дазваляюць ігнараваць файлы і папкі пры сканаванні вашай бібліятэкі. Гэта карысна, калі ў вас ёсць папкі, якія змяшчаюць файлы, якія вы не хочаце імпартаваць, напрыклад, файлы RAW.",
|
||||||
"external_library_management": "Кіраванне знешняй бібліятэкай",
|
|
||||||
"face_detection": "Выяўленне твараў",
|
"face_detection": "Выяўленне твараў",
|
||||||
"face_detection_description": "Выяўляць твары на фотаздымках і відэа з дапамогай машыннага навучання. Для відэа ўлічваецца толькі мініяцюра. \"Абнавіць\" (пера)апрацоўвае ўсе медыя. \"Скінуць\" дадаткова ачышчае ўсе бягучыя даныя пра твары. \"Адсутнічае\" ставіць у чаргу медыя, якія яшчэ не былі апрацаваныя. Выяўленыя твары будуць пастаўлены ў чаргу для распазнавання асоб пасля завяршэння выяўлення твараў, з групаваннем іх па існуючых або новых людзях.",
|
"face_detection_description": "Выяўляць твары на фотаздымках і відэа з дапамогай машыннага навучання. Для відэа ўлічваецца толькі мініяцюра. \"Абнавіць\" (пера)апрацоўвае ўсе медыя. \"Скінуць\" дадаткова ачышчае ўсе бягучыя даныя пра твары. \"Адсутнічае\" ставіць у чаргу медыя, якія яшчэ не былі апрацаваныя. Выяўленыя твары будуць пастаўлены ў чаргу для распазнавання асоб пасля завяршэння выяўлення твараў, з групаваннем іх па існуючых або новых людзях.",
|
||||||
"facial_recognition_job_description": "Групаваць выяўленыя твары па асобах. Гэты этап выконваецца пасля завяршэння выяўлення твараў. \"Скінуць\" (паўторна) перагрупоўвае ўсе твары. \"Адсутнічае\" ставіць у чаргу твары, якія яшчэ не прыпісаныя да якой-небудзь асобы.",
|
"facial_recognition_job_description": "Групаваць выяўленыя твары па асобах. Гэты этап выконваецца пасля завяршэння выяўлення твараў. \"Скінуць\" (паўторна) перагрупоўвае ўсе твары. \"Адсутнічае\" ставіць у чаргу твары, якія яшчэ не прыпісаныя да якой-небудзь асобы.",
|
||||||
@@ -104,7 +102,6 @@
|
|||||||
"job_not_concurrency_safe": "Гэта заданне небяспечнае для канкурэнтнага(адначасовага, паралельнага) выканання.",
|
"job_not_concurrency_safe": "Гэта заданне небяспечнае для канкурэнтнага(адначасовага, паралельнага) выканання.",
|
||||||
"job_settings": "Налады заданняў",
|
"job_settings": "Налады заданняў",
|
||||||
"job_settings_description": "Кіраваць наладамі адначасовага (паралельнага) выканання задання",
|
"job_settings_description": "Кіраваць наладамі адначасовага (паралельнага) выканання задання",
|
||||||
"job_status": "Становішча задання",
|
|
||||||
"jobs_delayed": "{jobCount, plural, other {# адкладзена}}",
|
"jobs_delayed": "{jobCount, plural, other {# адкладзена}}",
|
||||||
"jobs_failed": "{jobCount, plural, other {# не выканалася}}",
|
"jobs_failed": "{jobCount, plural, other {# не выканалася}}",
|
||||||
"library_created": "Створана бібліятэка: {library}",
|
"library_created": "Створана бібліятэка: {library}",
|
||||||
@@ -172,7 +169,6 @@
|
|||||||
"user_restore_description": "Уліковы запіс карыстальніка <b>{user}</b> будзе адноўлены.",
|
"user_restore_description": "Уліковы запіс карыстальніка <b>{user}</b> будзе адноўлены.",
|
||||||
"user_settings": "Налады карыстальніка",
|
"user_settings": "Налады карыстальніка",
|
||||||
"user_settings_description": "Кіраванне наладамі карыстальніка",
|
"user_settings_description": "Кіраванне наладамі карыстальніка",
|
||||||
"user_successfully_removed": "Карыстальнік {email} быў паспяхова выдалены.",
|
|
||||||
"version_check_enabled_description": "Уключыць праверку версіі",
|
"version_check_enabled_description": "Уключыць праверку версіі",
|
||||||
"version_check_implications": "Функцыя праверкі версіі перыядычна звяртаецца да github.com",
|
"version_check_implications": "Функцыя праверкі версіі перыядычна звяртаецца да github.com",
|
||||||
"version_check_settings": "Праверка версіі",
|
"version_check_settings": "Праверка версіі",
|
||||||
@@ -318,8 +314,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": "Рэдагаваць месцазнаходжанне",
|
||||||
@@ -398,6 +392,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": "Фота",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user